mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 04:36:31 +08:00
refactor: verticalize tag management and batch bindings (#35840)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
7e6745e105
commit
00bf3f83f2
@ -32,12 +32,7 @@ class TagBindingPayload(BaseModel):
|
||||
|
||||
|
||||
class TagBindingRemovePayload(BaseModel):
|
||||
tag_id: str = Field(description="Tag ID to remove")
|
||||
target_id: str = Field(description="Target ID to unbind tag from")
|
||||
type: TagType = Field(description="Tag type")
|
||||
|
||||
|
||||
class TagBindingItemDeletePayload(BaseModel):
|
||||
tag_ids: list[str] = Field(description="Tag IDs to remove", min_length=1)
|
||||
target_id: str = Field(description="Target ID to unbind tag from")
|
||||
type: TagType = Field(description="Tag type")
|
||||
|
||||
@ -75,7 +70,6 @@ register_schema_models(
|
||||
TagBasePayload,
|
||||
TagBindingPayload,
|
||||
TagBindingRemovePayload,
|
||||
TagBindingItemDeletePayload,
|
||||
TagListQueryParam,
|
||||
TagResponse,
|
||||
)
|
||||
@ -184,13 +178,13 @@ def _create_tag_bindings() -> tuple[dict[str, str], int]:
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
def _remove_tag_binding() -> tuple[dict[str, str], int]:
|
||||
def _remove_tag_bindings() -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission()
|
||||
|
||||
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(
|
||||
tag_id=payload.tag_id,
|
||||
tag_ids=payload.tag_ids,
|
||||
target_id=payload.target_id,
|
||||
type=payload.type,
|
||||
)
|
||||
@ -211,54 +205,15 @@ class TagBindingCollectionApi(Resource):
|
||||
return _create_tag_bindings()
|
||||
|
||||
|
||||
@console_ns.route("/tag-bindings/<uuid:id>")
|
||||
class TagBindingItemApi(Resource):
|
||||
"""Canonical item resource for tag binding deletion."""
|
||||
|
||||
@console_ns.doc("delete_tag_binding")
|
||||
@console_ns.doc(params={"id": "Tag ID"})
|
||||
@console_ns.expect(console_ns.models[TagBindingItemDeletePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def delete(self, id):
|
||||
_require_tag_binding_edit_permission()
|
||||
payload = TagBindingItemDeletePayload.model_validate(console_ns.payload or {})
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(
|
||||
tag_id=str(id),
|
||||
target_id=payload.target_id,
|
||||
type=payload.type,
|
||||
)
|
||||
)
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
@console_ns.route("/tag-bindings/create")
|
||||
class DeprecatedTagBindingCreateApi(Resource):
|
||||
"""Deprecated verb-based alias for tag binding creation."""
|
||||
|
||||
@console_ns.doc("create_tag_binding_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated legacy alias. Use POST /tag-bindings instead.")
|
||||
@console_ns.expect(console_ns.models[TagBindingPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
return _create_tag_bindings()
|
||||
|
||||
|
||||
@console_ns.route("/tag-bindings/remove")
|
||||
class DeprecatedTagBindingRemoveApi(Resource):
|
||||
"""Deprecated verb-based alias for tag binding deletion."""
|
||||
class TagBindingRemoveApi(Resource):
|
||||
"""Batch resource for tag binding deletion."""
|
||||
|
||||
@console_ns.doc("delete_tag_binding_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated legacy alias. Use DELETE /tag-bindings/{id} instead.")
|
||||
@console_ns.doc("remove_tag_bindings")
|
||||
@console_ns.doc(description="Remove one or more tag bindings from a target.")
|
||||
@console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
return _remove_tag_binding()
|
||||
return _remove_tag_bindings()
|
||||
|
||||
@ -2,7 +2,7 @@ from typing import Any, Literal, cast
|
||||
|
||||
from flask import request
|
||||
from flask_restx import marshal
|
||||
from pydantic import BaseModel, Field, TypeAdapter, field_validator
|
||||
from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
@ -100,9 +100,27 @@ class TagBindingPayload(BaseModel):
|
||||
|
||||
|
||||
class TagUnbindingPayload(BaseModel):
|
||||
tag_id: str
|
||||
"""Accept the legacy single-tag Service API payload while exposing a normalized tag_ids list internally."""
|
||||
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
tag_id: str | None = None
|
||||
target_id: str
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_legacy_tag_id(cls, data: object) -> object:
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
if not data.get("tag_ids") and data.get("tag_id"):
|
||||
return {**data, "tag_ids": [data["tag_id"]]}
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tag_ids(self) -> "TagUnbindingPayload":
|
||||
if not self.tag_ids:
|
||||
raise ValueError("Tag IDs is required.")
|
||||
return self
|
||||
|
||||
|
||||
class DatasetListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
@ -601,11 +619,11 @@ class DatasetTagBindingApi(DatasetApiResource):
|
||||
@service_api_ns.route("/datasets/tags/unbinding")
|
||||
class DatasetTagUnbindingApi(DatasetApiResource):
|
||||
@service_api_ns.expect(service_api_ns.models[TagUnbindingPayload.__name__])
|
||||
@service_api_ns.doc("unbind_dataset_tag")
|
||||
@service_api_ns.doc(description="Unbind a tag from a dataset")
|
||||
@service_api_ns.doc("unbind_dataset_tags")
|
||||
@service_api_ns.doc(description="Unbind tags from a dataset")
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
204: "Tag unbound successfully",
|
||||
204: "Tags unbound successfully",
|
||||
401: "Unauthorized - invalid API token",
|
||||
403: "Forbidden - insufficient permissions",
|
||||
}
|
||||
@ -618,7 +636,7 @@ class DatasetTagUnbindingApi(DatasetApiResource):
|
||||
|
||||
payload = TagUnbindingPayload.model_validate(service_api_ns.payload or {})
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(tag_id=payload.tag_id, target_id=payload.target_id, type=TagType.KNOWLEDGE)
|
||||
TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE)
|
||||
)
|
||||
|
||||
return "", 204
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask_login import current_user
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from extensions.ext_database import db
|
||||
@ -29,7 +31,7 @@ class TagBindingCreatePayload(BaseModel):
|
||||
|
||||
|
||||
class TagBindingDeletePayload(BaseModel):
|
||||
tag_id: str
|
||||
tag_ids: list[str] = Field(min_length=1)
|
||||
target_id: str
|
||||
type: TagType
|
||||
|
||||
@ -178,13 +180,18 @@ class TagService:
|
||||
@staticmethod
|
||||
def delete_tag_binding(payload: TagBindingDeletePayload):
|
||||
TagService.check_target_exists(payload.type, payload.target_id)
|
||||
tag_binding = db.session.scalar(
|
||||
select(TagBinding)
|
||||
.where(TagBinding.target_id == payload.target_id, TagBinding.tag_id == payload.tag_id)
|
||||
.limit(1)
|
||||
result = cast(
|
||||
CursorResult,
|
||||
db.session.execute(
|
||||
delete(TagBinding).where(
|
||||
TagBinding.target_id == payload.target_id,
|
||||
TagBinding.tag_id.in_(payload.tag_ids),
|
||||
TagBinding.tenant_id == current_user.current_tenant_id,
|
||||
)
|
||||
),
|
||||
)
|
||||
if tag_binding:
|
||||
db.session.delete(tag_binding)
|
||||
|
||||
if result.rowcount:
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -217,10 +217,20 @@ class TestTagUnbindingPayload:
|
||||
"""Test suite for TagUnbindingPayload Pydantic model."""
|
||||
|
||||
def test_payload_with_valid_data(self):
|
||||
payload = TagUnbindingPayload(tag_id="tag_123", target_id="dataset_456")
|
||||
assert payload.tag_id == "tag_123"
|
||||
payload = TagUnbindingPayload(tag_ids=["tag_123"], target_id="dataset_456")
|
||||
assert payload.tag_ids == ["tag_123"]
|
||||
assert payload.target_id == "dataset_456"
|
||||
|
||||
def test_payload_normalizes_legacy_tag_id(self):
|
||||
payload = TagUnbindingPayload(tag_id="tag_123", target_id="dataset_456")
|
||||
assert payload.tag_ids == ["tag_123"]
|
||||
assert payload.target_id == "dataset_456"
|
||||
|
||||
def test_payload_rejects_empty_tag_ids(self):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
TagUnbindingPayload(tag_ids=[], target_id="dataset_456")
|
||||
assert "Tag IDs is required" in str(exc_info.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@ -1012,6 +1022,36 @@ class TestDatasetTagUnbindingApiPost:
|
||||
mock_current_user.is_dataset_editor = True
|
||||
mock_tag_svc.delete_tag_binding.return_value = None
|
||||
|
||||
with app.test_request_context(
|
||||
"/datasets/tags/unbinding",
|
||||
method="POST",
|
||||
json={"tag_ids": ["tag-1"], "target_id": "ds-1"},
|
||||
):
|
||||
api = DatasetTagUnbindingApi()
|
||||
result = api.post(_=None)
|
||||
|
||||
assert result == ("", 204)
|
||||
from services.tag_service import TagBindingDeletePayload
|
||||
|
||||
mock_tag_svc.delete_tag_binding.assert_called_once_with(
|
||||
TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type="knowledge")
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.dataset.TagService")
|
||||
@patch("controllers.service_api.dataset.dataset.current_user")
|
||||
def test_unbind_legacy_tag_id_success(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_tag_svc,
|
||||
app,
|
||||
):
|
||||
from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi
|
||||
|
||||
mock_current_user.__class__ = Account
|
||||
mock_current_user.has_edit_permission = True
|
||||
mock_current_user.is_dataset_editor = True
|
||||
mock_tag_svc.delete_tag_binding.return_value = None
|
||||
|
||||
with app.test_request_context(
|
||||
"/datasets/tags/unbinding",
|
||||
method="POST",
|
||||
@ -1024,7 +1064,7 @@ class TestDatasetTagUnbindingApiPost:
|
||||
from services.tag_service import TagBindingDeletePayload
|
||||
|
||||
mock_tag_svc.delete_tag_binding.assert_called_once_with(
|
||||
TagBindingDeletePayload(tag_id="tag-1", target_id="ds-1", type="knowledge")
|
||||
TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type="knowledge")
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.dataset.current_user")
|
||||
@ -1038,7 +1078,7 @@ class TestDatasetTagUnbindingApiPost:
|
||||
with app.test_request_context(
|
||||
"/datasets/tags/unbinding",
|
||||
method="POST",
|
||||
json={"tag_id": "tag-1", "target_id": "ds-1"},
|
||||
json={"tag_ids": ["tag-1"], "target_id": "ds-1"},
|
||||
):
|
||||
api = DatasetTagUnbindingApi()
|
||||
with pytest.raises(Forbidden):
|
||||
|
||||
@ -1099,38 +1099,39 @@ class TestTagService:
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
# Create tag
|
||||
tag = self._create_test_tags(
|
||||
db_session_with_containers, mock_external_service_dependencies, tenant.id, "knowledge", 1
|
||||
)[0]
|
||||
# Create tags
|
||||
tags = self._create_test_tags(
|
||||
db_session_with_containers, mock_external_service_dependencies, tenant.id, "knowledge", 2
|
||||
)
|
||||
|
||||
# Create dataset and bind tag
|
||||
# Create dataset and bind tags
|
||||
dataset = self._create_test_dataset(db_session_with_containers, mock_external_service_dependencies, tenant.id)
|
||||
self._create_test_tag_bindings(
|
||||
db_session_with_containers, mock_external_service_dependencies, [tag], dataset.id, tenant.id
|
||||
db_session_with_containers, mock_external_service_dependencies, tags, dataset.id, tenant.id
|
||||
)
|
||||
|
||||
# Verify binding exists before deletion
|
||||
|
||||
binding_before = (
|
||||
# Verify bindings exist before deletion
|
||||
bindings_before = (
|
||||
db_session_with_containers.query(TagBinding)
|
||||
.where(TagBinding.tag_id == tag.id, TagBinding.target_id == dataset.id)
|
||||
.first()
|
||||
.where(TagBinding.tag_id.in_([tag.id for tag in tags]), TagBinding.target_id == dataset.id)
|
||||
.all()
|
||||
)
|
||||
assert binding_before is not None
|
||||
assert len(bindings_before) == 2
|
||||
|
||||
# Act: Execute the method under test
|
||||
delete_payload = TagBindingDeletePayload(type="knowledge", target_id=dataset.id, tag_id=tag.id)
|
||||
delete_payload = TagBindingDeletePayload(
|
||||
type="knowledge", target_id=dataset.id, tag_ids=[tag.id for tag in tags]
|
||||
)
|
||||
TagService.delete_tag_binding(delete_payload)
|
||||
|
||||
# Assert: Verify the expected outcomes
|
||||
# Verify tag binding was deleted
|
||||
binding_after = (
|
||||
# Verify tag bindings were deleted
|
||||
bindings_after = (
|
||||
db_session_with_containers.query(TagBinding)
|
||||
.where(TagBinding.tag_id == tag.id, TagBinding.target_id == dataset.id)
|
||||
.first()
|
||||
.where(TagBinding.tag_id.in_([tag.id for tag in tags]), TagBinding.target_id == dataset.id)
|
||||
.all()
|
||||
)
|
||||
assert binding_after is None
|
||||
assert len(bindings_after) == 0
|
||||
|
||||
def test_delete_tag_binding_non_existent_binding(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
@ -1156,7 +1157,7 @@ class TestTagService:
|
||||
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, tenant.id)
|
||||
|
||||
# Act: Try to delete non-existent binding
|
||||
delete_payload = TagBindingDeletePayload(type="app", target_id=app.id, tag_id=tag.id)
|
||||
delete_payload = TagBindingDeletePayload(type="app", target_id=app.id, tag_ids=[tag.id])
|
||||
TagService.delete_tag_binding(delete_payload)
|
||||
|
||||
# Assert: Verify the expected outcomes
|
||||
|
||||
@ -8,10 +8,8 @@ from werkzeug.exceptions import Forbidden
|
||||
import controllers.console.tag.tags as module
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.tag.tags import (
|
||||
DeprecatedTagBindingCreateApi,
|
||||
DeprecatedTagBindingRemoveApi,
|
||||
TagBindingCollectionApi,
|
||||
TagBindingItemApi,
|
||||
TagBindingRemoveApi,
|
||||
TagListApi,
|
||||
TagUpdateDeleteApi,
|
||||
)
|
||||
@ -249,39 +247,13 @@ class TestTagBindingCollectionApi:
|
||||
method(api)
|
||||
|
||||
|
||||
class TestDeprecatedTagBindingCreateApi:
|
||||
def test_create_success(self, app, admin_user, payload_patch):
|
||||
api = DeprecatedTagBindingCreateApi()
|
||||
class TestTagBindingRemoveApi:
|
||||
def test_remove_success(self, app, admin_user, payload_patch):
|
||||
api = TagBindingRemoveApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
"tag_ids": ["tag-1"],
|
||||
"target_id": "target-1",
|
||||
"type": "knowledge",
|
||||
}
|
||||
|
||||
with app.test_request_context("/", json=payload):
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.tag.tags.current_account_with_tenant",
|
||||
return_value=(admin_user, None),
|
||||
),
|
||||
payload_patch(payload),
|
||||
patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock,
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
save_mock.assert_called_once()
|
||||
assert status == 200
|
||||
assert result["result"] == "success"
|
||||
|
||||
|
||||
class TestTagBindingItemApi:
|
||||
def test_delete_success(self, app, admin_user, payload_patch):
|
||||
api = TagBindingItemApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
payload = {
|
||||
"tag_ids": ["tag-1", "tag-2"],
|
||||
"target_id": "target-1",
|
||||
"type": "knowledge",
|
||||
}
|
||||
@ -295,57 +267,16 @@ class TestTagBindingItemApi:
|
||||
payload_patch(payload),
|
||||
patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock,
|
||||
):
|
||||
result, status = method(api, "tag-1")
|
||||
result, status = method(api)
|
||||
|
||||
delete_mock.assert_called_once()
|
||||
delete_payload = delete_mock.call_args.args[0]
|
||||
assert delete_payload.tag_id == "tag-1"
|
||||
assert delete_payload.target_id == "target-1"
|
||||
assert delete_payload.type == TagType.KNOWLEDGE
|
||||
assert status == 200
|
||||
assert result["result"] == "success"
|
||||
|
||||
def test_delete_forbidden(self, app, readonly_user):
|
||||
api = TagBindingItemApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with patch(
|
||||
"controllers.console.tag.tags.current_account_with_tenant",
|
||||
return_value=(readonly_user, None),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, "tag-1")
|
||||
|
||||
|
||||
class TestDeprecatedTagBindingRemoveApi:
|
||||
def test_remove_success(self, app, admin_user, payload_patch):
|
||||
api = DeprecatedTagBindingRemoveApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
"tag_id": "tag-1",
|
||||
"target_id": "target-1",
|
||||
"type": "knowledge",
|
||||
}
|
||||
|
||||
with app.test_request_context("/", json=payload):
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.tag.tags.current_account_with_tenant",
|
||||
return_value=(admin_user, None),
|
||||
),
|
||||
payload_patch(payload),
|
||||
patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock,
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
delete_mock.assert_called_once()
|
||||
assert delete_payload.tag_ids == ["tag-1", "tag-2"]
|
||||
assert status == 200
|
||||
assert result["result"] == "success"
|
||||
|
||||
def test_remove_forbidden(self, app, readonly_user, payload_patch):
|
||||
api = DeprecatedTagBindingRemoveApi()
|
||||
api = TagBindingRemoveApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
with app.test_request_context("/", json={}):
|
||||
@ -371,32 +302,30 @@ class TestTagResponseModel:
|
||||
|
||||
|
||||
class TestTagBindingRouteMetadata:
|
||||
def test_legacy_write_routes_are_marked_deprecated(self):
|
||||
assert DeprecatedTagBindingCreateApi.post.__apidoc__["deprecated"] is True
|
||||
assert DeprecatedTagBindingRemoveApi.post.__apidoc__["deprecated"] is True
|
||||
def test_write_routes_are_not_deprecated(self):
|
||||
assert TagBindingCollectionApi.post.__apidoc__.get("deprecated") is not True
|
||||
assert TagBindingItemApi.delete.__apidoc__.get("deprecated") is not True
|
||||
assert TagBindingRemoveApi.post.__apidoc__.get("deprecated") is not True
|
||||
|
||||
def test_write_routes_have_stable_operation_ids(self):
|
||||
assert TagBindingCollectionApi.post.__apidoc__["id"] == "create_tag_binding"
|
||||
assert TagBindingItemApi.delete.__apidoc__["id"] == "delete_tag_binding"
|
||||
assert DeprecatedTagBindingCreateApi.post.__apidoc__["id"] == "create_tag_binding_deprecated"
|
||||
assert DeprecatedTagBindingRemoveApi.post.__apidoc__["id"] == "delete_tag_binding_deprecated"
|
||||
assert TagBindingRemoveApi.post.__apidoc__["id"] == "remove_tag_bindings"
|
||||
|
||||
def test_canonical_and_legacy_write_routes_are_registered(self):
|
||||
def test_write_routes_are_registered(self):
|
||||
route_map = {
|
||||
resource.__name__: urls
|
||||
for resource, urls, _route_doc, _kwargs in console_ns.resources
|
||||
if resource.__name__
|
||||
in {
|
||||
"TagBindingCollectionApi",
|
||||
"TagBindingItemApi",
|
||||
"DeprecatedTagBindingCreateApi",
|
||||
"DeprecatedTagBindingRemoveApi",
|
||||
"TagBindingRemoveApi",
|
||||
}
|
||||
}
|
||||
|
||||
assert route_map["TagBindingCollectionApi"] == ("/tag-bindings",)
|
||||
assert route_map["TagBindingItemApi"] == ("/tag-bindings/<uuid:id>",)
|
||||
assert route_map["DeprecatedTagBindingCreateApi"] == ("/tag-bindings/create",)
|
||||
assert route_map["DeprecatedTagBindingRemoveApi"] == ("/tag-bindings/remove",)
|
||||
assert route_map["TagBindingRemoveApi"] == ("/tag-bindings/remove",)
|
||||
|
||||
def test_legacy_write_routes_are_not_registered(self):
|
||||
urls = {url for _resource, resource_urls, _route_doc, _kwargs in console_ns.resources for url in resource_urls}
|
||||
|
||||
assert "/tag-bindings/create" not in urls
|
||||
assert "/tag-bindings/<uuid:id>" not in urls
|
||||
|
||||
@ -155,9 +155,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/account/(commonLayout)/account-page/email-change-modal.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
@ -1824,26 +1821,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/tag-management/__tests__/panel.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/tag-management/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/tag-management/tag-item-editor.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/tag-management/tag-remove-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/text-generation/hooks.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -2354,11 +2331,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -2464,17 +2436,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/create-app-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/item-operation/index.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -5368,11 +5329,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/service/knowledge/use-dataset.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/share.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
stories: [
|
||||
'../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||
'../features/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
// Not working with Storybook Vite framework
|
||||
// '@storybook/addon-onboarding',
|
||||
|
||||
@ -21,20 +21,14 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import AppSideBar from '@/app/components/app-sidebar'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import s from './style.module.css'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
type IAppDetailLayoutProps = {
|
||||
children: React.ReactNode
|
||||
appId: string
|
||||
@ -56,7 +50,6 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
setAppDetail: state.setAppDetail,
|
||||
setAppSidebarExpand: state.setAppSidebarExpand,
|
||||
})))
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
|
||||
const [appDetailRes, setAppDetailRes] = useState<App | null>(null)
|
||||
const [navigation, setNavigation] = useState<Array<{
|
||||
@ -174,9 +167,6 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
<div className="grow overflow-hidden bg-components-panel-bg">
|
||||
{children}
|
||||
</div>
|
||||
{showTagManagementModal && (
|
||||
<TagManagementModal type="app" show={showTagManagementModal} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,8 +3,7 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
@ -18,22 +17,23 @@ import { useLogout } from '@/service/use-common'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
email: string
|
||||
}
|
||||
|
||||
enum STEP {
|
||||
start = 'start',
|
||||
verifyOrigin = 'verifyOrigin',
|
||||
newEmail = 'newEmail',
|
||||
verifyNew = 'verifyNew',
|
||||
}
|
||||
const STEP = {
|
||||
start: 'start',
|
||||
verifyOrigin: 'verifyOrigin',
|
||||
newEmail: 'newEmail',
|
||||
verifyNew: 'verifyNew',
|
||||
} as const
|
||||
|
||||
const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
type Step = typeof STEP[keyof typeof STEP]
|
||||
|
||||
const EmailChangeModal = ({ onClose, email }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<STEP>(STEP.start)
|
||||
const [step, setStep] = useState<Step>(STEP.start)
|
||||
const [code, setCode] = useState<string>('')
|
||||
const [mail, setMail] = useState<string>('')
|
||||
const [time, setTime] = useState<number>(0)
|
||||
@ -41,13 +41,25 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
const [newEmailExited, setNewEmailExited] = useState<boolean>(false)
|
||||
const [unAvailableEmail, setUnAvailableEmail] = useState<boolean>(false)
|
||||
const [isCheckingEmail, setIsCheckingEmail] = useState<boolean>(false)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const clearCountdown = useCallback(() => {
|
||||
if (!timerRef.current)
|
||||
return
|
||||
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => clearCountdown, [clearCountdown])
|
||||
|
||||
const startCount = () => {
|
||||
clearCountdown()
|
||||
setTime(60)
|
||||
const timer = setInterval(() => {
|
||||
timerRef.current = setInterval(() => {
|
||||
setTime((prev) => {
|
||||
if (prev <= 0) {
|
||||
clearInterval(timer)
|
||||
if (prev <= 1) {
|
||||
clearCountdown()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
@ -181,7 +193,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={open => !open && onClose()}>
|
||||
<Dialog open onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="w-[420px]! p-6!">
|
||||
<div className="absolute top-5 right-5 cursor-pointer p-1.5" onClick={onClose}>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
|
||||
@ -332,11 +332,15 @@ export default function AccountPage() {
|
||||
/>
|
||||
)
|
||||
}
|
||||
<EmailChangeModal
|
||||
show={showUpdateEmail}
|
||||
onClose={() => setShowUpdateEmail(false)}
|
||||
email={userProfile.email}
|
||||
/>
|
||||
{/* Use conditional JSX instead of a mounted controlled Dialog so closing destroys the email-change form session. */}
|
||||
{showUpdateEmail
|
||||
? (
|
||||
<EmailChangeModal
|
||||
onClose={() => setShowUpdateEmail(false)}
|
||||
email={userProfile.email}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -301,9 +301,9 @@ vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children),
|
||||
}))
|
||||
|
||||
// TagSelector has API dependency (service/tag) - mock for isolated testing
|
||||
vi.mock('@/app/components/base/tag-management/selector', () => ({
|
||||
default: ({ tags }: { tags?: { id: string, name: string }[] }) => {
|
||||
// AppCardTags has tag API dependencies - mock for isolated testing
|
||||
vi.mock('@/features/tag-management/components/app-card-tags', () => ({
|
||||
AppCardTags: ({ tags }: { tags?: { id: string, name: string }[] }) => {
|
||||
return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: { id: string, name: string }) => React.createElement('span', { key: tag.id }, tag.name)))
|
||||
},
|
||||
}))
|
||||
@ -400,13 +400,30 @@ describe('AppCard', () => {
|
||||
it('should handle app with tags', () => {
|
||||
const appWithTags = {
|
||||
...mockApp,
|
||||
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 0 }],
|
||||
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }],
|
||||
}
|
||||
render(<AppCard app={appWithTags} />)
|
||||
// Verify the tag selector component renders
|
||||
expect(screen.getByLabelText('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display refreshed tag names from app props when tag ids stay the same', () => {
|
||||
const firstApp = createMockApp({
|
||||
tags: [{ id: 'tag1', name: 'Old Tag', type: 'app' as const, binding_count: 0 }],
|
||||
})
|
||||
const refreshedApp = createMockApp({
|
||||
tags: [{ id: 'tag1', name: 'New Tag', type: 'app' as const, binding_count: 0 }],
|
||||
})
|
||||
|
||||
const { rerender } = render(<AppCard app={firstApp} />)
|
||||
expect(screen.getByText('Old Tag')).toBeInTheDocument()
|
||||
|
||||
rerender(<AppCard app={refreshedApp} />)
|
||||
|
||||
expect(screen.getByText('New Tag')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Old Tag')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with onRefresh callback', () => {
|
||||
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
|
||||
expect(screen.getByTitle('Test App')).toBeInTheDocument()
|
||||
@ -1167,9 +1184,9 @@ describe('AppCard', () => {
|
||||
const multiTagApp = {
|
||||
...mockApp,
|
||||
tags: [
|
||||
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 0 },
|
||||
{ id: 'tag2', name: 'Tag 2', type: 'app', binding_count: 0 },
|
||||
{ id: 'tag3', name: 'Tag 3', type: 'app', binding_count: 0 },
|
||||
{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 },
|
||||
{ id: 'tag2', name: 'Tag 2', type: 'app' as const, binding_count: 0 },
|
||||
{ id: 'tag3', name: 'Tag 3', type: 'app' as const, binding_count: 0 },
|
||||
],
|
||||
}
|
||||
render(<AppCard app={multiTagApp} />)
|
||||
@ -1324,7 +1341,7 @@ describe('AppCard', () => {
|
||||
|
||||
it('should stop propagation when clicking tag selector area', () => {
|
||||
const multiTagApp = createMockApp({
|
||||
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 0 }],
|
||||
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }],
|
||||
})
|
||||
|
||||
render(<AppCard app={multiTagApp} />)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
@ -29,6 +28,11 @@ vi.mock('@/service/client', () => ({
|
||||
infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options),
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
list: {
|
||||
queryOptions: (options: unknown) => options,
|
||||
},
|
||||
},
|
||||
systemFeatures: {
|
||||
queryKey: () => ['console', 'systemFeatures'],
|
||||
},
|
||||
@ -139,10 +143,6 @@ vi.mock('@/service/use-apps', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
@ -236,10 +236,6 @@ type AppListInfiniteOptions = {
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useTagStore.setState({
|
||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
|
||||
showTagManagementModal: false,
|
||||
})
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockDragging = false
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import type { WorkflowOnlineUser } from '@/models/app'
|
||||
@ -36,11 +35,11 @@ import { Trans, useTranslation } from 'react-i18next'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TagSelector from '@/app/components/base/tag-management/selector'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { AppCardTags } from '@/features/tag-management/components/app-card-tags'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import dynamic from '@/next/dynamic'
|
||||
@ -77,6 +76,7 @@ type AppCardProps = {
|
||||
app: App
|
||||
onlineUsers?: WorkflowOnlineUser[]
|
||||
onRefresh?: () => void
|
||||
onOpenTagManagement?: () => void
|
||||
}
|
||||
|
||||
type AppCardOperationsMenuProps = {
|
||||
@ -207,7 +207,7 @@ const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps>
|
||||
)
|
||||
}
|
||||
|
||||
const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const deleteAppNameInputId = useId()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@ -396,19 +396,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor
|
||||
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
|
||||
|
||||
const appTagsKey = useMemo(() => app.tags.map(tag => tag.id).join(','), [app.tags])
|
||||
const [tagState, setTagState] = useState<{ key: string, tags: Tag[] }>(() => ({
|
||||
key: appTagsKey,
|
||||
tags: app.tags,
|
||||
}))
|
||||
const tags = tagState.key === appTagsKey ? tagState.tags : app.tags
|
||||
const handleTagsUpdate = useCallback((nextTags: Tag[]) => {
|
||||
setTagState({
|
||||
key: appTagsKey,
|
||||
tags: nextTags,
|
||||
})
|
||||
}, [appTagsKey])
|
||||
|
||||
const EditTimeText = useMemo(() => {
|
||||
const timeText = formatTime({
|
||||
date: (app.updated_at || app.created_at) * 1000,
|
||||
@ -523,15 +510,12 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="mr-[41px] w-full grow">
|
||||
<TagSelector
|
||||
position="bl"
|
||||
type="app"
|
||||
targetID={app.id}
|
||||
value={tags.map(tag => tag.id)}
|
||||
selectedTags={tags}
|
||||
onCacheUpdate={handleTagsUpdate}
|
||||
onChange={onRefresh}
|
||||
<div className="mr-[41px] min-w-0 grow overflow-hidden">
|
||||
<AppCardTags
|
||||
appId={app.id}
|
||||
tags={app.tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -11,10 +11,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -24,12 +23,12 @@ import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
import useAppsQueryStateHook from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
|
||||
import NewAppCard from './new-app-card'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), {
|
||||
ssr: false,
|
||||
})
|
||||
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), {
|
||||
@ -57,18 +56,20 @@ const List: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'category',
|
||||
parseAsAppListCategory,
|
||||
)
|
||||
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const appsQuery = useAppsQueryStateHook()
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = appsQuery
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
const [searchKeywords, setSearchKeywords] = useState(keywords)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
@ -245,7 +246,7 @@ const List: FC<Props> = ({
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
@ -279,6 +280,7 @@ const List: FC<Props> = ({
|
||||
app={app}
|
||||
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
|
||||
onRefresh={refetch}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))
|
||||
: <Empty />}
|
||||
@ -302,9 +304,12 @@ const List: FC<Props> = ({
|
||||
)}
|
||||
<CheckModal />
|
||||
<div ref={anchorRef} className="h-0"> </div>
|
||||
{showTagManagementModal && (
|
||||
<TagManagementModal type="app" show={showTagManagementModal} />
|
||||
)}
|
||||
<TagManagementModal
|
||||
type="app"
|
||||
show={showTagManagementModal}
|
||||
onClose={() => setShowTagManagementModal(false)}
|
||||
onTagsChange={refetch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCreateFromDSLModal && (
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
import type { Tag } from '../constant'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import TagRemoveModal from '../tag-remove-modal'
|
||||
|
||||
const mockTag: Tag = {
|
||||
id: 'tag-1',
|
||||
name: 'Frontend',
|
||||
type: 'app',
|
||||
binding_count: 3,
|
||||
}
|
||||
|
||||
describe('TagRemoveModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior and visibility control.
|
||||
describe('Rendering', () => {
|
||||
it('should render modal content when show is true', () => {
|
||||
render(
|
||||
<TagRemoveModal
|
||||
show={true}
|
||||
tag={mockTag}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.tag.delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('"Frontend"')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.deleteTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render modal content when show is false', () => {
|
||||
render(
|
||||
<TagRemoveModal
|
||||
show={false}
|
||||
tag={mockTag}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.tag.delete')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.tag.deleteTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions for closing and confirming actions.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when top-right close icon is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<TagRemoveModal
|
||||
show={true}
|
||||
tag={mockTag}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeIconButton = screen.getByTestId('tag-remove-modal-close-button')
|
||||
expect(closeIconButton).toBeInTheDocument()
|
||||
await user.click(closeIconButton)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<TagRemoveModal
|
||||
show={true}
|
||||
tag={mockTag}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('common.operation.cancel'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onConfirm when delete button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
render(
|
||||
<TagRemoveModal
|
||||
show={true}
|
||||
tag={mockTag}
|
||||
onConfirm={onConfirm}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case for unusual tag names in the title.
|
||||
describe('Edge Cases', () => {
|
||||
it('should render quoted empty tag name safely', () => {
|
||||
render(
|
||||
<TagRemoveModal
|
||||
show={true}
|
||||
tag={{ ...mockTag, name: '' }}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('""')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +0,0 @@
|
||||
export type Tag = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
binding_count: number
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { createTag, fetchTagList } from '@/service/tag'
|
||||
import { useStore as useTagStore } from './store'
|
||||
import TagItemEditor from './tag-item-editor'
|
||||
|
||||
type TagManagementModalProps = {
|
||||
type: 'knowledge' | 'app'
|
||||
show: boolean
|
||||
}
|
||||
const TagManagementModal = ({ show, type }: TagManagementModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const tagList = useTagStore(s => s.tagList)
|
||||
const setTagList = useTagStore(s => s.setTagList)
|
||||
const setShowTagManagementModal = useTagStore(s => s.setShowTagManagementModal)
|
||||
const getTagList = async (type: 'knowledge' | 'app') => {
|
||||
const res = await fetchTagList(type)
|
||||
setTagList(res)
|
||||
}
|
||||
const [pending, setPending] = useState<boolean>(false)
|
||||
const [name, setName] = useState<string>('')
|
||||
const createNewTag = async () => {
|
||||
if (!name)
|
||||
return
|
||||
if (pending)
|
||||
return
|
||||
try {
|
||||
setPending(true)
|
||||
const newTag = await createTag(name, type)
|
||||
toast.success(t('tag.created', { ns: 'common' }))
|
||||
setTagList([
|
||||
newTag,
|
||||
...tagList,
|
||||
])
|
||||
setName('')
|
||||
setPending(false)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('tag.failed', { ns: 'common' }))
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
getTagList(type)
|
||||
}, [type])
|
||||
return (
|
||||
<Modal className="!w-[600px] !max-w-[600px] rounded-xl px-8 py-6" isShow={show} onClose={() => setShowTagManagementModal(false)}>
|
||||
<div className="relative pb-2 text-xl leading-[30px] font-semibold text-text-primary">{t('tag.manageTags', { ns: 'common' })}</div>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={() => setShowTagManagementModal(false)}>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="tag-management-modal-close-button" />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<input className="w-[100px] shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm leading-5 text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary focus:border-solid" placeholder={t('tag.addNew', { ns: 'common' }) || ''} autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} />
|
||||
{tagList.map(tag => (<TagItemEditor key={tag.id} tag={tag} />))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default TagManagementModal
|
||||
@ -1,116 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fetchTagList } from '@/service/tag'
|
||||
import Panel from './panel'
|
||||
import { useStore as useTagStore } from './store'
|
||||
import Trigger from './trigger'
|
||||
|
||||
export type TagSelectorProps = {
|
||||
targetID: string
|
||||
isPopover?: boolean
|
||||
position?: 'bl' | 'br'
|
||||
type: 'knowledge' | 'app'
|
||||
value: string[]
|
||||
selectedTags: Tag[]
|
||||
onCacheUpdate: (tags: Tag[]) => void
|
||||
onChange?: () => void
|
||||
minWidth?: number | string
|
||||
}
|
||||
|
||||
const TagSelector: FC<TagSelectorProps> = ({
|
||||
targetID,
|
||||
isPopover = true,
|
||||
position,
|
||||
type,
|
||||
value,
|
||||
selectedTags,
|
||||
onCacheUpdate,
|
||||
onChange,
|
||||
minWidth,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const tagList = useTagStore(s => s.tagList)
|
||||
const setTagList = useTagStore(s => s.setTagList)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const getTagList = useCallback(async () => {
|
||||
const res = await fetchTagList(type)
|
||||
setTagList(res)
|
||||
}, [setTagList, type])
|
||||
|
||||
const tags = useMemo(() => {
|
||||
if (selectedTags?.length)
|
||||
return selectedTags.filter(selectedTag => tagList.find(tag => tag.id === selectedTag.id)).map(tag => tag.name)
|
||||
return []
|
||||
}, [selectedTags, tagList])
|
||||
|
||||
const placement = useMemo(() => {
|
||||
if (position === 'bl')
|
||||
return 'bottom-start' as const
|
||||
if (position === 'br')
|
||||
return 'bottom-end' as const
|
||||
return 'bottom' as const
|
||||
}, [position])
|
||||
|
||||
const resolvedMinWidth = useMemo(() => {
|
||||
if (minWidth == null)
|
||||
return undefined
|
||||
|
||||
return typeof minWidth === 'number' ? `${minWidth}px` : minWidth
|
||||
}, [minWidth])
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (tags.length)
|
||||
return tags.join(', ')
|
||||
|
||||
return t('tag.addTag', { ns: 'common' })
|
||||
}, [tags, t])
|
||||
|
||||
if (!isPopover)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
aria-label={triggerLabel}
|
||||
className={cn(
|
||||
open ? 'bg-state-base-hover' : 'bg-transparent',
|
||||
'block w-full rounded-lg border-0 p-0 text-left focus:outline-hidden',
|
||||
)}
|
||||
>
|
||||
<Trigger tags={tags} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={4}
|
||||
popupClassName="overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
|
||||
popupProps={{
|
||||
style: {
|
||||
width: 'var(--anchor-width, auto)',
|
||||
minWidth: resolvedMinWidth,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Panel
|
||||
type={type}
|
||||
targetID={targetID}
|
||||
value={value}
|
||||
selectedTags={selectedTags}
|
||||
onCacheUpdate={onCacheUpdate}
|
||||
onChange={onChange}
|
||||
onCreate={getTagList}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagSelector
|
||||
@ -1,19 +0,0 @@
|
||||
import type { Tag } from './constant'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type State = {
|
||||
tagList: Tag[]
|
||||
showTagManagementModal: boolean
|
||||
}
|
||||
|
||||
type Action = {
|
||||
setTagList: (tagList?: Tag[]) => void
|
||||
setShowTagManagementModal: (showTagManagementModal: boolean) => void
|
||||
}
|
||||
|
||||
export const useStore = create<State & Action>(set => ({
|
||||
tagList: [],
|
||||
setTagList: tagList => set(() => ({ tagList })),
|
||||
showTagManagementModal: false,
|
||||
setShowTagManagementModal: showTagManagementModal => set(() => ({ showTagManagementModal })),
|
||||
}))
|
||||
@ -1,48 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
|
||||
type TagRemoveModalProps = {
|
||||
show: boolean
|
||||
tag: Tag
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const TagRemoveModal = ({ show, tag, onConfirm, onClose }: TagRemoveModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={cn('w-[480px] max-w-[480px] p-8')}
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose} data-testid="tag-remove-modal-close-button">
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-default-burn p-3 shadow-xl">
|
||||
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
|
||||
</div>
|
||||
<div className="mt-3 text-xl leading-[30px] font-semibold text-text-primary">
|
||||
{`${t('tag.delete', { ns: 'common' })} `}
|
||||
<span>{`"${tag.name}"`}</span>
|
||||
</div>
|
||||
<div className="my-1 text-sm leading-5 text-text-tertiary">
|
||||
{t('tag.deleteTip', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="flex items-center justify-end pt-6">
|
||||
<Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button className="border-red-700" variant="primary" tone="destructive" onClick={onConfirm}>{t('operation.delete', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagRemoveModal
|
||||
@ -56,8 +56,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
// Mock useDatasetCardState hook
|
||||
vi.mock('../dataset-card/hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
@ -77,6 +75,14 @@ vi.mock('../../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../dataset-card', () => ({
|
||||
default: ({ dataset }: { dataset: DataSet }) => (
|
||||
<article data-testid={`dataset-card-${dataset.id}`}>
|
||||
{dataset.name}
|
||||
</article>
|
||||
),
|
||||
}))
|
||||
|
||||
function createMockDataset(overrides: Partial<DataSet> = {}): DataSet {
|
||||
return {
|
||||
id: 'dataset-1',
|
||||
|
||||
@ -36,11 +36,6 @@ vi.mock('@/context/external-api-panel-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock tag management store
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => false,
|
||||
}))
|
||||
|
||||
// Mock useDocumentTitle hook
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
@ -108,15 +103,16 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
|
||||
}))
|
||||
|
||||
// Mock TagManagementModal
|
||||
vi.mock('@/app/components/base/tag-management', () => ({
|
||||
default: () => <div data-testid="tag-management-modal" />,
|
||||
vi.mock('@/features/tag-management/components/tag-management-modal', () => ({
|
||||
TagManagementModal: ({ show }: { show: boolean }) => show ? <div data-testid="tag-management-modal" /> : null,
|
||||
}))
|
||||
|
||||
// Mock TagFilter
|
||||
vi.mock('@/app/components/base/tag-management/filter', () => ({
|
||||
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
|
||||
vi.mock('@/features/tag-management/components/tag-filter', () => ({
|
||||
TagFilter: ({ onChange, onOpenTagManagement }: { value: string[], onChange: (val: string[]) => void, onOpenTagManagement: () => void }) => (
|
||||
<div data-testid="tag-filter">
|
||||
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
|
||||
<button onClick={onOpenTagManagement}>Manage Tags</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -226,7 +222,7 @@ describe('List', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<List />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
|
||||
expect(mainContainer).toHaveClass('relative', 'flex', 'grow', 'flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
@ -312,15 +308,9 @@ describe('List', () => {
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should show TagManagementModal when showTagManagementModal is true', async () => {
|
||||
vi.doMock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => true, // showTagManagementModal is true
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('../index')
|
||||
|
||||
render(<ListComponent />)
|
||||
it('should show TagManagementModal when tag management is opened', () => {
|
||||
render(<List />)
|
||||
fireEvent.click(screen.getByText('Manage Tags'))
|
||||
|
||||
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -30,8 +30,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
|
||||
vi.mock('../hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
@ -55,8 +53,8 @@ vi.mock('../components/dataset-card-header', () => ({
|
||||
vi.mock('../components/dataset-card-modals', () => ({
|
||||
default: () => <div data-testid="card-modals" />,
|
||||
}))
|
||||
vi.mock('../components/tag-area', () => ({
|
||||
default: ({ onClick }: { onClick: (e: React.MouseEvent) => void, ref?: React.Ref<HTMLDivElement> }) => (
|
||||
vi.mock('@/features/tag-management/components/dataset-card-tags', () => ({
|
||||
DatasetCardTags: ({ onClick }: { onClick: (e: React.MouseEvent) => void }) => (
|
||||
<div data-testid="tag-area" onClick={onClick} />
|
||||
),
|
||||
}))
|
||||
|
||||
@ -1,198 +0,0 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import TagArea from '../tag-area'
|
||||
|
||||
// Mock TagSelector as it's a complex component from base
|
||||
vi.mock('@/app/components/base/tag-management/selector', () => ({
|
||||
default: ({ value, selectedTags, onCacheUpdate, onChange }: {
|
||||
value: string[]
|
||||
selectedTags: Tag[]
|
||||
onCacheUpdate: (tags: Tag[]) => void
|
||||
onChange?: () => void
|
||||
}) => (
|
||||
<div data-testid="tag-selector">
|
||||
<div data-testid="tag-values">{value.join(',')}</div>
|
||||
<div data-testid="selected-count">
|
||||
{selectedTags.length}
|
||||
{' '}
|
||||
tags
|
||||
</div>
|
||||
<button onClick={() => onCacheUpdate([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])}>
|
||||
Update Tags
|
||||
</button>
|
||||
<button onClick={onChange}>
|
||||
Trigger Change
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('TagArea', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const mockTags: Tag[] = [
|
||||
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
|
||||
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
tags: mockTags,
|
||||
setTags: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
isHoveringTagSelector: false,
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TagSelector with correct value', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
|
||||
})
|
||||
|
||||
it('should display selected tags count', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass dataset id to TagSelector', () => {
|
||||
const dataset = createMockDataset({ id: 'custom-dataset-id' })
|
||||
render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty tags', () => {
|
||||
render(<TagArea {...defaultProps} tags={[]} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
|
||||
})
|
||||
|
||||
it('should forward ref correctly', () => {
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
return <TagArea {...defaultProps} ref={ref} />
|
||||
}
|
||||
render(<TestComponent />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when container is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<TagArea {...defaultProps} onClick={onClick} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.click(wrapper)
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setTags when tags are updated', () => {
|
||||
const setTags = vi.fn()
|
||||
render(<TagArea {...defaultProps} setTags={setTags} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Update Tags'))
|
||||
|
||||
expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
it('should call onSuccess when onChange is triggered', () => {
|
||||
const onSuccess = vi.fn()
|
||||
render(<TagArea {...defaultProps} onSuccess={onSuccess} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Trigger Change'))
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).not.toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should show mask when not hovering and has tags', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={false} tags={mockTags} />)
|
||||
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
|
||||
expect(maskDiv).toBeInTheDocument()
|
||||
expect(maskDiv).not.toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should hide mask when hovering', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={true} />)
|
||||
// When hovering, the mask div should have 'hidden' class
|
||||
const maskDiv = container.querySelector('.absolute.right-0.top-0')
|
||||
expect(maskDiv).toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should make TagSelector visible when tags exist', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} tags={mockTags} />)
|
||||
const tagSelectorWrapper = container.querySelector('.visible')
|
||||
expect(tagSelectorWrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined onSuccess', () => {
|
||||
render(<TagArea {...defaultProps} onSuccess={undefined} />)
|
||||
// Should not throw when clicking Trigger Change
|
||||
expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle many tags', () => {
|
||||
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
name: `Tag ${i}`,
|
||||
type: 'knowledge' as const,
|
||||
binding_count: 0,
|
||||
}))
|
||||
render(<TagArea {...defaultProps} tags={manyTags} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,55 +0,0 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import TagSelector from '@/app/components/base/tag-management/selector'
|
||||
|
||||
type TagAreaProps = {
|
||||
dataset: DataSet
|
||||
tags: Tag[]
|
||||
setTags: (tags: Tag[]) => void
|
||||
onSuccess?: () => void
|
||||
isHoveringTagSelector: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
const TagArea = React.forwardRef<HTMLDivElement, TagAreaProps>(({
|
||||
dataset,
|
||||
tags,
|
||||
setTags,
|
||||
onSuccess,
|
||||
isHoveringTagSelector,
|
||||
onClick,
|
||||
}, ref) => (
|
||||
<div
|
||||
className={cn('relative w-full px-3', !dataset.embedding_available && 'opacity-30')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'invisible w-full group-hover:visible',
|
||||
tags.length > 0 && 'visible',
|
||||
)}
|
||||
>
|
||||
<TagSelector
|
||||
position="bl"
|
||||
type="knowledge"
|
||||
targetID={dataset.id}
|
||||
value={tags.map(tag => tag.id)}
|
||||
selectedTags={tags}
|
||||
onCacheUpdate={setTags}
|
||||
onChange={onSuccess}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 right-0 z-5 h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg',
|
||||
isHoveringTagSelector && 'hidden',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
TagArea.displayName = 'TagArea'
|
||||
|
||||
export default TagArea
|
||||
@ -66,15 +66,6 @@ describe('useDatasetCardState', () => {
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should return tags from dataset', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.tags).toEqual(dataset.tags)
|
||||
})
|
||||
|
||||
it('should have initial modal state closed', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
@ -96,36 +87,6 @@ describe('useDatasetCardState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tags State', () => {
|
||||
it('should update tags when setTags is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
it('should sync tags when dataset tags change', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result, rerender } = renderHook(
|
||||
({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
{ initialProps: { dataset } },
|
||||
)
|
||||
|
||||
const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }]
|
||||
const updatedDataset = createMockDataset({ tags: newTags })
|
||||
|
||||
rerender({ dataset: updatedDataset })
|
||||
|
||||
expect(result.current.tags).toEqual(newTags)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Handlers', () => {
|
||||
it('should open rename modal when openRenameModal is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
@ -279,15 +240,6 @@ describe('useDatasetCardState', () => {
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty tags array', () => {
|
||||
const dataset = createMockDataset({ tags: [] })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.tags).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle undefined onSuccess', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
@ -20,11 +19,6 @@ type UseDatasetCardStateOptions = {
|
||||
|
||||
export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const [tags, setTags] = useState<Tag[]>(dataset.tags)
|
||||
|
||||
useEffect(() => {
|
||||
setTags(dataset.tags)
|
||||
}, [dataset.tags])
|
||||
|
||||
// Modal state
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
@ -113,10 +107,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
}, [dataset.id, deleteDatasetMutation, onSuccess, t, closeConfirmDelete])
|
||||
|
||||
return {
|
||||
// Tag state
|
||||
tags,
|
||||
setTags,
|
||||
|
||||
// Modal state
|
||||
modalState,
|
||||
openRenameModal,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { useHover } from 'ahooks'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { DatasetCardTags } from '@/features/tag-management/components/dataset-card-tags'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import CornerLabels from './components/corner-labels'
|
||||
import DatasetCardFooter from './components/dataset-card-footer'
|
||||
@ -10,29 +10,27 @@ import DatasetCardHeader from './components/dataset-card-header'
|
||||
import DatasetCardModals from './components/dataset-card-modals'
|
||||
import Description from './components/description'
|
||||
import OperationsDropdown from './components/operations-dropdown'
|
||||
import TagArea from './components/tag-area'
|
||||
import { useDatasetCardState } from './hooks/use-dataset-card-state'
|
||||
import { useDatasetCardState as useDatasetCardController } from './hooks/use-dataset-card-state'
|
||||
|
||||
const EXTERNAL_PROVIDER = 'external'
|
||||
|
||||
type DatasetCardProps = {
|
||||
dataset: DataSet
|
||||
onSuccess?: () => void
|
||||
onOpenTagManagement?: () => void
|
||||
}
|
||||
|
||||
const DatasetCard = ({
|
||||
dataset,
|
||||
onSuccess,
|
||||
onOpenTagManagement = () => {},
|
||||
}: DatasetCardProps) => {
|
||||
const { push } = useRouter()
|
||||
|
||||
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
|
||||
const tagSelectorRef = useRef<HTMLDivElement>(null)
|
||||
const isHoveringTagSelector = useHover(tagSelectorRef)
|
||||
|
||||
const datasetCard = useDatasetCardController({ dataset, onSuccess })
|
||||
const {
|
||||
tags,
|
||||
setTags,
|
||||
modalState,
|
||||
openRenameModal,
|
||||
closeRenameModal,
|
||||
@ -40,7 +38,7 @@ const DatasetCard = ({
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
onConfirmDelete,
|
||||
} = useDatasetCardState({ dataset, onSuccess })
|
||||
} = datasetCard
|
||||
|
||||
const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER
|
||||
const isPipelineUnpublished = useMemo(() => {
|
||||
@ -72,14 +70,13 @@ const DatasetCard = ({
|
||||
<CornerLabels dataset={dataset} />
|
||||
<DatasetCardHeader dataset={dataset} />
|
||||
<Description dataset={dataset} />
|
||||
<TagArea
|
||||
ref={tagSelectorRef}
|
||||
dataset={dataset}
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
onSuccess={onSuccess}
|
||||
isHoveringTagSelector={isHoveringTagSelector}
|
||||
<DatasetCardTags
|
||||
datasetId={dataset.id}
|
||||
embeddingAvailable={dataset.embedding_available}
|
||||
tags={dataset.tags}
|
||||
onClick={handleTagAreaClick}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onSuccess}
|
||||
/>
|
||||
<DatasetCardFooter dataset={dataset} />
|
||||
<OperationsDropdown
|
||||
|
||||
@ -12,12 +12,14 @@ type Props = {
|
||||
tags: string[]
|
||||
keywords: string
|
||||
includeAll: boolean
|
||||
onOpenTagManagement?: () => void
|
||||
}
|
||||
|
||||
const Datasets = ({
|
||||
tags,
|
||||
keywords,
|
||||
includeAll,
|
||||
onOpenTagManagement = () => {},
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const isCurrentWorkspaceEditor = useAppContextWithSelector(state => state.isCurrentWorkspaceEditor)
|
||||
@ -60,7 +62,7 @@ const Datasets = ({
|
||||
<nav className="grid grow grid-cols-1 content-start gap-3 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{isCurrentWorkspaceEditor && <NewDatasetCard />}
|
||||
{datasetList?.pages.map(({ data: datasets }) => datasets.map(dataset => (
|
||||
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} />),
|
||||
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} onOpenTagManagement={onOpenTagManagement} />),
|
||||
))}
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
|
||||
@ -8,15 +8,13 @@ import { useBoolean, useDebounceFn } from 'ahooks'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TagManagementModal from '@/app/components/base/tag-management'
|
||||
import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
// Hooks
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useExternalApiPanel } from '@/context/external-api-panel-context'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
|
||||
import { useDatasetApiBaseUrl, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
// Components
|
||||
import ExternalAPIPanel from '../external-api/external-api-panel'
|
||||
@ -28,9 +26,10 @@ const List = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { isCurrentWorkspaceOwner } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
|
||||
const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false)
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
useDocumentTitle(t('knowledge', { ns: 'dataset' }))
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
@ -56,7 +55,7 @@ const List = () => {
|
||||
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
|
||||
|
||||
return (
|
||||
<div className="scroll-container relative flex grow flex-col overflow-y-auto bg-background-body">
|
||||
<div className="relative flex grow flex-col overflow-y-auto bg-background-body">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-end gap-x-1 bg-background-body px-12 pt-4 pb-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isCurrentWorkspaceOwner && (
|
||||
@ -69,7 +68,7 @@ const List = () => {
|
||||
tooltip={t('allKnowledgeDescription', { ns: 'dataset' }) as string}
|
||||
/>
|
||||
)}
|
||||
<TagFilter type="knowledge" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
<TagFilter type="knowledge" value={tagFilterValue} onChange={handleTagsChange} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
@ -93,11 +92,14 @@ const List = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Datasets tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} />
|
||||
<Datasets tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
{!systemFeatures.branding.enabled && <DatasetFooter />}
|
||||
{showTagManagementModal && (
|
||||
<TagManagementModal type="knowledge" show={showTagManagementModal} />
|
||||
)}
|
||||
<TagManagementModal
|
||||
type="knowledge"
|
||||
show={showTagManagementModal}
|
||||
onClose={() => setShowTagManagementModal(false)}
|
||||
onTagsChange={invalidDatasetList}
|
||||
/>
|
||||
{showExternalApiPanel && <ExternalAPIPanel onClose={() => setShowExternalApiPanel(false)} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -177,22 +177,6 @@ describe('CreateAppModal', () => {
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onHide when pressing Escape while visible', async () => {
|
||||
const { onHide } = await setup()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onHide when pressing Escape while hidden', async () => {
|
||||
const { onHide } = await setup({ show: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Quota Gating', () => {
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
'use client'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@ -44,6 +42,8 @@ export type CreateAppModalProps = {
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
type CreateAppPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
||||
|
||||
const CreateAppModal = ({
|
||||
show = false,
|
||||
isEditModal = false,
|
||||
@ -84,8 +84,9 @@ const CreateAppModal = ({
|
||||
toast(t('appCustomize.nameRequired', { ns: 'explore' }), { type: 'error' })
|
||||
return
|
||||
}
|
||||
const isValid = maxActiveRequestsInput.trim() !== '' && !isNaN(Number(maxActiveRequestsInput))
|
||||
const payload: any = {
|
||||
const parsedMaxActiveRequests = Number(maxActiveRequestsInput)
|
||||
const isValid = maxActiveRequestsInput.trim() !== '' && !Number.isNaN(parsedMaxActiveRequests)
|
||||
const payload: CreateAppPayload = {
|
||||
name,
|
||||
icon_type: appIcon.type,
|
||||
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
|
||||
@ -94,7 +95,7 @@ const CreateAppModal = ({
|
||||
use_icon_as_answer_icon: useIconAsAnswerIcon,
|
||||
}
|
||||
if (isValid)
|
||||
payload.max_active_requests = Number(maxActiveRequestsInput)
|
||||
payload.max_active_requests = parsedMaxActiveRequests
|
||||
|
||||
onConfirm(payload)
|
||||
onHide()
|
||||
@ -107,103 +108,94 @@ const CreateAppModal = ({
|
||||
handleSubmit()
|
||||
})
|
||||
|
||||
useKeyPress('esc', () => {
|
||||
if (show)
|
||||
onHide()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
className="relative max-w-[480px]! px-8"
|
||||
>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
{isEditModal && (
|
||||
<div className="mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('editAppTitle', { ns: 'app' })}</div>
|
||||
)}
|
||||
{!isEditModal && (
|
||||
<div className="mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('appCustomize.title', { ns: 'explore', name: appName })}</div>
|
||||
)}
|
||||
<div className="mb-9">
|
||||
{/* icon & name */}
|
||||
<div className="pt-2">
|
||||
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionName', { ns: 'app' })}</div>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<AppIcon
|
||||
size="large"
|
||||
onClick={() => { setShowAppIconPicker(true) }}
|
||||
className="cursor-pointer"
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
|
||||
background={appIcon.type === 'image' ? undefined : appIcon.background}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
/>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('newApp.appNamePlaceholder', { ns: 'app' }) || ''}
|
||||
className="h-10 grow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* description */}
|
||||
<div className="pt-2">
|
||||
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionDescription', { ns: 'app' })}</div>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* answer icon */}
|
||||
{isEditModal && (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.ADVANCED_CHAT || appMode === AppModeEnum.AGENT_CHAT) && (
|
||||
<Dialog open={show} onOpenChange={open => !open && onHide()} disablePointerDismissal>
|
||||
<DialogContent className="px-8">
|
||||
<DialogCloseButton />
|
||||
{isEditModal && (
|
||||
<DialogTitle className="mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('editAppTitle', { ns: 'app' })}</DialogTitle>
|
||||
)}
|
||||
{!isEditModal && (
|
||||
<DialogTitle className="mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('appCustomize.title', { ns: 'explore', name: appName })}</DialogTitle>
|
||||
)}
|
||||
<div className="mb-9">
|
||||
{/* icon & name */}
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('answerIcon.title', { ns: 'app' })}</div>
|
||||
<Switch
|
||||
checked={useIconAsAnswerIcon}
|
||||
onCheckedChange={v => setUseIconAsAnswerIcon(v)}
|
||||
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionName', { ns: 'app' })}</div>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<AppIcon
|
||||
size="large"
|
||||
onClick={() => { setShowAppIconPicker(true) }}
|
||||
className="cursor-pointer"
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
|
||||
background={appIcon.type === 'image' ? undefined : appIcon.background}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
/>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('newApp.appNamePlaceholder', { ns: 'app' }) || ''}
|
||||
className="h-10 grow"
|
||||
/>
|
||||
</div>
|
||||
<p className="body-xs-regular text-text-tertiary">{t('answerIcon.descriptionInExplore', { ns: 'app' })}</p>
|
||||
</div>
|
||||
)}
|
||||
{isEditModal && (
|
||||
{/* description */}
|
||||
<div className="pt-2">
|
||||
<div className="mt-2 mb-2 text-sm leading-[20px] font-medium text-text-primary">{t('maxActiveRequests', { ns: 'app' })}</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t('maxActiveRequestsPlaceholder', { ns: 'app' })}
|
||||
value={maxActiveRequestsInput}
|
||||
onChange={(e) => {
|
||||
setMaxActiveRequestsInput(e.target.value)
|
||||
}}
|
||||
className="h-10 w-full"
|
||||
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionDescription', { ns: 'app' })}</div>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="mt-2 mb-0 body-xs-regular text-text-tertiary">{t('maxActiveRequestsTip', { ns: 'app' })}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isEditModal && isAppsFull && <AppsFull className="mt-4" loc="app-explore-create" />}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse">
|
||||
<Button
|
||||
disabled={(!isEditModal && isAppsFull) || !name.trim() || confirmDisabled}
|
||||
className="ml-2 w-24 gap-1"
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<span>{!isEditModal ? t('operation.create', { ns: 'common' }) : t('operation.save', { ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
|
||||
</Button>
|
||||
<Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* answer icon */}
|
||||
{isEditModal && (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.ADVANCED_CHAT || appMode === AppModeEnum.AGENT_CHAT) && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('answerIcon.title', { ns: 'app' })}</div>
|
||||
<Switch
|
||||
checked={useIconAsAnswerIcon}
|
||||
onCheckedChange={v => setUseIconAsAnswerIcon(v)}
|
||||
/>
|
||||
</div>
|
||||
<p className="body-xs-regular text-text-tertiary">{t('answerIcon.descriptionInExplore', { ns: 'app' })}</p>
|
||||
</div>
|
||||
)}
|
||||
{isEditModal && (
|
||||
<div className="pt-2">
|
||||
<div className="mt-2 mb-2 text-sm leading-[20px] font-medium text-text-primary">{t('maxActiveRequests', { ns: 'app' })}</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t('maxActiveRequestsPlaceholder', { ns: 'app' })}
|
||||
value={maxActiveRequestsInput}
|
||||
onChange={(e) => {
|
||||
setMaxActiveRequestsInput(e.target.value)
|
||||
}}
|
||||
className="h-10 w-full"
|
||||
/>
|
||||
<p className="mt-2 mb-0 body-xs-regular text-text-tertiary">{t('maxActiveRequestsTip', { ns: 'app' })}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isEditModal && isAppsFull && <AppsFull className="mt-4" loc="app-explore-create" />}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse">
|
||||
<Button
|
||||
disabled={(!isEditModal && isAppsFull) || !name.trim() || confirmDisabled}
|
||||
className="ml-2 w-24 gap-1"
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<span>{!isEditModal ? t('operation.create', { ns: 'common' }) : t('operation.save', { ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
|
||||
</Button>
|
||||
<Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{showAppIconPicker && (
|
||||
<AppIconPicker
|
||||
initialEmoji={appIcon.type === 'emoji'
|
||||
|
||||
91
web/contract/console/tags.ts
Normal file
91
web/contract/console/tags.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { type } from '@orpc/contract'
|
||||
import { base } from '../base'
|
||||
|
||||
export type TagType = 'knowledge' | 'app'
|
||||
|
||||
export type Tag = {
|
||||
id: string
|
||||
name: string
|
||||
type: TagType
|
||||
binding_count: number
|
||||
}
|
||||
|
||||
export const tagListContract = base
|
||||
.route({
|
||||
path: '/tags',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
query: {
|
||||
type: TagType
|
||||
}
|
||||
}>())
|
||||
.output(type<Tag[]>())
|
||||
|
||||
export const tagCreateContract = base
|
||||
.route({
|
||||
path: '/tags',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(type<{
|
||||
body: {
|
||||
name: string
|
||||
type: TagType
|
||||
}
|
||||
}>())
|
||||
.output(type<Tag>())
|
||||
|
||||
export const tagUpdateContract = base
|
||||
.route({
|
||||
path: '/tags/{tagId}',
|
||||
method: 'PATCH',
|
||||
})
|
||||
.input(type<{
|
||||
params: {
|
||||
tagId: string
|
||||
}
|
||||
body: {
|
||||
name: string
|
||||
}
|
||||
}>())
|
||||
.output(type<unknown>())
|
||||
|
||||
export const tagDeleteContract = base
|
||||
.route({
|
||||
path: '/tags/{tagId}',
|
||||
method: 'DELETE',
|
||||
})
|
||||
.input(type<{
|
||||
params: {
|
||||
tagId: string
|
||||
}
|
||||
}>())
|
||||
.output(type<unknown>())
|
||||
|
||||
export const tagBindingCreateContract = base
|
||||
.route({
|
||||
path: '/tag-bindings',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(type<{
|
||||
body: {
|
||||
tag_ids: string[]
|
||||
target_id: string
|
||||
type: TagType
|
||||
}
|
||||
}>())
|
||||
.output(type<unknown>())
|
||||
|
||||
export const tagBindingRemoveContract = base
|
||||
.route({
|
||||
path: '/tag-bindings/remove',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(type<{
|
||||
body: {
|
||||
tag_ids: string[]
|
||||
target_id: string
|
||||
type: TagType
|
||||
}
|
||||
}>())
|
||||
.output(type<unknown>())
|
||||
@ -18,6 +18,14 @@ import { changePreferredProviderTypeContract, modelProvidersModelsContract } fro
|
||||
import { notificationContract, notificationDismissContract } from './console/notification'
|
||||
import { pluginCheckInstalledContract, pluginLatestVersionsContract } from './console/plugins'
|
||||
import { systemFeaturesContract } from './console/system'
|
||||
import {
|
||||
tagBindingCreateContract,
|
||||
tagBindingRemoveContract,
|
||||
tagCreateContract,
|
||||
tagDeleteContract,
|
||||
tagListContract,
|
||||
tagUpdateContract,
|
||||
} from './console/tags'
|
||||
import {
|
||||
triggerOAuthConfigContract,
|
||||
triggerOAuthConfigureContract,
|
||||
@ -103,6 +111,14 @@ export const consoleRouterContract = {
|
||||
workflowComments: workflowCommentContracts,
|
||||
notification: notificationContract,
|
||||
notificationDismiss: notificationDismissContract,
|
||||
tags: {
|
||||
list: tagListContract,
|
||||
create: tagCreateContract,
|
||||
update: tagUpdateContract,
|
||||
delete: tagDeleteContract,
|
||||
bind: tagBindingCreateContract,
|
||||
unbind: tagBindingRemoveContract,
|
||||
},
|
||||
triggers: {
|
||||
list: triggersContract,
|
||||
providerInfo: triggerProviderInfoContract,
|
||||
|
||||
152
web/features/tag-management/__tests__/dataset-card-tags.spec.tsx
Normal file
152
web/features/tag-management/__tests__/dataset-card-tags.spec.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DatasetCardTags } from '../components/dataset-card-tags'
|
||||
|
||||
// Mock TagSelector as it's a complex component from base
|
||||
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
||||
TagSelector: ({ selectedTagIds, selectedTags, onOpenTagManagement }: {
|
||||
selectedTagIds: string[]
|
||||
selectedTags: Tag[]
|
||||
onOpenTagManagement?: () => void
|
||||
}) => (
|
||||
<div data-testid="tag-selector">
|
||||
<div data-testid="tag-values">{selectedTagIds.join(',')}</div>
|
||||
<div data-testid="selected-count">
|
||||
{selectedTags.length}
|
||||
{' '}
|
||||
tags
|
||||
</div>
|
||||
<button onClick={onOpenTagManagement}>
|
||||
Open Management
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DatasetCardTags', () => {
|
||||
const mockTags: Tag[] = [
|
||||
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
|
||||
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
datasetId: 'dataset-1',
|
||||
embeddingAvailable: true,
|
||||
tags: mockTags,
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DatasetCardTags {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TagSelector with correct value', () => {
|
||||
render(<DatasetCardTags {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
|
||||
})
|
||||
|
||||
it('should display selected tags count', () => {
|
||||
render(<DatasetCardTags {...defaultProps} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass dataset id to TagSelector', () => {
|
||||
render(<DatasetCardTags {...defaultProps} datasetId="custom-dataset-id" />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty tags', () => {
|
||||
render(<DatasetCardTags {...defaultProps} tags={[]} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when container is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<DatasetCardTags {...defaultProps} onClick={onClick} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.click(wrapper)
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should open tag management when requested', () => {
|
||||
const onOpenTagManagement = vi.fn()
|
||||
render(<DatasetCardTags {...defaultProps} onOpenTagManagement={onOpenTagManagement} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Management'))
|
||||
|
||||
expect(onOpenTagManagement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const { container } = render(<DatasetCardTags {...defaultProps} embeddingAvailable={false} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const { container } = render(<DatasetCardTags {...defaultProps} embeddingAvailable={true} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).not.toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should hide mask with CSS when the tag area is hovered', () => {
|
||||
const { container } = render(<DatasetCardTags {...defaultProps} />)
|
||||
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
|
||||
expect(maskDiv).toBeInTheDocument()
|
||||
expect(maskDiv).toHaveClass('group-hover/tag-area:hidden')
|
||||
expect(maskDiv).toHaveClass('group-hover:bg-tag-selector-mask-hover-bg')
|
||||
})
|
||||
|
||||
it('should keep TagSelector visible when tags are empty', () => {
|
||||
const { container } = render(<DatasetCardTags {...defaultProps} tags={[]} />)
|
||||
const tagSelectorWrapper = screen.getByTestId('tag-selector').parentElement
|
||||
|
||||
expect(tagSelectorWrapper).toBeInTheDocument()
|
||||
expect(tagSelectorWrapper).toHaveClass('w-full')
|
||||
expect(tagSelectorWrapper).not.toHaveClass('invisible')
|
||||
expect(container.querySelector('.invisible')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep TagSelector visible when tags exist', () => {
|
||||
const { container } = render(<DatasetCardTags {...defaultProps} />)
|
||||
const tagSelectorWrapper = screen.getByTestId('tag-selector').parentElement
|
||||
|
||||
expect(tagSelectorWrapper).toBeInTheDocument()
|
||||
expect(tagSelectorWrapper).toHaveClass('w-full')
|
||||
expect(container.querySelector('.invisible')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined onOpenTagManagement', () => {
|
||||
render(<DatasetCardTags {...defaultProps} onOpenTagManagement={undefined} />)
|
||||
expect(() => fireEvent.click(screen.getByText('Open Management'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle many tags', () => {
|
||||
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
name: `Tag ${i}`,
|
||||
type: 'knowledge' as const,
|
||||
binding_count: 0,
|
||||
}))
|
||||
render(<DatasetCardTags {...defaultProps} tags={manyTags} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,28 +1,15 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import * as React from 'react'
|
||||
import TagFilter from '../filter'
|
||||
import { useStore as useTagStore } from '../store'
|
||||
import { TagFilter } from '../components/tag-filter'
|
||||
|
||||
const { fetchTagList } = vi.hoisted(() => ({
|
||||
fetchTagList: vi.fn(),
|
||||
}))
|
||||
// Mock the tag service (API layer)
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList,
|
||||
const { mockUseQueryData } = vi.hoisted(() => ({
|
||||
mockUseQueryData: { current: [] as Tag[] },
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => {
|
||||
return {
|
||||
useMount: (fn: () => void) => {
|
||||
React.useEffect(() => {
|
||||
fn()
|
||||
}, [])
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => ({ data: mockUseQueryData.current }),
|
||||
}))
|
||||
|
||||
const mockTags: Tag[] = [
|
||||
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
|
||||
@ -47,11 +34,7 @@ const i18n = {
|
||||
describe('TagFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fetchTagList).mockResolvedValue(mockTags)
|
||||
// Pre-populate the Zustand store with tags so dropdown content is available
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: mockTags, showTagManagementModal: false })
|
||||
})
|
||||
mockUseQueryData.current = mockTags
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -196,12 +179,13 @@ describe('TagFilter', () => {
|
||||
|
||||
it('should open manage tags modal and close dropdown', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagFilter {...defaultProps} />)
|
||||
const onOpenTagManagement = vi.fn()
|
||||
render(<TagFilter {...defaultProps} onOpenTagManagement={onOpenTagManagement} />)
|
||||
|
||||
await user.click(screen.getByText(i18n.placeholder))
|
||||
await user.click(screen.getByText(i18n.manageTags))
|
||||
|
||||
expect(useTagStore.getState().showTagManagementModal).toBe(true)
|
||||
expect(onOpenTagManagement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -257,42 +241,10 @@ describe('TagFilter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Fetching', () => {
|
||||
it('should fetch tag list on mount', () => {
|
||||
render(<TagFilter {...defaultProps} />)
|
||||
expect(fetchTagList).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('should fetch with correct type parameter', () => {
|
||||
render(<TagFilter {...defaultProps} type="knowledge" />)
|
||||
expect(fetchTagList).toHaveBeenCalledWith('knowledge')
|
||||
})
|
||||
|
||||
it('should update the store with fetched tags', async () => {
|
||||
const freshTags: Tag[] = [
|
||||
{ id: 'new-1', name: 'NewTag', type: 'app', binding_count: 0 },
|
||||
]
|
||||
vi.mocked(fetchTagList).mockResolvedValue(freshTags)
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [] })
|
||||
})
|
||||
|
||||
render(<TagFilter {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTagStore.getState().tagList).toEqual(freshTags)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should show no tag message when tag list is completely empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Mock fetchTagList to return empty so useMount doesn't repopulate
|
||||
vi.mocked(fetchTagList).mockResolvedValue([])
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [] })
|
||||
})
|
||||
mockUseQueryData.current = []
|
||||
|
||||
render(<TagFilter {...defaultProps} />)
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { act } from 'react'
|
||||
import { useStore as useTagStore } from '../store'
|
||||
import TagItemEditor from '../tag-item-editor'
|
||||
import { TagItemEditor } from '../components/tag-item-editor'
|
||||
|
||||
const tagMocks = vi.hoisted(() => {
|
||||
const record = vi.fn()
|
||||
@ -25,9 +23,22 @@ const tagMocks = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
updateTag: tagMocks.updateTag,
|
||||
deleteTag: tagMocks.deleteTag,
|
||||
vi.mock('../hooks/use-tag-mutations', () => ({
|
||||
useUpdateTagMutation: () => ({
|
||||
mutate: ({ params, body }: { params: { tagId: string }, body: { name: string } }, options?: { onSuccess?: () => void, onError?: () => void }) => {
|
||||
Promise.resolve(tagMocks.updateTag(params.tagId, body.name))
|
||||
.then(() => options?.onSuccess?.())
|
||||
.catch(() => options?.onError?.())
|
||||
},
|
||||
}),
|
||||
useDeleteTagMutation: () => ({
|
||||
isPending: false,
|
||||
mutate: ({ params }: { params: { tagId: string } }, options?: { onSuccess?: () => void, onError?: () => void }) => {
|
||||
Promise.resolve(tagMocks.deleteTag(params.tagId))
|
||||
.then(() => options?.onSuccess?.())
|
||||
.catch(() => options?.onError?.())
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@ -58,24 +69,11 @@ const baseTag: Tag = {
|
||||
binding_count: 3,
|
||||
}
|
||||
|
||||
const anotherTag: Tag = {
|
||||
id: 'tag-2',
|
||||
name: 'Backend',
|
||||
type: 'app',
|
||||
binding_count: 1,
|
||||
}
|
||||
|
||||
describe('TagItemEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(tagMocks.updateTag).mockResolvedValue(undefined)
|
||||
vi.mocked(tagMocks.deleteTag).mockResolvedValue(undefined)
|
||||
act(() => {
|
||||
useTagStore.setState({
|
||||
tagList: [baseTag, anotherTag],
|
||||
showTagManagementModal: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Rendering behavior for initial tag display.
|
||||
@ -120,7 +118,6 @@ describe('TagItemEditor', () => {
|
||||
type: 'success',
|
||||
message: 'common.actionMsg.modifiedSuccessfully',
|
||||
})
|
||||
expect(useTagStore.getState().tagList.find(tag => tag.id === 'tag-1')?.name).toBe('Frontend V2')
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -177,7 +174,6 @@ describe('TagItemEditor', () => {
|
||||
type: 'error',
|
||||
message: 'common.actionMsg.modifiedUnsuccessfully',
|
||||
})
|
||||
expect(useTagStore.getState().tagList.find(tag => tag.id === 'tag-1')?.name).toBe('Frontend')
|
||||
})
|
||||
})
|
||||
|
||||
@ -186,9 +182,6 @@ describe('TagItemEditor', () => {
|
||||
it('should delete immediately when binding count is zero', async () => {
|
||||
const user = userEvent.setup()
|
||||
const removableTag: Tag = { ...baseTag, binding_count: 0 }
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [removableTag, anotherTag] })
|
||||
})
|
||||
render(<TagItemEditor tag={removableTag} />)
|
||||
|
||||
const removeButton = screen.getByTestId('tag-item-editor-remove-button')
|
||||
@ -202,7 +195,6 @@ describe('TagItemEditor', () => {
|
||||
type: 'success',
|
||||
message: 'common.actionMsg.modifiedSuccessfully',
|
||||
})
|
||||
expect(useTagStore.getState().tagList.find(tag => tag.id === 'tag-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should open confirm modal and delete on confirm when binding count is non-zero', async () => {
|
||||
@ -243,9 +235,6 @@ describe('TagItemEditor', () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(tagMocks.deleteTag).mockRejectedValueOnce(new Error('delete failed'))
|
||||
const removableTag: Tag = { ...baseTag, binding_count: 0 }
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [removableTag, anotherTag] })
|
||||
})
|
||||
render(<TagItemEditor tag={removableTag} />)
|
||||
|
||||
const removeButton = screen.getByTestId('tag-item-editor-remove-button')
|
||||
@ -258,31 +247,6 @@ describe('TagItemEditor', () => {
|
||||
type: 'error',
|
||||
message: 'common.actionMsg.modifiedUnsuccessfully',
|
||||
})
|
||||
expect(useTagStore.getState().tagList.find(tag => tag.id === 'tag-1')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should prevent duplicate delete requests while pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveDelete!: () => void
|
||||
vi.mocked(tagMocks.deleteTag).mockImplementation(() => new Promise((resolve) => {
|
||||
resolveDelete = () => resolve(undefined)
|
||||
}))
|
||||
|
||||
const removableTag: Tag = { ...baseTag, binding_count: 0 }
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [removableTag, anotherTag] })
|
||||
})
|
||||
render(<TagItemEditor tag={removableTag} />)
|
||||
|
||||
const removeButton = screen.getByTestId('tag-item-editor-remove-button')
|
||||
await user.click(removeButton as HTMLElement)
|
||||
await user.click(removeButton as HTMLElement)
|
||||
|
||||
expect(tagMocks.deleteTag).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveDelete()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,11 +1,9 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { act } from 'react'
|
||||
import * as ReactI18next from 'react-i18next'
|
||||
import TagManagementModal from '../index'
|
||||
import { useStore as useTagStore } from '../store'
|
||||
import { TagManagementModal } from '../components/tag-management-modal'
|
||||
|
||||
const { mockNotify, mockToast } = vi.hoisted(() => {
|
||||
const mockNotify = vi.fn()
|
||||
@ -25,15 +23,36 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: mockToast,
|
||||
}))
|
||||
|
||||
// Hoisted mocks
|
||||
const { fetchTagList, createTag } = vi.hoisted(() => ({
|
||||
fetchTagList: vi.fn(),
|
||||
const { mockUseQueryData, createTag } = vi.hoisted(() => ({
|
||||
mockUseQueryData: { current: [] as Tag[] },
|
||||
createTag: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList,
|
||||
createTag,
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => ({ data: mockUseQueryData.current }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-tag-mutations', () => ({
|
||||
useCreateTagMutation: () => ({
|
||||
isPending: false,
|
||||
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
|
||||
const tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 } as Tag
|
||||
Promise.resolve(createTag(body.name, body.type))
|
||||
.then(() => options?.onSuccess?.(tag))
|
||||
.catch(() => options?.onError?.())
|
||||
},
|
||||
}),
|
||||
useUpdateTagMutation: () => ({
|
||||
mutate: (_input: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
},
|
||||
}),
|
||||
useDeleteTagMutation: () => ({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockTags: Tag[] = [
|
||||
@ -45,6 +64,7 @@ const mockTags: Tag[] = [
|
||||
const defaultProps = {
|
||||
type: 'app' as const,
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
// i18n mock renders "ns.key" format (dot-separated)
|
||||
@ -58,11 +78,8 @@ const i18n = {
|
||||
describe('TagManagementModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fetchTagList).mockResolvedValue(mockTags)
|
||||
mockUseQueryData.current = mockTags
|
||||
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: mockTags, showTagManagementModal: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -95,7 +112,7 @@ describe('TagManagementModal', () => {
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '')
|
||||
})
|
||||
|
||||
it('should render existing tags from the store', () => {
|
||||
it('should render existing tags from query data', () => {
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
// TagItemEditor renders each tag's name
|
||||
expect(screen.getByText('Frontend')).toBeInTheDocument()
|
||||
@ -109,32 +126,15 @@ describe('TagManagementModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should fetch tags for the given type on mount', async () => {
|
||||
render(<TagManagementModal {...defaultProps} type="app" />)
|
||||
await waitFor(() => {
|
||||
expect(fetchTagList).toHaveBeenCalledWith('app')
|
||||
})
|
||||
})
|
||||
|
||||
it('should fetch knowledge tags when type is knowledge', async () => {
|
||||
render(<TagManagementModal {...defaultProps} type="knowledge" />)
|
||||
await waitFor(() => {
|
||||
expect(fetchTagList).toHaveBeenCalledWith('knowledge')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should close modal when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
const onClose = vi.fn()
|
||||
render(<TagManagementModal {...defaultProps} onClose={onClose} />)
|
||||
|
||||
const closeIcon = screen.getByTestId('tag-management-modal-close-button')
|
||||
const closeButton = closeIcon.parentElement!
|
||||
await user.click(closeButton)
|
||||
await user.click(screen.getByTestId('tag-management-modal-close-button'))
|
||||
|
||||
expect(useTagStore.getState().showTagManagementModal).toBe(false)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should update input value when typing', async () => {
|
||||
@ -189,40 +189,6 @@ describe('TagManagementModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should add the new tag to the store tag list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const newTag = { id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 }
|
||||
vi.mocked(createTag).mockResolvedValue(newTag)
|
||||
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.addNew)
|
||||
await user.type(input, 'NewTag')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
const storeTagList = useTagStore.getState().tagList
|
||||
expect(storeTagList).toContainEqual(newTag)
|
||||
})
|
||||
})
|
||||
|
||||
it('should prepend the new tag to the beginning of the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const newTag = { id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 }
|
||||
vi.mocked(createTag).mockResolvedValue(newTag)
|
||||
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.addNew)
|
||||
await user.type(input, 'NewTag')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
const storeTagList = useTagStore.getState().tagList
|
||||
expect(storeTagList[0]).toEqual(newTag)
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a tag on input blur-sm', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
@ -268,74 +234,11 @@ describe('TagManagementModal', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not allow duplicate creation while pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Make createTag slow to simulate pending
|
||||
let resolveCreate: (value: Tag) => void
|
||||
vi.mocked(createTag).mockImplementation(() => new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
}))
|
||||
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.addNew)
|
||||
await user.type(input, 'NewTag')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
// First call should go through
|
||||
expect(createTag).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Attempt second creation while first is pending — need to type again + enter
|
||||
// But the component sets pending=true, so the second call is blocked.
|
||||
// The input value was cleared? No — pending is set before clearing.
|
||||
// Actually the component does: setPending(true) -> await createTag -> setName('') -> setPending(false)
|
||||
// So while pending, name is still 'NewTag', but calling createNewTag again does nothing.
|
||||
// We can trigger via blur
|
||||
await user.click(document.body)
|
||||
|
||||
// Should still be only 1 call because pending guard blocks it
|
||||
expect(createTag).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Resolve the pending promise
|
||||
await act(async () => {
|
||||
resolveCreate!({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Fetching', () => {
|
||||
it('should update store with fetched tags', async () => {
|
||||
const freshTags: Tag[] = [
|
||||
{ id: 'fresh-1', name: 'FreshTag', type: 'app', binding_count: 0 },
|
||||
]
|
||||
vi.mocked(fetchTagList).mockResolvedValue(freshTags)
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [] })
|
||||
})
|
||||
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTagStore.getState().tagList).toEqual(freshTags)
|
||||
})
|
||||
})
|
||||
|
||||
it('should refetch when type prop changes', () => {
|
||||
const { rerender } = render(<TagManagementModal {...defaultProps} type="app" />)
|
||||
expect(fetchTagList).toHaveBeenCalledWith('app')
|
||||
|
||||
vi.clearAllMocks()
|
||||
rerender(<TagManagementModal {...defaultProps} type="knowledge" />)
|
||||
expect(fetchTagList).toHaveBeenCalledWith('knowledge')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty tag list', () => {
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [] })
|
||||
})
|
||||
mockUseQueryData.current = []
|
||||
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
|
||||
@ -360,13 +263,11 @@ describe('TagManagementModal', () => {
|
||||
|
||||
it('should close modal via the Modal onClose callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
act(() => {
|
||||
useTagStore.setState({ showTagManagementModal: true })
|
||||
})
|
||||
render(<TagManagementModal {...defaultProps} />)
|
||||
const onClose = vi.fn()
|
||||
render(<TagManagementModal {...defaultProps} onClose={onClose} />)
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => {
|
||||
expect(useTagStore.getState().showTagManagementModal).toBe(false)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,11 +1,9 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { act } from 'react'
|
||||
import * as ReactI18next from 'react-i18next'
|
||||
import Panel from '../panel'
|
||||
import { useStore as useTagStore } from '../store'
|
||||
import { TagPanel } from '../components/tag-panel'
|
||||
|
||||
const { mockNotify, mockToast } = vi.hoisted(() => {
|
||||
const mockNotify = vi.fn()
|
||||
@ -32,10 +30,41 @@ const { createTag, bindTag, unBindTag } = vi.hoisted(() => ({
|
||||
unBindTag: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
createTag,
|
||||
bindTag,
|
||||
unBindTag,
|
||||
vi.mock('../hooks/use-tag-mutations', () => ({
|
||||
useCreateTagMutation: () => {
|
||||
const mutation = {
|
||||
isPending: false,
|
||||
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
|
||||
mutation.isPending = true
|
||||
const tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 } as Tag
|
||||
Promise.resolve(createTag(body.name, body.type))
|
||||
.then(() => options?.onSuccess?.(tag))
|
||||
.catch(() => options?.onError?.())
|
||||
.finally(() => {
|
||||
mutation.isPending = false
|
||||
})
|
||||
},
|
||||
}
|
||||
return mutation
|
||||
},
|
||||
useApplyTagBindingsMutation: () => ({
|
||||
mutate: (
|
||||
{ currentTagIds, nextTagIds, targetId, type }: { currentTagIds: string[], nextTagIds: string[], targetId: string, type: 'app' | 'knowledge' },
|
||||
options?: { onSuccess?: () => void, onError?: () => void },
|
||||
) => {
|
||||
const addTagIds = nextTagIds.filter(tagId => !currentTagIds.includes(tagId))
|
||||
const removeTagIds = currentTagIds.filter(tagId => !nextTagIds.includes(tagId))
|
||||
const operations: Promise<unknown>[] = []
|
||||
|
||||
if (addTagIds.length)
|
||||
operations.push(Promise.resolve(bindTag(addTagIds, targetId, type)))
|
||||
operations.push(...removeTagIds.map(tagId => Promise.resolve(unBindTag(tagId, targetId, type))))
|
||||
|
||||
Promise.all(operations)
|
||||
.then(() => options?.onSuccess?.())
|
||||
.catch(() => options?.onError?.())
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// i18n mock renders "ns.key" format (dot-separated)
|
||||
@ -59,13 +88,11 @@ const appTags: Tag[] = [
|
||||
const knowledgeTag: Tag = { id: 'tag-k1', name: 'KnowledgeDB', type: 'knowledge', binding_count: 2 }
|
||||
|
||||
const defaultProps = {
|
||||
targetID: 'target-1',
|
||||
targetId: 'target-1',
|
||||
type: 'app' as const,
|
||||
value: ['tag-1'!], // tag-1 is already selected/bound
|
||||
selectedTagIds: ['tag-1'!], // tag-1 is already selected/bound
|
||||
selectedTags: [appTags[0]!], // pre-selected tags shown separately
|
||||
onCacheUpdate: vi.fn<(tags: Tag[]) => void>(),
|
||||
onChange: vi.fn<() => void>(),
|
||||
onCreate: vi.fn<() => void>(),
|
||||
tagList: [...appTags, knowledgeTag],
|
||||
}
|
||||
|
||||
describe('Panel', () => {
|
||||
@ -74,19 +101,16 @@ describe('Panel', () => {
|
||||
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
|
||||
vi.mocked(bindTag).mockResolvedValue(undefined)
|
||||
vi.mocked(unBindTag).mockResolvedValue(undefined)
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [...appTags, knowledgeTag], showTagManagementModal: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the search input', () => {
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
expect(input)!.toBeInTheDocument()
|
||||
expect(input.tagName).toBe('INPUT')
|
||||
@ -101,18 +125,18 @@ describe('Panel', () => {
|
||||
|
||||
vi.spyOn(ReactI18next, 'useTranslation').mockReturnValueOnce(mockedTranslation)
|
||||
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
expect(screen.getByRole('textbox'))!.toHaveAttribute('placeholder', '')
|
||||
})
|
||||
|
||||
it('should render selected tags from selectedTags prop', () => {
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unselected tags matching the type', () => {
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
// tag-2 and tag-3 are app type and not in value[]
|
||||
// tag-2 and tag-3 are app type and not in value[]
|
||||
expect(screen.getByText('Backend'))!.toBeInTheDocument()
|
||||
@ -120,7 +144,7 @@ describe('Panel', () => {
|
||||
})
|
||||
|
||||
it('should not render tags of a different type', () => {
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
// knowledgeTag is type 'knowledge', should not appear
|
||||
// knowledgeTag is type 'knowledge', should not appear
|
||||
// knowledgeTag is type 'knowledge', should not appear
|
||||
@ -157,20 +181,17 @@ describe('Panel', () => {
|
||||
})
|
||||
|
||||
it('should render the manage tags button', () => {
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
expect(screen.getByText(i18n.manageTags))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no-tag message when there are no tags', () => {
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [] })
|
||||
})
|
||||
render(<Panel {...defaultProps} value={[]} selectedTags={[]} />)
|
||||
render(<TagPanel {...defaultProps} selectedTagIds={[]} selectedTags={[]} tagList={[]} />)
|
||||
expect(screen.getByText(i18n.noTag))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show no-tag message when tags exist', () => {
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
expect(screen.queryByText(i18n.noTag)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -178,7 +199,7 @@ describe('Panel', () => {
|
||||
describe('Search / Filter', () => {
|
||||
it('should filter tags by keyword', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'Back')
|
||||
@ -189,7 +210,7 @@ describe('Panel', () => {
|
||||
|
||||
it('should filter selected tags by keyword', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'Front')
|
||||
@ -202,10 +223,7 @@ describe('Panel', () => {
|
||||
const user = userEvent.setup()
|
||||
// notExisted uses .every(tag => tag.type === type && tag.name !== keywords)
|
||||
// so store must only contain same-type tags for notExisted to be true
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: appTags })
|
||||
})
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'BrandNewTag')
|
||||
@ -219,10 +237,7 @@ describe('Panel', () => {
|
||||
it('should not show create option when keyword matches an existing tag name', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Use only same-type tags so we can verify name matching specifically
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: appTags })
|
||||
})
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'Frontend')
|
||||
@ -264,7 +279,7 @@ describe('Panel', () => {
|
||||
|
||||
it('should clear search when clear button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'Back')
|
||||
@ -291,7 +306,7 @@ describe('Panel', () => {
|
||||
|
||||
it('should select an unselected tag when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const backendRowBeforeSelect = getTagRow('Backend')
|
||||
expect(within(backendRowBeforeSelect).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument()
|
||||
@ -304,7 +319,7 @@ describe('Panel', () => {
|
||||
|
||||
it('should deselect a selected tag when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const frontendRowBeforeDeselect = getTagRow('Frontend')
|
||||
expect(within(frontendRowBeforeDeselect).getByTestId('check-icon-tag-1'))!.toBeInTheDocument()
|
||||
@ -317,7 +332,7 @@ describe('Panel', () => {
|
||||
|
||||
it('should toggle tag selection on multiple clicks', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const backendRowBeforeToggle = getTagRow('Backend')
|
||||
expect(within(backendRowBeforeToggle).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument()
|
||||
@ -337,14 +352,11 @@ describe('Panel', () => {
|
||||
describe('Tag Creation', () => {
|
||||
beforeEach(() => {
|
||||
// notExisted requires all tags to be same type, so remove knowledgeTag
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: appTags })
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a new tag when clicking the create option', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'BrandNewTag')
|
||||
@ -359,7 +371,7 @@ describe('Panel', () => {
|
||||
|
||||
it('should show success notification after tag creation', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'BrandNewTag')
|
||||
@ -377,7 +389,7 @@ describe('Panel', () => {
|
||||
|
||||
it('should clear keywords after successful tag creation', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'BrandNewTag')
|
||||
@ -390,45 +402,11 @@ describe('Panel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onCreate callback after successful tag creation', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'BrandNewTag')
|
||||
|
||||
const createOption = await screen.findByTestId('create-tag-option')
|
||||
await user.click(createOption)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onCreate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should add new tag to the store tag list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const newTag: Tag = { id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 }
|
||||
vi.mocked(createTag).mockResolvedValue(newTag)
|
||||
|
||||
render(<Panel {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'BrandNewTag')
|
||||
|
||||
const createOption = await screen.findByTestId('create-tag-option')
|
||||
await user.click(createOption)
|
||||
|
||||
await waitFor(() => {
|
||||
const storeTagList = useTagStore.getState().tagList
|
||||
expect(storeTagList).toContainEqual(newTag)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification when tag creation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(createTag).mockRejectedValue(new Error('Creation failed'))
|
||||
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'FailTag')
|
||||
@ -445,7 +423,7 @@ describe('Panel', () => {
|
||||
})
|
||||
|
||||
it('should not create tag when keywords is empty', () => {
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
// The create option should not appear when no keywords
|
||||
// The create option should not appear when no keywords
|
||||
@ -482,187 +460,38 @@ describe('Panel', () => {
|
||||
expect(screen.queryByText(i18n.create, { exact: false })).not.toBeInTheDocument()
|
||||
expect(createTag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not allow duplicate creation while pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveCreate!: (value: Tag) => void
|
||||
vi.mocked(createTag).mockImplementation(() => new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
}))
|
||||
|
||||
render(<Panel {...defaultProps} />)
|
||||
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'BrandNewTag')
|
||||
|
||||
const createOption = await screen.findByTestId('create-tag-option')
|
||||
await user.click(createOption)
|
||||
|
||||
expect(createTag).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Try clicking again while still pending
|
||||
await user.click(createOption)
|
||||
|
||||
// Should still be only 1 call because creating guard blocks it
|
||||
expect(createTag).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Resolve the pending promise
|
||||
await act(async () => {
|
||||
resolveCreate({ id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bind/Unbind on Unmount', () => {
|
||||
it('should call bindTag for newly selected tags on unmount', async () => {
|
||||
describe('Binding Selection State', () => {
|
||||
it('should not submit tag bindings on panel unmount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(<Panel {...defaultProps} />)
|
||||
const { unmount } = render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
|
||||
// Select 'Backend' (tag-2) — currently not in value[]
|
||||
await user.click(screen.getByText('Backend'))
|
||||
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call unBindTag for deselected tags on unmount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(<Panel {...defaultProps} />)
|
||||
|
||||
// Deselect 'Frontend' (tag-1) — currently in value[]
|
||||
await user.click(screen.getByText('Frontend'))
|
||||
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(unBindTag).toHaveBeenCalledWith('tag-1', 'target-1', 'app')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onCacheUpdate with selected tags on unmount when value changed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(<Panel {...defaultProps} />)
|
||||
|
||||
// Select 'Backend' (tag-2)
|
||||
await user.click(screen.getByText('Backend'))
|
||||
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onCacheUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const [updatedTags] = (vi.mocked(defaultProps.onCacheUpdate).mock.calls[0] ?? []) as [any]
|
||||
expect(updatedTags.map((tag: any) => tag.id)).toEqual(['tag-1', 'tag-2'])
|
||||
})
|
||||
|
||||
it('should not call bind/unbind when value has not changed', async () => {
|
||||
const { unmount } = render(<Panel {...defaultProps} />)
|
||||
|
||||
unmount()
|
||||
|
||||
await act(async () => { })
|
||||
expect(bindTag).not.toHaveBeenCalled()
|
||||
expect(unBindTag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange after all operations complete on unmount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(<Panel {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('Backend'))
|
||||
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip onChange callback when onChange prop is undefined', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { unmount } = render(<Panel {...defaultProps} onChange={undefined} />)
|
||||
|
||||
await user.click(screen.getByText('Backend'))
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app')
|
||||
})
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show success notification after successful bind', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(<Panel {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('Backend'))
|
||||
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: i18n.modifiedSuccessfully,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification when bind fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(bindTag).mockRejectedValue(new Error('Bind failed'))
|
||||
|
||||
const { unmount } = render(<Panel {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('Backend'))
|
||||
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: i18n.modifiedUnsuccessfully,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification when unbind fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(unBindTag).mockRejectedValue(new Error('Unbind failed'))
|
||||
|
||||
const { unmount } = render(<Panel {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('Frontend'))
|
||||
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: i18n.modifiedUnsuccessfully,
|
||||
})
|
||||
})
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Manage Tags Modal', () => {
|
||||
it('should open the tag management modal when manage tags is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Panel {...defaultProps} />)
|
||||
const onOpenTagManagement = vi.fn()
|
||||
render(<TagPanel {...defaultProps} onOpenTagManagement={onOpenTagManagement} />)
|
||||
|
||||
await user.click(screen.getByText(i18n.manageTags))
|
||||
|
||||
expect(useTagStore.getState().showTagManagementModal).toBe(true)
|
||||
expect(onOpenTagManagement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty value array', () => {
|
||||
render(<Panel {...defaultProps} value={[]} selectedTags={[]} />)
|
||||
render(<TagPanel {...defaultProps} selectedTagIds={[]} selectedTags={[]} />)
|
||||
// All app-type tags should appear in the unselected list
|
||||
// All app-type tags should appear in the unselected list
|
||||
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
|
||||
@ -670,19 +499,16 @@ describe('Panel', () => {
|
||||
expect(screen.getByText('API'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty tagList in store', () => {
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [] })
|
||||
})
|
||||
render(<Panel {...defaultProps} value={[]} selectedTags={[]} />)
|
||||
it('should handle empty tagList', () => {
|
||||
render(<TagPanel {...defaultProps} selectedTagIds={[]} selectedTags={[]} tagList={[]} />)
|
||||
expect(screen.getByText(i18n.noTag))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle all tags already selected', () => {
|
||||
render(
|
||||
<Panel
|
||||
<TagPanel
|
||||
{...defaultProps}
|
||||
value={['tag-1', 'tag-2', 'tag-3']}
|
||||
selectedTagIds={['tag-1', 'tag-2', 'tag-3']}
|
||||
selectedTags={appTags}
|
||||
/>,
|
||||
)
|
||||
@ -696,10 +522,7 @@ describe('Panel', () => {
|
||||
it('should show divider between create option and tag list when both present', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Only same-type tags for notExisted to work
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: appTags })
|
||||
})
|
||||
render(<Panel {...defaultProps} />)
|
||||
render(<TagPanel {...defaultProps} tagList={appTags} />)
|
||||
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.type(input, 'Back')
|
||||
// 'Back' matches Backend (unselected), notExisted is true (no tag named 'Back')
|
||||
@ -709,15 +532,13 @@ describe('Panel', () => {
|
||||
})
|
||||
|
||||
it('should handle knowledge type tags correctly', () => {
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [knowledgeTag] })
|
||||
})
|
||||
render(
|
||||
<Panel
|
||||
<TagPanel
|
||||
{...defaultProps}
|
||||
type="knowledge"
|
||||
value={[]}
|
||||
selectedTagIds={[]}
|
||||
selectedTags={[]}
|
||||
tagList={[knowledgeTag]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('KnowledgeDB'))!.toBeInTheDocument()
|
||||
@ -1,9 +1,7 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import TagSelector from '../selector'
|
||||
import { useStore as useTagStore } from '../store'
|
||||
import { TagSelector } from '../components/tag-selector'
|
||||
|
||||
const { mockToast } = vi.hoisted(() => {
|
||||
const mockToast = Object.assign(vi.fn(), {
|
||||
@ -22,19 +20,50 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: mockToast,
|
||||
}))
|
||||
|
||||
// Hoisted mocks
|
||||
const { fetchTagList, createTag, bindTag, unBindTag } = vi.hoisted(() => ({
|
||||
fetchTagList: vi.fn(),
|
||||
const { mockUseQueryData, createTag, bindTag, unBindTag } = vi.hoisted(() => ({
|
||||
mockUseQueryData: { current: [] as Tag[] },
|
||||
createTag: vi.fn(),
|
||||
bindTag: vi.fn(),
|
||||
unBindTag: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList,
|
||||
createTag,
|
||||
bindTag,
|
||||
unBindTag,
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => ({ data: mockUseQueryData.current }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-tag-mutations', () => ({
|
||||
useCreateTagMutation: () => ({
|
||||
isPending: false,
|
||||
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
|
||||
try {
|
||||
const tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 } as Tag
|
||||
createTag(body.name, body.type)
|
||||
options?.onSuccess?.(tag)
|
||||
}
|
||||
catch {
|
||||
options?.onError?.()
|
||||
}
|
||||
},
|
||||
}),
|
||||
useApplyTagBindingsMutation: () => ({
|
||||
mutate: (
|
||||
{ currentTagIds, nextTagIds, targetId, type }: { currentTagIds: string[], nextTagIds: string[], targetId: string, type: 'app' | 'knowledge' },
|
||||
options?: { onSuccess?: () => void, onError?: () => void, onSettled?: () => void },
|
||||
) => {
|
||||
const addTagIds = nextTagIds.filter(tagId => !currentTagIds.includes(tagId))
|
||||
const removeTagIds = currentTagIds.filter(tagId => !nextTagIds.includes(tagId))
|
||||
const operations: Promise<unknown>[] = []
|
||||
|
||||
if (addTagIds.length)
|
||||
operations.push(Promise.resolve(bindTag(addTagIds, targetId, type)))
|
||||
operations.push(...removeTagIds.map(tagId => Promise.resolve(unBindTag(tagId, targetId, type))))
|
||||
|
||||
Promise.all(operations)
|
||||
.then(() => options?.onSuccess?.())
|
||||
.catch(() => options?.onError?.())
|
||||
.finally(() => options?.onSettled?.())
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// i18n keys rendered in "ns.key" format
|
||||
@ -43,6 +72,8 @@ const i18n = {
|
||||
selectorPlaceholder: 'common.tag.selectorPlaceholder',
|
||||
manageTags: 'common.tag.manageTags',
|
||||
noTag: 'common.tag.noTag',
|
||||
modifiedSuccessfully: 'common.actionMsg.modifiedSuccessfully',
|
||||
modifiedUnsuccessfully: 'common.actionMsg.modifiedUnsuccessfully',
|
||||
}
|
||||
|
||||
const appTags: Tag[] = [
|
||||
@ -51,12 +82,10 @@ const appTags: Tag[] = [
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
targetID: 'target-1',
|
||||
targetId: 'target-1',
|
||||
type: 'app' as const,
|
||||
value: ['tag-1'!],
|
||||
selectedTagIds: ['tag-1'!],
|
||||
selectedTags: [appTags[0]!],
|
||||
onCacheUpdate: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('TagSelector', () => {
|
||||
@ -68,13 +97,10 @@ describe('TagSelector', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fetchTagList).mockResolvedValue(appTags)
|
||||
mockUseQueryData.current = appTags
|
||||
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
|
||||
vi.mocked(bindTag).mockResolvedValue(undefined)
|
||||
vi.mocked(unBindTag).mockResolvedValue(undefined)
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: appTags, showTagManagementModal: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -84,7 +110,7 @@ describe('TagSelector', () => {
|
||||
})
|
||||
|
||||
it('should render TagSelector add-tag placeholder when defaultProps are overridden with empty selectedTags and value', () => {
|
||||
render(<TagSelector {...defaultProps} selectedTags={[]} value={[]} />)
|
||||
render(<TagSelector {...defaultProps} selectedTags={[]} selectedTagIds={[]} />)
|
||||
expect(screen.getByText(i18n.addTag))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -115,7 +141,7 @@ describe('TagSelector', () => {
|
||||
<TagSelector
|
||||
{...defaultProps}
|
||||
selectedTags={[appTags[0]!, unknownTag]}
|
||||
value={['tag-1', 'unknown']}
|
||||
selectedTagIds={['tag-1', 'unknown']}
|
||||
/>,
|
||||
)
|
||||
// 'Frontend' is in tagList, 'Unknown' is not
|
||||
@ -129,7 +155,7 @@ describe('TagSelector', () => {
|
||||
<TagSelector
|
||||
{...defaultProps}
|
||||
selectedTags={appTags}
|
||||
value={['tag-1', 'tag-2']}
|
||||
selectedTagIds={['tag-1', 'tag-2']}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
|
||||
@ -164,10 +190,8 @@ describe('TagSelector', () => {
|
||||
|
||||
it('should show the no-tag message when tag list is empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: [] })
|
||||
})
|
||||
render(<TagSelector {...defaultProps} selectedTags={[]} value={[]} />)
|
||||
mockUseQueryData.current = []
|
||||
render(<TagSelector {...defaultProps} selectedTags={[]} selectedTagIds={[]} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
@ -176,7 +200,7 @@ describe('TagSelector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should bind a newly selected tag and update cache when closing the panel', async () => {
|
||||
it('should bind a newly selected tag when closing the panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagSelector {...defaultProps} />)
|
||||
|
||||
@ -192,12 +216,28 @@ describe('TagSelector', () => {
|
||||
await waitFor(() => {
|
||||
expect(bindTag).toHaveBeenCalledTimes(1)
|
||||
expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app')
|
||||
expect(defaultProps.onCacheUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(defaultProps.onCacheUpdate).toHaveBeenCalledWith(appTags)
|
||||
})
|
||||
})
|
||||
|
||||
it('should unbind a deselected tag and update cache when closing the panel', async () => {
|
||||
it('should show one success toast when tag bindings are applied on close', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagSelector {...defaultProps} />)
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
|
||||
await user.click(triggerButton)
|
||||
|
||||
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.click(getPanelTagRow('Backend'))
|
||||
await user.click(triggerButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(i18n.modifiedSuccessfully, {
|
||||
id: 'tag-bindings-app-target-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should unbind a deselected tag when closing the panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagSelector {...defaultProps} />)
|
||||
|
||||
@ -213,21 +253,85 @@ describe('TagSelector', () => {
|
||||
await waitFor(() => {
|
||||
expect(unBindTag).toHaveBeenCalledTimes(1)
|
||||
expect(unBindTag).toHaveBeenCalledWith('tag-1', 'target-1', 'app')
|
||||
expect(defaultProps.onCacheUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(defaultProps.onCacheUpdate).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show one error toast when applying tag bindings fails on close', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(unBindTag).mockRejectedValueOnce(new Error('Unbind failed'))
|
||||
render(<TagSelector {...defaultProps} />)
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
|
||||
await user.click(triggerButton)
|
||||
|
||||
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.click(getPanelTagRow('Frontend'))
|
||||
await user.click(triggerButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(i18n.modifiedUnsuccessfully, {
|
||||
id: 'tag-bindings-app-target-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not apply bindings when the selection is unchanged on close', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onTagsChange = vi.fn()
|
||||
render(<TagSelector {...defaultProps} onTagsChange={onTagsChange} />)
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
|
||||
await user.click(triggerButton)
|
||||
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.click(triggerButton)
|
||||
|
||||
expect(bindTag).not.toHaveBeenCalled()
|
||||
expect(unBindTag).not.toHaveBeenCalled()
|
||||
expect(mockToast.success).not.toHaveBeenCalled()
|
||||
expect(mockToast.error).not.toHaveBeenCalled()
|
||||
expect(onTagsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should notify tag changes after bindings are applied successfully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onTagsChange = vi.fn()
|
||||
render(<TagSelector {...defaultProps} onTagsChange={onTagsChange} />)
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
|
||||
await user.click(triggerButton)
|
||||
|
||||
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.click(getPanelTagRow('Backend'))
|
||||
await user.click(triggerButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTagsChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify tag changes after applying bindings settles with an error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onTagsChange = vi.fn()
|
||||
vi.mocked(unBindTag).mockRejectedValueOnce(new Error('Unbind failed'))
|
||||
render(<TagSelector {...defaultProps} onTagsChange={onTagsChange} />)
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
|
||||
await user.click(triggerButton)
|
||||
|
||||
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
|
||||
await user.click(getPanelTagRow('Frontend'))
|
||||
await user.click(triggerButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTagsChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Fetching (getTagList / onCreate)', () => {
|
||||
it('should update the store tagList after fetching', async () => {
|
||||
describe('Data Fetching', () => {
|
||||
it('should create tags through the mutation hook', async () => {
|
||||
const user = userEvent.setup()
|
||||
const freshTags: Tag[] = [
|
||||
...appTags,
|
||||
{ id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 },
|
||||
]
|
||||
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 })
|
||||
vi.mocked(fetchTagList).mockResolvedValue(freshTags)
|
||||
|
||||
render(<TagSelector {...defaultProps} />)
|
||||
|
||||
@ -247,13 +351,7 @@ describe('TagSelector', () => {
|
||||
expect(createTag).toHaveBeenCalledWith('BrandNewTag', 'app')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTagList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTagStore.getState().tagList).toEqual(freshTags)
|
||||
})
|
||||
expect(mockUseQueryData.current).toEqual(appTags)
|
||||
})
|
||||
})
|
||||
|
||||
@ -266,7 +364,7 @@ describe('TagSelector', () => {
|
||||
<TagSelector
|
||||
{...defaultProps}
|
||||
selectedTags={orphanTags}
|
||||
value={['orphan-1']}
|
||||
selectedTagIds={['orphan-1']}
|
||||
/>,
|
||||
)
|
||||
// Orphan tag is not in store tagList, so tags memo returns []
|
||||
@ -310,17 +408,14 @@ describe('TagSelector', () => {
|
||||
const knowledgeTags: Tag[] = [
|
||||
{ id: 'k-1', name: 'KnowledgeDB', type: 'knowledge', binding_count: 2 },
|
||||
]
|
||||
vi.mocked(fetchTagList).mockResolvedValue(knowledgeTags)
|
||||
act(() => {
|
||||
useTagStore.setState({ tagList: knowledgeTags })
|
||||
})
|
||||
mockUseQueryData.current = knowledgeTags
|
||||
|
||||
render(
|
||||
<TagSelector
|
||||
{...defaultProps}
|
||||
type="knowledge"
|
||||
selectedTags={knowledgeTags}
|
||||
value={['k-1']}
|
||||
selectedTagIds={['k-1']}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Trigger from '../trigger'
|
||||
import { TagTrigger } from '../components/tag-trigger'
|
||||
|
||||
describe('Trigger', () => {
|
||||
beforeEach(() => {
|
||||
@ -9,13 +9,13 @@ describe('Trigger', () => {
|
||||
// Rendering behavior for empty and populated states.
|
||||
describe('Rendering', () => {
|
||||
it('should render add-tag placeholder when tags are empty', () => {
|
||||
render(<Trigger tags={[]} />)
|
||||
render(<TagTrigger tags={[]} />)
|
||||
|
||||
expect(screen.getByText('common.tag.addTag')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all tags when tags are provided', () => {
|
||||
render(<Trigger tags={['Frontend', 'Backend']} />)
|
||||
render(<TagTrigger tags={['Frontend', 'Backend']} />)
|
||||
|
||||
expect(screen.getByText('Frontend')).toBeInTheDocument()
|
||||
expect(screen.getByText('Backend')).toBeInTheDocument()
|
||||
@ -26,10 +26,10 @@ describe('Trigger', () => {
|
||||
// Prop-driven rendering updates.
|
||||
describe('Props', () => {
|
||||
it('should update from placeholder to tag badges when tags prop changes', () => {
|
||||
const { rerender } = render(<Trigger tags={[]} />)
|
||||
const { rerender } = render(<TagTrigger tags={[]} />)
|
||||
expect(screen.getByText('common.tag.addTag')).toBeInTheDocument()
|
||||
|
||||
rerender(<Trigger tags={['Database']} />)
|
||||
rerender(<TagTrigger tags={['Database']} />)
|
||||
|
||||
expect(screen.getByText('Database')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument()
|
||||
@ -39,7 +39,7 @@ describe('Trigger', () => {
|
||||
// Edge behavior for unusual but valid tag arrays.
|
||||
describe('Edge Cases', () => {
|
||||
it('should render a badge even when a tag label is an empty string', () => {
|
||||
render(<Trigger tags={['']} />)
|
||||
render(<TagTrigger tags={['']} />)
|
||||
|
||||
// One outer container + one tag badge.
|
||||
expect(screen.getAllByTestId(/^tag-badge-/)).toHaveLength(1)
|
||||
@ -48,7 +48,7 @@ describe('Trigger', () => {
|
||||
|
||||
it('should render one badge per tag for longer tag lists', () => {
|
||||
const tags = ['A', 'B', 'C', 'D', 'E']
|
||||
render(<Trigger tags={tags} />)
|
||||
render(<TagTrigger tags={tags} />)
|
||||
|
||||
tags.forEach(tag => expect(screen.getByText(tag)).toBeInTheDocument())
|
||||
expect(screen.getAllByTestId(/^tag-badge-/)).toHaveLength(tags.length)
|
||||
@ -0,0 +1,95 @@
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AppCardTags } from '../app-card-tags'
|
||||
|
||||
const renderTagSelector = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
||||
TagSelector: (props: {
|
||||
onOpenTagManagement?: () => void
|
||||
onTagsChange?: () => void
|
||||
position: string
|
||||
selectedTagIds: string[]
|
||||
selectedTags: Tag[]
|
||||
targetId: string
|
||||
type: string
|
||||
}) => {
|
||||
renderTagSelector(props)
|
||||
|
||||
return (
|
||||
<div data-testid="tag-selector">
|
||||
<span data-testid="target-id">{props.targetId}</span>
|
||||
<span data-testid="tag-type">{props.type}</span>
|
||||
<span data-testid="selected-tag-ids">{props.selectedTagIds.join(',')}</span>
|
||||
<span data-testid="selected-tag-names">{props.selectedTags.map(tag => tag.name).join(',')}</span>
|
||||
<button type="button" onClick={props.onOpenTagManagement}>Manage Tags</button>
|
||||
<button type="button" onClick={props.onTagsChange}>Tags Changed</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const tags: Tag[] = [
|
||||
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 1 },
|
||||
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 2 },
|
||||
]
|
||||
|
||||
describe('AppCardTags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render TagSelector with app tag bindings', () => {
|
||||
render(<AppCardTags appId="app-1" tags={tags} />)
|
||||
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('target-id')).toHaveTextContent('app-1')
|
||||
expect(screen.getByTestId('tag-type')).toHaveTextContent('app')
|
||||
expect(screen.getByTestId('selected-tag-ids')).toHaveTextContent('tag-1,tag-2')
|
||||
expect(screen.getByTestId('selected-tag-names')).toHaveTextContent('Frontend,Backend')
|
||||
expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({
|
||||
position: 'bl',
|
||||
targetId: 'app-1',
|
||||
type: 'app',
|
||||
selectedTagIds: ['tag-1', 'tag-2'],
|
||||
selectedTags: tags,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Callbacks', () => {
|
||||
it('should forward tag management and tag change callbacks', () => {
|
||||
const onOpenTagManagement = vi.fn()
|
||||
const onTagsChange = vi.fn()
|
||||
|
||||
render(
|
||||
<AppCardTags
|
||||
appId="app-1"
|
||||
tags={tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onTagsChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Manage Tags'))
|
||||
fireEvent.click(screen.getByText('Tags Changed'))
|
||||
|
||||
expect(onOpenTagManagement).toHaveBeenCalledTimes(1)
|
||||
expect(onTagsChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should pass an empty selection when the app has no tags', () => {
|
||||
render(<AppCardTags appId="app-1" tags={[]} />)
|
||||
|
||||
expect(screen.getByTestId('selected-tag-ids')).toHaveTextContent('')
|
||||
expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({
|
||||
selectedTagIds: [],
|
||||
selectedTags: [],
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
31
web/features/tag-management/components/app-card-tags.tsx
Normal file
31
web/features/tag-management/components/app-card-tags.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { TagSelector } from '@/features/tag-management/components/tag-selector'
|
||||
|
||||
type AppCardTagsProps = {
|
||||
appId: string
|
||||
tags: Tag[]
|
||||
onOpenTagManagement?: () => void
|
||||
onTagsChange?: () => void
|
||||
}
|
||||
|
||||
export const AppCardTags = ({
|
||||
appId,
|
||||
tags,
|
||||
onOpenTagManagement = () => {},
|
||||
onTagsChange,
|
||||
}: AppCardTagsProps) => {
|
||||
return (
|
||||
<div className="group/tag-area relative min-w-0 overflow-hidden">
|
||||
<TagSelector
|
||||
position="bl"
|
||||
type="app"
|
||||
targetId={appId}
|
||||
selectedTagIds={tags.map(tag => tag.id)}
|
||||
selectedTags={tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
<div className="pointer-events-none absolute top-0 right-0 z-5 h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg group-hover/tag-area:hidden" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
web/features/tag-management/components/dataset-card-tags.tsx
Normal file
42
web/features/tag-management/components/dataset-card-tags.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { TagSelector } from '@/features/tag-management/components/tag-selector'
|
||||
|
||||
type DatasetCardTagsProps = {
|
||||
datasetId: string
|
||||
embeddingAvailable: boolean
|
||||
tags: Tag[]
|
||||
onClick: (e: MouseEvent) => void
|
||||
onOpenTagManagement?: () => void
|
||||
onTagsChange?: () => void
|
||||
}
|
||||
|
||||
export const DatasetCardTags = ({
|
||||
datasetId,
|
||||
embeddingAvailable,
|
||||
tags,
|
||||
onClick,
|
||||
onOpenTagManagement = () => {},
|
||||
onTagsChange,
|
||||
}: DatasetCardTagsProps) => (
|
||||
<div
|
||||
className={cn('group/tag-area relative w-full px-3', !embeddingAvailable && 'opacity-30')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-full">
|
||||
<TagSelector
|
||||
position="bl"
|
||||
type="knowledge"
|
||||
targetId={datasetId}
|
||||
selectedTagIds={tags.map(tag => tag.id)}
|
||||
selectedTags={tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-0 right-0 z-5 h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg group-hover/tag-area:hidden"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@ -1,36 +1,42 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useMount } from 'ahooks'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
|
||||
import Tag01Icon from '@/app/components/base/icons/src/vender/line/financeAndECommerce/Tag01'
|
||||
import Tag03Icon from '@/app/components/base/icons/src/vender/line/financeAndECommerce/Tag03'
|
||||
import CheckIcon from '@/app/components/base/icons/src/vender/line/general/Check'
|
||||
import XCircleIcon from '@/app/components/base/icons/src/vender/solid/general/XCircle'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { fetchTagList } from '@/service/tag'
|
||||
|
||||
import { useStore as useTagStore } from './store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type TagFilterProps = {
|
||||
type: 'knowledge' | 'app'
|
||||
value: string[]
|
||||
onChange: (v: string[]) => void
|
||||
onOpenTagManagement?: () => void
|
||||
}
|
||||
const TagFilter: FC<TagFilterProps> = ({
|
||||
export const TagFilter = ({
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
onOpenTagManagement = () => {},
|
||||
}: TagFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const tagList = useTagStore(s => s.tagList)
|
||||
const setTagList = useTagStore(s => s.setTagList)
|
||||
const setShowTagManagementModal = useTagStore(s => s.setShowTagManagementModal)
|
||||
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
type,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
@ -49,12 +55,6 @@ const TagFilter: FC<TagFilterProps> = ({
|
||||
onChange([...value, tag.id])
|
||||
}
|
||||
|
||||
useMount(() => {
|
||||
fetchTagList(type).then((res) => {
|
||||
setTagList(res)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@ -66,12 +66,12 @@ const TagFilter: FC<TagFilterProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left select-none',
|
||||
'flex h-8 max-w-[240px] min-w-[112px] cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left select-none',
|
||||
!!value.length && 'pr-6 shadow-xs',
|
||||
)}
|
||||
>
|
||||
<div className="p-px">
|
||||
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-trigger-icon" />
|
||||
<Tag01Icon className="h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-trigger-icon" />
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-[13px] leading-[18px] text-text-secondary">
|
||||
{!value.length && t('tag.placeholder', { ns: 'common' })}
|
||||
@ -82,7 +82,7 @@ const TagFilter: FC<TagFilterProps> = ({
|
||||
)}
|
||||
{!value.length && (
|
||||
<div className="shrink-0 p-px">
|
||||
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-arrow-down-icon" />
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-arrow-down-icon" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@ -91,11 +91,12 @@ const TagFilter: FC<TagFilterProps> = ({
|
||||
{!!value.length && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="group/clear absolute top-1/2 right-2 -translate-y-1/2 p-px"
|
||||
onClick={() => onChange([])}
|
||||
data-testid="tag-filter-clear-button"
|
||||
>
|
||||
<span className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
|
||||
<XCircleIcon className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
<PopoverContent
|
||||
@ -121,12 +122,12 @@ const TagFilter: FC<TagFilterProps> = ({
|
||||
onClick={() => selectTag(tag)}
|
||||
>
|
||||
<div title={tag.name} className="grow truncate text-sm leading-5 text-text-tertiary">{tag.name}</div>
|
||||
{value.includes(tag.id) && <span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-secondary" data-testid="tag-filter-selected-icon" />}
|
||||
{value.includes(tag.id) && <CheckIcon className="h-4 w-4 shrink-0 text-text-secondary" data-testid="tag-filter-selected-icon" />}
|
||||
</div>
|
||||
))}
|
||||
{!filteredTagList.length && (
|
||||
<div className="flex flex-col items-center gap-1 p-3">
|
||||
<Tag03 className="h-6 w-6 text-text-tertiary" />
|
||||
<Tag03Icon className="h-6 w-6 text-text-tertiary" />
|
||||
<div className="text-xs leading-[14px] text-text-tertiary">{t('tag.noTag', { ns: 'common' })}</div>
|
||||
</div>
|
||||
)}
|
||||
@ -136,11 +137,11 @@ const TagFilter: FC<TagFilterProps> = ({
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setShowTagManagementModal(true)
|
||||
onOpenTagManagement()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Tag03 className="h-4 w-4 text-text-tertiary" />
|
||||
<Tag03Icon className="h-4 w-4 text-text-tertiary" />
|
||||
<div className="grow truncate text-sm leading-5 text-text-secondary">
|
||||
{t('tag.manageTags', { ns: 'common' })}
|
||||
</div>
|
||||
@ -153,5 +154,3 @@ const TagFilter: FC<TagFilterProps> = ({
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default TagFilter
|
||||
@ -1,5 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -11,23 +10,27 @@ import {
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { deleteTag, updateTag } from '@/service/tag'
|
||||
import { useStore as useTagStore } from './store'
|
||||
import { useDeleteTagMutation, useUpdateTagMutation } from '../hooks/use-tag-mutations'
|
||||
|
||||
type TagItemEditorProps = {
|
||||
tag: Tag
|
||||
onTagsChange?: () => void
|
||||
}
|
||||
const TagItemEditor: FC<TagItemEditorProps> = ({ tag }) => {
|
||||
export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const tagList = useTagStore(s => s.tagList)
|
||||
const setTagList = useTagStore(s => s.setTagList)
|
||||
const updateTagMutation = useUpdateTagMutation(tag.type)
|
||||
const deleteTagMutation = useDeleteTagMutation(tag.type)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [name, setName] = useState(tag.name)
|
||||
const editTag = async (tagID: string, name: string) => {
|
||||
const editTag = (tagId: string, name: string) => {
|
||||
if (name === tag.name) {
|
||||
setIsEditing(false)
|
||||
return
|
||||
@ -38,61 +41,46 @@ const TagItemEditor: FC<TagItemEditorProps> = ({ tag }) => {
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const newList = tagList.map((tag) => {
|
||||
if (tag.id === tagID) {
|
||||
return {
|
||||
...tag,
|
||||
name,
|
||||
}
|
||||
}
|
||||
return tag
|
||||
})
|
||||
setTagList([
|
||||
...newList,
|
||||
])
|
||||
setIsEditing(false)
|
||||
await updateTag(tagID, name)
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
setName(name)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
setName(tag.name)
|
||||
const recoverList = tagList.map((tag) => {
|
||||
if (tag.id === tagID) {
|
||||
return {
|
||||
...tag,
|
||||
name: tag.name,
|
||||
}
|
||||
}
|
||||
return tag
|
||||
})
|
||||
setTagList([
|
||||
...recoverList,
|
||||
])
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
updateTagMutation.mutate({
|
||||
params: {
|
||||
tagId,
|
||||
},
|
||||
body: {
|
||||
name,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
setName(name)
|
||||
setIsEditing(false)
|
||||
onTagsChange?.()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
setName(tag.name)
|
||||
setIsEditing(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false)
|
||||
const [pending, setPending] = useState<boolean>(false)
|
||||
const removeTag = async (tagID: string) => {
|
||||
if (pending)
|
||||
const removeTag = (tagId: string) => {
|
||||
if (deleteTagMutation.isPending)
|
||||
return
|
||||
try {
|
||||
setPending(true)
|
||||
await deleteTag(tagID)
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
const newList = tagList.filter(tag => tag.id !== tagID)
|
||||
setTagList([
|
||||
...newList,
|
||||
])
|
||||
setPending(false)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
deleteTagMutation.mutate({
|
||||
params: {
|
||||
tagId,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
onTagsChange?.()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
},
|
||||
})
|
||||
}
|
||||
const { run: handleRemove } = useDebounceFn(() => {
|
||||
removeTag(tag.id)
|
||||
@ -105,8 +93,11 @@ const TagItemEditor: FC<TagItemEditorProps> = ({ tag }) => {
|
||||
<div className="text-sm leading-5 text-text-secondary">
|
||||
{tag.name}
|
||||
</div>
|
||||
<Tooltip popupContent={<div>{t('common.tagBound', { ns: 'workflow' })}</div>} needsDelay>
|
||||
<div className="shrink-0 px-1 text-sm leading-4.5 font-medium text-text-tertiary">{tag.binding_count}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="shrink-0 px-1 text-sm leading-4.5 font-medium text-text-tertiary">{tag.binding_count}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.tagBound', { ns: 'workflow' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="group/edit shrink-0 cursor-pointer rounded-md p-1 hover:bg-state-base-hover" onClick={() => setIsEditing(true)}>
|
||||
<span className="i-ri-edit-line h-3 w-3 text-text-tertiary group-hover/edit:text-text-secondary" data-testid="tag-item-editor-edit-button" />
|
||||
@ -157,4 +148,3 @@ const TagItemEditor: FC<TagItemEditorProps> = ({ tag }) => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default TagItemEditor
|
||||
@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useCreateTagMutation } from '../hooks/use-tag-mutations'
|
||||
import { TagItemEditor } from './tag-item-editor'
|
||||
|
||||
type TagManagementModalProps = {
|
||||
type: 'knowledge' | 'app'
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onTagsChange?: () => void
|
||||
}
|
||||
export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagManagementModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
type,
|
||||
},
|
||||
},
|
||||
enabled: show,
|
||||
}))
|
||||
const createTagMutation = useCreateTagMutation()
|
||||
const [name, setName] = useState<string>('')
|
||||
|
||||
const createNewTag = () => {
|
||||
if (!name)
|
||||
return
|
||||
if (createTagMutation.isPending)
|
||||
return
|
||||
|
||||
createTagMutation.mutate({
|
||||
body: {
|
||||
name,
|
||||
type,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('tag.created', { ns: 'common' }))
|
||||
setName('')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('tag.failed', { ns: 'common' }))
|
||||
},
|
||||
})
|
||||
}
|
||||
const handleClose = () => {
|
||||
setName('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={open => !open && handleClose()}>
|
||||
<DialogContent className="w-[600px]! max-w-[600px]! rounded-xl! p-8!">
|
||||
<div className="relative pb-2 text-xl leading-[30px] font-semibold text-text-primary">{t('tag.manageTags', { ns: 'common' })}</div>
|
||||
<DialogCloseButton data-testid="tag-management-modal-close-button" className="top-4 right-4" />
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<input className="w-25 shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm leading-5 text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary focus:border-solid" placeholder={t('tag.addNew', { ns: 'common' }) || ''} autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} />
|
||||
{tagList.map(tag => (<TagItemEditor key={tag.id} tag={tag} onTagsChange={onTagsChange} />))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -1,27 +1,30 @@
|
||||
import type { TagSelectorProps } from './selector'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { Tag, TagType } from '@/contract/console/tags'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useUnmount } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { bindTag, createTag, unBindTag } from '@/service/tag'
|
||||
import { useStore as useTagStore } from './store'
|
||||
import { useCreateTagMutation } from '../hooks/use-tag-mutations'
|
||||
|
||||
type PanelProps = {
|
||||
onCreate: () => void
|
||||
} & TagSelectorProps
|
||||
const Panel = (props: PanelProps) => {
|
||||
type TagPanelProps = {
|
||||
type: TagType
|
||||
selectedTagIds: string[]
|
||||
selectedTags: Tag[]
|
||||
onOpenTagManagement?: () => void
|
||||
tagList: Tag[]
|
||||
draftTagIds?: string[]
|
||||
onDraftTagIdsChange?: (tagIds: string[]) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
export const TagPanel = (props: TagPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { targetID, type, value, selectedTags, onCacheUpdate, onChange, onCreate } = props
|
||||
const tagList = useTagStore(s => s.tagList)
|
||||
const setTagList = useTagStore(s => s.setTagList)
|
||||
const setShowTagManagementModal = useTagStore(s => s.setShowTagManagementModal)
|
||||
const [selectedTagIDs, setSelectedTagIDs] = useState<string[]>(value)
|
||||
const { type, selectedTagIds, selectedTags, tagList, onOpenTagManagement, onClose } = props
|
||||
const createTagMutation = useCreateTagMutation()
|
||||
const [localDraftTagIds, setLocalDraftTagIds] = useState<string[]>(selectedTagIds)
|
||||
const draftTagIds = props.draftTagIds ?? localDraftTagIds
|
||||
const onDraftTagIdsChange = props.onDraftTagIdsChange ?? setLocalDraftTagIds
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
@ -33,78 +36,35 @@ const Panel = (props: PanelProps) => {
|
||||
return selectedTags.filter(tag => tag.name.includes(keywords))
|
||||
}, [keywords, selectedTags])
|
||||
const filteredTagList = useMemo(() => {
|
||||
return tagList.filter(tag => tag.type === type && !value.includes(tag.id) && tag.name.includes(keywords))
|
||||
}, [type, tagList, value, keywords])
|
||||
const [creating, setCreating] = useState<boolean>(false)
|
||||
const createNewTag = async () => {
|
||||
return tagList.filter(tag => tag.type === type && !selectedTagIds.includes(tag.id) && tag.name.includes(keywords))
|
||||
}, [type, tagList, selectedTagIds, keywords])
|
||||
const createNewTag = () => {
|
||||
if (!keywords)
|
||||
return
|
||||
if (creating)
|
||||
if (createTagMutation.isPending)
|
||||
return
|
||||
try {
|
||||
setCreating(true)
|
||||
const newTag = await createTag(keywords, type)
|
||||
toast.success(t('tag.created', { ns: 'common' }))
|
||||
setTagList([
|
||||
...tagList,
|
||||
newTag,
|
||||
])
|
||||
setKeywords('')
|
||||
setCreating(false)
|
||||
onCreate()
|
||||
}
|
||||
catch {
|
||||
toast.error(t('tag.failed', { ns: 'common' }))
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
const bind = async (tagIDs: string[]) => {
|
||||
try {
|
||||
await bindTag(tagIDs, targetID, type)
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
}
|
||||
}
|
||||
const unbind = async (tagID: string) => {
|
||||
try {
|
||||
await unBindTag(tagID, targetID, type)
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
}
|
||||
}
|
||||
const selectTag = (tag: Tag) => {
|
||||
if (selectedTagIDs.includes(tag.id))
|
||||
setSelectedTagIDs(selectedTagIDs.filter(v => v !== tag.id))
|
||||
else
|
||||
setSelectedTagIDs([...selectedTagIDs, tag.id])
|
||||
}
|
||||
const valueNotChanged = useMemo(() => {
|
||||
return value.length === selectedTagIDs.length && value.every(v => selectedTagIDs.includes(v)) && selectedTagIDs.every(v => value.includes(v))
|
||||
}, [value, selectedTagIDs])
|
||||
const handleValueChange = () => {
|
||||
const addTagIDs = selectedTagIDs.filter(v => !value.includes(v))
|
||||
const removeTagIDs = value.filter(v => !selectedTagIDs.includes(v))
|
||||
const selectedTags = tagList.filter(tag => selectedTagIDs.includes(tag.id))
|
||||
onCacheUpdate(selectedTags)
|
||||
const operations: Promise<unknown>[] = []
|
||||
if (addTagIDs.length)
|
||||
operations.push(bind(addTagIDs))
|
||||
if (removeTagIDs.length)
|
||||
operations.push(...removeTagIDs.map(tagID => unbind(tagID)))
|
||||
Promise.all(operations).finally(() => {
|
||||
if (onChange)
|
||||
onChange()
|
||||
|
||||
createTagMutation.mutate({
|
||||
body: {
|
||||
name: keywords,
|
||||
type,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('tag.created', { ns: 'common' }))
|
||||
setKeywords('')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('tag.failed', { ns: 'common' }))
|
||||
},
|
||||
})
|
||||
}
|
||||
useUnmount(() => {
|
||||
if (valueNotChanged)
|
||||
return
|
||||
handleValueChange()
|
||||
})
|
||||
const selectTag = (tagId: string) => {
|
||||
if (draftTagIds.includes(tagId))
|
||||
onDraftTagIdsChange(draftTagIds.filter(v => v !== tagId))
|
||||
else
|
||||
onDraftTagIdsChange([...draftTagIds, tagId])
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur">
|
||||
<div className="p-2 pb-1">
|
||||
@ -125,16 +85,16 @@ const Panel = (props: PanelProps) => {
|
||||
{(filteredTagList.length > 0 || filteredSelectedTagList.length > 0) && (
|
||||
<div className="max-h-[232px] overflow-y-auto p-1">
|
||||
{filteredSelectedTagList.map(tag => (
|
||||
<div key={tag.id} className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={() => selectTag(tag)} data-testid="tag-row">
|
||||
<Checkbox className="shrink-0" checked={selectedTagIDs.includes(tag.id)} onCheck={noop} id={tag.id} />
|
||||
<div key={tag.id} className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={() => selectTag(tag.id)} data-testid="tag-row">
|
||||
<Checkbox className="shrink-0" checked={draftTagIds.includes(tag.id)} onCheck={noop} id={tag.id} />
|
||||
<div title={tag.name} className="grow truncate px-1 system-md-regular text-text-secondary">
|
||||
{tag.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredTagList.map(tag => (
|
||||
<div key={tag.id} className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={() => selectTag(tag)} data-testid="tag-row">
|
||||
<Checkbox className="shrink-0" checked={selectedTagIDs.includes(tag.id)} onCheck={noop} id={tag.id} />
|
||||
<div key={tag.id} className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={() => selectTag(tag.id)} data-testid="tag-row">
|
||||
<Checkbox className="shrink-0" checked={draftTagIds.includes(tag.id)} onCheck={noop} id={tag.id} />
|
||||
<div title={tag.name} className="grow truncate px-1 system-md-regular text-text-secondary">
|
||||
{tag.name}
|
||||
</div>
|
||||
@ -152,7 +112,13 @@ const Panel = (props: PanelProps) => {
|
||||
)}
|
||||
<Divider type="horizontal" className="my-0 h-px bg-divider-subtle" />
|
||||
<div className="p-1">
|
||||
<div className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={() => setShowTagManagementModal(true)}>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onOpenTagManagement?.()
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-price-tag-3-line h-4 w-4 text-text-tertiary" />
|
||||
<div className="grow truncate px-1 system-md-regular text-text-secondary">
|
||||
{t('tag.manageTags', { ns: 'common' })}
|
||||
@ -162,4 +128,3 @@ const Panel = (props: PanelProps) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Panel)
|
||||
144
web/features/tag-management/components/tag-selector.tsx
Normal file
144
web/features/tag-management/components/tag-selector.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useApplyTagBindingsMutation } from '../hooks/use-tag-mutations'
|
||||
import { TagPanel } from './tag-panel'
|
||||
import { TagTrigger } from './tag-trigger'
|
||||
|
||||
type TagSelectorProps = {
|
||||
targetId: string
|
||||
isPopover?: boolean
|
||||
position?: 'bl' | 'br'
|
||||
type: 'knowledge' | 'app'
|
||||
selectedTagIds: string[]
|
||||
selectedTags: Tag[]
|
||||
onOpenTagManagement?: () => void
|
||||
onTagsChange?: () => void
|
||||
minWidth?: number | string
|
||||
}
|
||||
|
||||
export const TagSelector = ({
|
||||
targetId,
|
||||
isPopover = true,
|
||||
position,
|
||||
type,
|
||||
selectedTagIds,
|
||||
selectedTags,
|
||||
onOpenTagManagement = () => {},
|
||||
onTagsChange,
|
||||
minWidth,
|
||||
}: TagSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draftTagIds, setDraftTagIds] = useState<string[]>(selectedTagIds)
|
||||
const applyTagBindingsMutation = useApplyTagBindingsMutation()
|
||||
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
type,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const tagNames = selectedTags.length
|
||||
? selectedTags.filter(selectedTag => tagList.find(tag => tag.id === selectedTag.id)).map(tag => tag.name)
|
||||
: []
|
||||
const placement = position === 'bl'
|
||||
? 'bottom-start'
|
||||
: position === 'br'
|
||||
? 'bottom-end'
|
||||
: 'bottom'
|
||||
const resolvedMinWidth = minWidth == null
|
||||
? undefined
|
||||
: typeof minWidth === 'number' ? `${minWidth}px` : minWidth
|
||||
const triggerLabel = tagNames.length ? tagNames.join(', ') : t('tag.addTag', { ns: 'common' })
|
||||
|
||||
const applyTagBindings = useCallback(() => {
|
||||
const draftTagIdSet = new Set(draftTagIds)
|
||||
const tagSelectionChanged = selectedTagIds.length !== draftTagIds.length
|
||||
|| selectedTagIds.some(tagId => !draftTagIdSet.has(tagId))
|
||||
|
||||
if (!tagSelectionChanged)
|
||||
return
|
||||
|
||||
const toastId = `tag-bindings-${type}-${targetId}`
|
||||
|
||||
applyTagBindingsMutation.mutate({
|
||||
currentTagIds: selectedTagIds,
|
||||
nextTagIds: draftTagIds,
|
||||
targetId,
|
||||
type,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }), {
|
||||
id: toastId,
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }), {
|
||||
id: toastId,
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
onTagsChange?.()
|
||||
},
|
||||
})
|
||||
}, [applyTagBindingsMutation, draftTagIds, onTagsChange, selectedTagIds, t, targetId, type])
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (nextOpen)
|
||||
setDraftTagIds(selectedTagIds)
|
||||
else
|
||||
applyTagBindings()
|
||||
|
||||
setOpen(nextOpen)
|
||||
}, [applyTagBindings, selectedTagIds])
|
||||
|
||||
if (!isPopover)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
aria-label={triggerLabel}
|
||||
className={cn(
|
||||
open ? 'bg-state-base-hover' : 'bg-transparent',
|
||||
'block w-full rounded-lg border-0 p-0 text-left focus:outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
)}
|
||||
>
|
||||
<TagTrigger tags={tagNames} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={4}
|
||||
popupClassName="overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
|
||||
popupProps={{
|
||||
style: {
|
||||
width: 'var(--anchor-width, auto)',
|
||||
minWidth: resolvedMinWidth,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TagPanel
|
||||
type={type}
|
||||
selectedTagIds={selectedTagIds}
|
||||
selectedTags={selectedTags}
|
||||
draftTagIds={draftTagIds}
|
||||
onDraftTagIdsChange={setDraftTagIds}
|
||||
tagList={tagList}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@ -1,11 +1,10 @@
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type TriggerProps = {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const Trigger = ({
|
||||
export const TagTrigger = ({
|
||||
tags,
|
||||
}: TriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -14,9 +13,9 @@ const Trigger = ({
|
||||
<div className="flex w-full cursor-pointer items-center gap-1 overflow-hidden rounded-lg p-1 hover:bg-state-base-hover">
|
||||
{!tags.length
|
||||
? (
|
||||
<div className="flex items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
|
||||
<div className="flex max-w-full min-w-0 items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
|
||||
<span className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" />
|
||||
<div className="system-2xs-medium-uppercase text-nowrap text-text-tertiary">
|
||||
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('tag.addTag', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
@ -24,15 +23,15 @@ const Trigger = ({
|
||||
: (
|
||||
<>
|
||||
{
|
||||
tags.map((content, index) => {
|
||||
tags.map((content) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
|
||||
data-testid={`tag-badge-${index}`}
|
||||
key={content}
|
||||
className="flex max-w-[120px] min-w-0 shrink-0 items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
|
||||
data-testid={`tag-badge-${content}`}
|
||||
>
|
||||
<span className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" />
|
||||
<div className="system-2xs-medium-uppercase text-nowrap text-text-tertiary">
|
||||
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
@ -44,5 +43,3 @@ const Trigger = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Trigger)
|
||||
@ -0,0 +1,274 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
useApplyTagBindingsMutation,
|
||||
useCreateTagMutation,
|
||||
useDeleteTagMutation,
|
||||
useUpdateTagMutation,
|
||||
} from '../use-tag-mutations'
|
||||
|
||||
const {
|
||||
bindTag,
|
||||
createTagMutationOptions,
|
||||
deleteTagMutationOptions,
|
||||
listQueryOptions,
|
||||
unbindTag,
|
||||
updateTagMutationOptions,
|
||||
} = vi.hoisted(() => ({
|
||||
bindTag: vi.fn(),
|
||||
createTagMutationOptions: vi.fn(),
|
||||
deleteTagMutationOptions: vi.fn(),
|
||||
listQueryOptions: vi.fn((options: { input: { query: { type: string } } }) => ({
|
||||
queryKey: ['console', 'tags', 'list', options.input.query.type],
|
||||
})),
|
||||
unbindTag: vi.fn(),
|
||||
updateTagMutationOptions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
tags: {
|
||||
bind: bindTag,
|
||||
unbind: unbindTag,
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
tags: {
|
||||
create: {
|
||||
mutationOptions: createTagMutationOptions,
|
||||
},
|
||||
update: {
|
||||
mutationOptions: updateTagMutationOptions,
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: deleteTagMutationOptions,
|
||||
},
|
||||
list: {
|
||||
queryOptions: listQueryOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const appTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
id: 'tag-1',
|
||||
name: 'Frontend',
|
||||
type: 'app',
|
||||
binding_count: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderMutationHook = <TResult,>(hook: () => TResult) => {
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return {
|
||||
queryClient,
|
||||
...renderHook(hook, { wrapper }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('useTagMutations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
bindTag.mockResolvedValue(undefined)
|
||||
unbindTag.mockResolvedValue(undefined)
|
||||
createTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
|
||||
mutationFn: ({ body }: { body: { name: string, type: Tag['type'] } }) => Promise.resolve(appTag({
|
||||
id: 'created-tag',
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
binding_count: 0,
|
||||
})),
|
||||
...options,
|
||||
}))
|
||||
updateTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
|
||||
mutationFn: () => Promise.resolve({ result: 'success' }),
|
||||
...options,
|
||||
}))
|
||||
deleteTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
|
||||
mutationFn: () => Promise.resolve({ result: 'success' }),
|
||||
...options,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Create Tag', () => {
|
||||
it('should prepend the created tag to the matching tag list cache', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useCreateTagMutation())
|
||||
const cacheKey = ['console', 'tags', 'list', 'app']
|
||||
queryClient.setQueryData<Tag[]>(cacheKey, [
|
||||
appTag({ id: 'existing-tag', name: 'Existing' }),
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
body: {
|
||||
name: 'Created',
|
||||
type: 'app',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toEqual([
|
||||
appTag({ id: 'created-tag', name: 'Created', binding_count: 0 }),
|
||||
appTag({ id: 'existing-tag', name: 'Existing' }),
|
||||
])
|
||||
expect(listQueryOptions).toHaveBeenCalledWith({
|
||||
input: {
|
||||
query: {
|
||||
type: 'app',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave an absent tag list cache absent after creating a tag', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useCreateTagMutation())
|
||||
const cacheKey = ['console', 'tags', 'list', 'knowledge']
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
body: {
|
||||
name: 'Knowledge',
|
||||
type: 'knowledge',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update Tag', () => {
|
||||
it('should rename only the matching tag in the matching tag list cache', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useUpdateTagMutation('app'))
|
||||
const appCacheKey = ['console', 'tags', 'list', 'app']
|
||||
const knowledgeCacheKey = ['console', 'tags', 'list', 'knowledge']
|
||||
queryClient.setQueryData<Tag[]>(appCacheKey, [
|
||||
appTag({ id: 'tag-1', name: 'Old name' }),
|
||||
appTag({ id: 'tag-2', name: 'Unchanged' }),
|
||||
])
|
||||
queryClient.setQueryData<Tag[]>(knowledgeCacheKey, [
|
||||
appTag({ id: 'tag-1', name: 'Old knowledge name', type: 'knowledge' }),
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
params: {
|
||||
tagId: 'tag-1',
|
||||
},
|
||||
body: {
|
||||
name: 'Renamed',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<Tag[]>(appCacheKey)).toEqual([
|
||||
appTag({ id: 'tag-1', name: 'Renamed' }),
|
||||
appTag({ id: 'tag-2', name: 'Unchanged' }),
|
||||
])
|
||||
expect(queryClient.getQueryData<Tag[]>(knowledgeCacheKey)).toEqual([
|
||||
appTag({ id: 'tag-1', name: 'Old knowledge name', type: 'knowledge' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Tag', () => {
|
||||
it('should remove the deleted tag from the matching tag list cache', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useDeleteTagMutation('app'))
|
||||
const cacheKey = ['console', 'tags', 'list', 'app']
|
||||
queryClient.setQueryData<Tag[]>(cacheKey, [
|
||||
appTag({ id: 'tag-1' }),
|
||||
appTag({ id: 'tag-2', name: 'Backend' }),
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
params: {
|
||||
tagId: 'tag-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toEqual([
|
||||
appTag({ id: 'tag-2', name: 'Backend' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apply Tag Bindings', () => {
|
||||
it('should bind added tags and unbind removed tags using batched request bodies', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useApplyTagBindingsMutation())
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
currentTagIds: ['tag-1', 'tag-2'],
|
||||
nextTagIds: ['tag-2', 'tag-3', 'tag-4'],
|
||||
targetId: 'app-1',
|
||||
type: 'app',
|
||||
})
|
||||
})
|
||||
|
||||
expect(bindTag).toHaveBeenCalledWith({
|
||||
body: {
|
||||
tag_ids: ['tag-3', 'tag-4'],
|
||||
target_id: 'app-1',
|
||||
type: 'app',
|
||||
},
|
||||
})
|
||||
expect(unbindTag).toHaveBeenCalledWith({
|
||||
body: {
|
||||
tag_ids: ['tag-1'],
|
||||
target_id: 'app-1',
|
||||
type: 'app',
|
||||
},
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['console', 'tags', 'list', 'app'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip network requests when tag bindings do not change but still invalidate tags', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useApplyTagBindingsMutation())
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
currentTagIds: ['tag-1'],
|
||||
nextTagIds: ['tag-1'],
|
||||
targetId: 'knowledge-1',
|
||||
type: 'knowledge',
|
||||
})
|
||||
})
|
||||
|
||||
expect(bindTag).not.toHaveBeenCalled()
|
||||
expect(unbindTag).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['console', 'tags', 'list', 'knowledge'],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
102
web/features/tag-management/hooks/use-tag-mutations.ts
Normal file
102
web/features/tag-management/hooks/use-tag-mutations.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { TagType } from '@/contract/console/tags'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
|
||||
const getTagsListQueryOptions = (tagType: TagType) => consoleQuery.tags.list.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
type: tagType,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const useCreateTagMutation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation(consoleQuery.tags.create.mutationOptions({
|
||||
onSuccess: (tag) => {
|
||||
queryClient.setQueryData(
|
||||
getTagsListQueryOptions(tag.type).queryKey,
|
||||
oldTags => oldTags ? [tag, ...oldTags] : oldTags,
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const useUpdateTagMutation = (tagType: TagType) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation(consoleQuery.tags.update.mutationOptions({
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.setQueryData(
|
||||
getTagsListQueryOptions(tagType).queryKey,
|
||||
oldTags => oldTags?.map(tag => tag.id === variables.params.tagId
|
||||
? {
|
||||
...tag,
|
||||
name: variables.body.name,
|
||||
}
|
||||
: tag),
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const useDeleteTagMutation = (tagType: TagType) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation(consoleQuery.tags.delete.mutationOptions({
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.setQueryData(
|
||||
getTagsListQueryOptions(tagType).queryKey,
|
||||
oldTags => oldTags?.filter(tag => tag.id !== variables.params.tagId),
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
type ApplyTagBindingsInput = {
|
||||
currentTagIds: string[]
|
||||
nextTagIds: string[]
|
||||
targetId: string
|
||||
type: TagType
|
||||
}
|
||||
|
||||
export const useApplyTagBindingsMutation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['tag-bindings', 'apply'],
|
||||
mutationFn: async ({ currentTagIds, nextTagIds, targetId, type }: ApplyTagBindingsInput) => {
|
||||
const addTagIds = nextTagIds.filter(tagId => !currentTagIds.includes(tagId))
|
||||
const removeTagIds = currentTagIds.filter(tagId => !nextTagIds.includes(tagId))
|
||||
const operations: Promise<unknown>[] = []
|
||||
|
||||
if (addTagIds.length) {
|
||||
operations.push(consoleClient.tags.bind({
|
||||
body: {
|
||||
tag_ids: addTagIds,
|
||||
target_id: targetId,
|
||||
type,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (removeTagIds.length) {
|
||||
operations.push(consoleClient.tags.unbind({
|
||||
body: {
|
||||
tag_ids: removeTagIds,
|
||||
target_id: targetId,
|
||||
type,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
return Promise.all(operations)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: getTagsListQueryOptions(variables.type).queryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { Tag } from './constant'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { ToastHost } from '@langgenius/dify-ui/toast'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import TagManagementModal from '.'
|
||||
import { useStore as useTagStore } from './store'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal'
|
||||
|
||||
const INITIAL_TAGS: Tag[] = [
|
||||
{ id: 'tag-product', name: 'Product', type: 'app', binding_count: 12 },
|
||||
@ -18,19 +17,11 @@ const TagManagementPlayground = ({
|
||||
}: {
|
||||
type?: 'app' | 'knowledge'
|
||||
}) => {
|
||||
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
|
||||
const tagsRef = useRef<Tag[]>(INITIAL_TAGS)
|
||||
const setTagList = useTagStore(s => s.setTagList)
|
||||
const showModal = useTagStore(s => s.showTagManagementModal)
|
||||
const setShowModal = useTagStore(s => s.setShowTagManagementModal)
|
||||
const [showModal, setShowModal] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setTagList(tagsRef.current)
|
||||
setShowModal(true)
|
||||
}, [setTagList, setShowModal])
|
||||
|
||||
useEffect(() => {
|
||||
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
|
||||
const originalFetch = globalThis.fetch?.bind(globalThis)
|
||||
let tags = [...INITIAL_TAGS]
|
||||
|
||||
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
@ -41,22 +32,21 @@ const TagManagementPlayground = ({
|
||||
if (parsedUrl.pathname.endsWith('/tags')) {
|
||||
if (method === 'GET') {
|
||||
const tagType = parsedUrl.searchParams.get('type') || 'app'
|
||||
const payload = tagsRef.current.filter(tag => tag.type === tagType)
|
||||
const payload = tags.filter(tag => tag.type === tagType)
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const body = await request.clone().json() as { name: string, type: string }
|
||||
const body = await request.clone().json() as { name: string, type: Tag['type'] }
|
||||
const newTag: Tag = {
|
||||
id: `tag-${Date.now()}`,
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
binding_count: 0,
|
||||
}
|
||||
tagsRef.current = [newTag, ...tagsRef.current]
|
||||
setTagList(tagsRef.current)
|
||||
tags = [newTag, ...tags]
|
||||
return new Response(JSON.stringify(newTag), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -64,15 +54,15 @@ const TagManagementPlayground = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedUrl.pathname.endsWith('/tag-bindings/create') || parsedUrl.pathname.endsWith('/tag-bindings/remove')) {
|
||||
if (parsedUrl.pathname.endsWith('/tag-bindings') || parsedUrl.pathname.endsWith('/tag-bindings/remove')) {
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
if (originalFetchRef.current)
|
||||
return originalFetchRef.current(request)
|
||||
if (originalFetch)
|
||||
return originalFetch(request)
|
||||
|
||||
throw new Error(`Unhandled request in mock fetch: ${url}`)
|
||||
}
|
||||
@ -80,10 +70,10 @@ const TagManagementPlayground = ({
|
||||
globalThis.fetch = handler as typeof globalThis.fetch
|
||||
|
||||
return () => {
|
||||
if (originalFetchRef.current)
|
||||
globalThis.fetch = originalFetchRef.current
|
||||
if (originalFetch)
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
}, [setTagList])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -98,7 +88,7 @@ const TagManagementPlayground = ({
|
||||
</button>
|
||||
<p className="text-xs text-text-tertiary">Mocked tag management flows with create and bind actions.</p>
|
||||
</div>
|
||||
<TagManagementModal show={showModal} type={type} />
|
||||
<TagManagementModal show={showModal} type={type} onClose={() => setShowModal(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import type { DataSourceNotionPage, DataSourceProvider } from './common'
|
||||
import type { DatasourceType } from './pipeline'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import type { MetadataItemWithValue } from '@/app/components/datasets/metadata/types'
|
||||
import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { ExternalKnowledgeBase, General, ParentChild, Qa } from '@/app/components/base/icons/src/public/knowledge/dataset-card'
|
||||
|
||||
@ -27,7 +27,7 @@ import { useInvalid } from '../use-base'
|
||||
|
||||
const NAME_SPACE = 'dataset'
|
||||
|
||||
const DatasetListKey = [NAME_SPACE, 'list']
|
||||
const datasetListQueryKey = [NAME_SPACE, 'list']
|
||||
|
||||
const normalizeDatasetsParams = (params: Partial<FetchDatasetsParams['params']> = {}) => {
|
||||
const {
|
||||
@ -62,17 +62,16 @@ export const useInfiniteDatasets = (
|
||||
options?: UseInfiniteDatasetsOptions,
|
||||
) => {
|
||||
const normalizedParams = normalizeDatasetsParams(params)
|
||||
const buildUrl = (pageParam: number | undefined) => {
|
||||
const queryString = qs.stringify({
|
||||
...normalizedParams,
|
||||
page: pageParam ?? normalizedParams.page,
|
||||
}, { indices: false })
|
||||
return `/datasets?${queryString}`
|
||||
}
|
||||
|
||||
return useInfiniteQuery<DataSetListResponse>({
|
||||
queryKey: [...DatasetListKey, 'infinite', normalizedParams],
|
||||
queryFn: ({ pageParam = normalizedParams.page }) => get<DataSetListResponse>(buildUrl(pageParam as number | undefined)),
|
||||
queryKey: [...datasetListQueryKey, 'infinite', normalizedParams],
|
||||
queryFn: ({ pageParam = normalizedParams.page }) => {
|
||||
const queryString = qs.stringify({
|
||||
...normalizedParams,
|
||||
page: pageParam as number | undefined,
|
||||
}, { indices: false })
|
||||
return get<DataSetListResponse>(`/datasets?${queryString}`)
|
||||
},
|
||||
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: normalizedParams.page,
|
||||
staleTime: 0,
|
||||
@ -84,7 +83,7 @@ export const useInfiniteDatasets = (
|
||||
export const useDatasetList = (params: DatasetListRequest) => {
|
||||
const { initialPage, tag_ids, limit, include_all, keyword } = params
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...DatasetListKey, initialPage, tag_ids, limit, include_all, keyword],
|
||||
queryKey: [...datasetListQueryKey, initialPage, tag_ids, limit, include_all, keyword],
|
||||
queryFn: ({ pageParam = 1 }) => {
|
||||
const urlParams = qs.stringify({
|
||||
tag_ids,
|
||||
@ -101,7 +100,7 @@ export const useDatasetList = (params: DatasetListRequest) => {
|
||||
}
|
||||
|
||||
export const useInvalidDatasetList = () => {
|
||||
return useInvalid([...DatasetListKey])
|
||||
return useInvalid([...datasetListQueryKey])
|
||||
}
|
||||
|
||||
export const datasetDetailQueryKeyPrefix = [NAME_SPACE, 'detail']
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import { del, get, patch, post } from './base'
|
||||
|
||||
export const fetchTagList = (type: string) => {
|
||||
return get<Tag[]>('/tags', { params: { type } })
|
||||
}
|
||||
|
||||
export const createTag = (name: string, type: string) => {
|
||||
return post<Tag>('/tags', {
|
||||
body: {
|
||||
name,
|
||||
type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const updateTag = (tagID: string, name: string) => {
|
||||
return patch(`/tags/${tagID}`, {
|
||||
body: {
|
||||
name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteTag = (tagID: string) => {
|
||||
return del(`/tags/${tagID}`)
|
||||
}
|
||||
|
||||
export const bindTag = (tagIDList: string[], targetID: string, type: string) => {
|
||||
return post('/tag-bindings/create', {
|
||||
body: {
|
||||
tag_ids: tagIDList,
|
||||
target_id: targetID,
|
||||
type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const unBindTag = (tagID: string, targetID: string, type: string) => {
|
||||
return post('/tag-bindings/remove', {
|
||||
body: {
|
||||
tag_id: tagID,
|
||||
target_id: targetID,
|
||||
type,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { CollectionType } from '@/app/components/tools/types'
|
||||
import type { UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import type { LanguagesSupported } from '@/i18n-config/language'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user