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:
yyh 2026-05-07 09:36:10 +08:00 committed by GitHub
parent 7e6745e105
commit 00bf3f83f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 1851 additions and 2007 deletions

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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>
)
}

View File

@ -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" />

View File

@ -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}
</>
)
}

View File

@ -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} />)

View File

@ -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

View File

@ -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>

View File

@ -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 && (

View File

@ -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()
})
})
})

View File

@ -1,6 +0,0 @@
export type Tag = {
id: string
name: string
type: string
binding_count: number
}

View File

@ -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

View File

@ -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

View File

@ -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 })),
}))

View File

@ -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

View File

@ -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',

View File

@ -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()
})

View File

@ -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} />
),
}))

View File

@ -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')
})
})
})

View File

@ -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

View File

@ -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(() =>

View File

@ -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,

View File

@ -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

View File

@ -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" />

View File

@ -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>
)

View File

@ -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', () => {

View File

@ -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'

View 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>())

View File

@ -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,

View 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')
})
})
})

View File

@ -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} />)

View File

@ -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()
})
})
})
})

View File

@ -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()
})
})
})

View File

@ -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()

View File

@ -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']}
/>,
)

View File

@ -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)

View File

@ -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: [],
}))
})
})
})

View 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>
)
}

View 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>
)

View File

@ -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

View File

@ -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

View File

@ -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>
)
}

View File

@ -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)

View 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>
)
}

View File

@ -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)

View File

@ -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'],
})
})
})
})
})

View 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,
})
},
})
}

View File

@ -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)} />
</>
)
}

View File

@ -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'

View File

@ -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']

View File

@ -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,
},
})
}

View File

@ -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'