Merge branch 'main' into tp

This commit is contained in:
JzoNg 2026-05-07 10:16:57 +08:00
commit cd91757623
110 changed files with 3682 additions and 2238 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

@ -246,8 +246,18 @@ class TidbService:
userPrefix = item["userPrefix"]
if state == "ACTIVE" and len(userPrefix) > 0:
cluster_info = tidb_serverless_list_map[item["clusterId"]]
cluster_info.status = TidbAuthBindingStatus.ACTIVE
cluster_info.account = f"{userPrefix}.root"
if not cluster_info.qdrant_endpoint:
cluster_info.qdrant_endpoint = TidbService.extract_qdrant_endpoint(
item
) or TidbService.fetch_qdrant_endpoint(api_url, public_key, private_key, item["clusterId"])
if cluster_info.qdrant_endpoint:
cluster_info.status = TidbAuthBindingStatus.ACTIVE
else:
logger.warning(
"Cluster %s is ACTIVE but qdrant endpoint is not ready; will retry later",
item["clusterId"],
)
db.session.add(cluster_info)
db.session.commit()
else:

View File

@ -1,8 +1,11 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from dify_vdb_tidb_on_qdrant.tidb_service import TidbService
from models.enums import TidbAuthBindingStatus
class TestExtractQdrantEndpoint:
"""Unit tests for TidbService.extract_qdrant_endpoint."""
@ -216,3 +219,86 @@ class TestBatchCreateEdgeCases:
private_key="priv",
region="us-east-1",
)
class TestBatchUpdateTidbServerlessClusterStatus:
"""Verify that status updates only expose clusters after qdrant endpoint is ready."""
@patch("dify_vdb_tidb_on_qdrant.tidb_service.db")
@patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client")
def test_sets_active_when_batch_response_contains_endpoint(self, mock_http, mock_db):
binding = SimpleNamespace(
cluster_id="c-1",
status=TidbAuthBindingStatus.CREATING,
account="root",
qdrant_endpoint=None,
)
mock_http.get.return_value = MagicMock(
status_code=200,
json=lambda: {
"clusters": [
{
"clusterId": "c-1",
"state": "ACTIVE",
"userPrefix": "pfx",
"endpoints": {"public": {"host": "gw.tidbcloud.com"}},
}
]
},
)
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
assert binding.account == "pfx.root"
assert binding.qdrant_endpoint == "https://qdrant-gw.tidbcloud.com"
assert binding.status == TidbAuthBindingStatus.ACTIVE
mock_db.session.add.assert_called_once_with(binding)
mock_db.session.commit.assert_called_once()
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com")
@patch("dify_vdb_tidb_on_qdrant.tidb_service.db")
@patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client")
def test_fetches_endpoint_when_batch_response_omits_it(self, mock_http, mock_db, mock_fetch_endpoint):
binding = SimpleNamespace(
cluster_id="c-1",
status=TidbAuthBindingStatus.CREATING,
account="root",
qdrant_endpoint=None,
)
mock_http.get.return_value = MagicMock(
status_code=200,
json=lambda: {"clusters": [{"clusterId": "c-1", "state": "ACTIVE", "userPrefix": "pfx", "endpoints": {}}]},
)
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
assert binding.account == "pfx.root"
assert binding.qdrant_endpoint == "https://qdrant-gw.tidbcloud.com"
assert binding.status == TidbAuthBindingStatus.ACTIVE
mock_fetch_endpoint.assert_called_once_with("url", "pub", "priv", "c-1")
mock_db.session.add.assert_called_once_with(binding)
mock_db.session.commit.assert_called_once()
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value=None)
@patch("dify_vdb_tidb_on_qdrant.tidb_service.db")
@patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client")
def test_keeps_creating_when_endpoint_is_not_ready(self, mock_http, mock_db, mock_fetch_endpoint):
binding = SimpleNamespace(
cluster_id="c-1",
status=TidbAuthBindingStatus.CREATING,
account="root",
qdrant_endpoint=None,
)
mock_http.get.return_value = MagicMock(
status_code=200,
json=lambda: {"clusters": [{"clusterId": "c-1", "state": "ACTIVE", "userPrefix": "pfx", "endpoints": {}}]},
)
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
assert binding.account == "pfx.root"
assert binding.qdrant_endpoint is None
assert binding.status == TidbAuthBindingStatus.CREATING
mock_fetch_endpoint.assert_called_once_with("url", "pub", "priv", "c-1")
mock_db.session.add.assert_called_once_with(binding)
mock_db.session.commit.assert_called_once()

View File

@ -174,7 +174,7 @@ dev = [
# "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved.
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"pyrefly>=0.62.0",
"pyrefly>=0.64.0",
"xinference-client>=2.7.0",
]

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

24
api/uv.lock generated
View File

@ -1629,7 +1629,7 @@ dev = [
{ name = "lxml-stubs", specifier = ">=0.5.1" },
{ name = "mypy", specifier = ">=1.20.2" },
{ name = "pandas-stubs", specifier = ">=3.0.0" },
{ name = "pyrefly", specifier = ">=0.62.0" },
{ name = "pyrefly", specifier = ">=0.64.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-benchmark", specifier = ">=5.2.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
@ -5359,19 +5359,19 @@ wheels = [
[[package]]
name = "pyrefly"
version = "0.62.0"
version = "0.64.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" }
sdist = { url = "https://files.pythonhosted.org/packages/85/99/923622d7b52ef84e83f357b19bd08dff063ccc5f4472b003105e1f308d93/pyrefly-0.64.0.tar.gz", hash = "sha256:fbfcdb0031adadc340b6c64cb41c6094c95349ee952fe3d4c143866add829172", size = 5678516, upload-time = "2026-05-06T17:28:44.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" },
{ url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" },
{ url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" },
{ url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c8/5a7ba88c4fa1b5090d877f70fa1b742b921b9e7d8d3f4b6b9b1ba1820850/pyrefly-0.62.0-py3-none-win32.whl", hash = "sha256:1cd98edc20cab5bac8016c9220ee66080e39bd22e7f0e9bb3e2c4e2be1555eed", size = 12010170, upload-time = "2026-04-20T17:12:06.791Z" },
{ url = "https://files.pythonhosted.org/packages/2e/78/d8f810de010ff2ed594c630c724fd817ef430963249e9eb396ce8f785e9d/pyrefly-0.62.0-py3-none-win_amd64.whl", hash = "sha256:6994f8ee7d6720325ee52207fbdaca98a799a1efe462bb5ba90c47160f7f3e6e", size = 12861816, upload-time = "2026-04-20T17:12:09.689Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a9/ac824ef6a3f50b7c0ec5974471f8f2cb205cd1edd53a5abbcf7ba37feb5d/pyrefly-0.62.0-py3-none-win_arm64.whl", hash = "sha256:362a5d47a5ac5aaa5258091e878a1759ff8b687d8cf462af1c516144f7b0108a", size = 12352977, upload-time = "2026-04-20T17:12:12.736Z" },
{ url = "https://files.pythonhosted.org/packages/b8/1c/b001b7e84a811dbb3c85e31bd4bfc3edfa3c94438140cd1d6e8c06b7c1df/pyrefly-0.64.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:683b317d8d0e815fb2ad75b7e0fa6c15eed5be4bcbc407dc13312984da3a9c47", size = 13287462, upload-time = "2026-05-06T17:28:19.169Z" },
{ url = "https://files.pythonhosted.org/packages/89/02/1e6fcd311bd7c24aaccc0afb998d584e1fa6c370e1428b4b091103760efe/pyrefly-0.64.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:96913cc4f066a7bd008b9dba8e3951234e92bb8a3a2cb1aea0e274fd2a444c55", size = 12777104, upload-time = "2026-05-06T17:28:22.047Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2b/3f347b8d97c9065d6ace14a22591c8d91e64610e74e0d4f214b3025ebcf7/pyrefly-0.64.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2ae557e1b6a6a5bda844806cae10b212cf84ea786ece10d55083a0321ee1705", size = 37064924, upload-time = "2026-05-06T17:28:24.743Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/0b40175e930a96139a8e9f62a8e1db7f9a5e9df8e6cef08bf280affcb05e/pyrefly-0.64.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d062ac1744346efacd7df23c6bbff662ad29ed495923cb59ede656a306355655", size = 39719832, upload-time = "2026-05-06T17:28:28.042Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4b/0afb4ad02eb67ddb299ff3f7108ceb307e520578b00e900d07f2371423ca/pyrefly-0.64.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6850b305d45121911fbe25ad56497d2e887b387ea50644ba15a8ad2a8cf855f4", size = 37861666, upload-time = "2026-05-06T17:28:31.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1b/f5390f8678433708288afab13f043ddd021a55dba3f665360d2c9396ee04/pyrefly-0.64.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a259925620a84fe87cd30a82643ec524eeef631f0c4ec5af81a21e006c2f5b1", size = 42634235, upload-time = "2026-05-06T17:28:34.405Z" },
{ url = "https://files.pythonhosted.org/packages/47/f7/4b66934e375dde3e4d75373b1a94eb7e7c0c0c788e94267641a223930180/pyrefly-0.64.0-py3-none-win32.whl", hash = "sha256:20317f6dd97e22bc508b8dbc537e59b0ab58e384113ee61920c87ed1a6a12f62", size = 12213388, upload-time = "2026-05-06T17:28:37.146Z" },
{ url = "https://files.pythonhosted.org/packages/0a/15/653523d99795041a1be6dadf7a73225317cb2aae4b21e6df57edbce807f0/pyrefly-0.64.0-py3-none-win_amd64.whl", hash = "sha256:e88fc6a83add9b7c2224be0f74df1b0db10b3af856ae30e4e0a90ba3644c712f", size = 13136719, upload-time = "2026-05-06T17:28:39.767Z" },
{ url = "https://files.pythonhosted.org/packages/50/bb/9ea1c26b511b38a3e1eefc1bd3de7d3f65b2bbfdb59295f3244f61564a81/pyrefly-0.64.0-py3-none-win_arm64.whl", hash = "sha256:73744bd95e836abda0d08e9cdcf008142090ae0124c8f8ff477c944b60c0343c", size = 12526050, upload-time = "2026-05-06T17:28:42.077Z" },
]
[[package]]

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
@ -1921,11 +1898,6 @@
"count": 4
}
},
"web/app/components/billing/plan/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/billing/pricing/assets/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 12
@ -2359,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
@ -2469,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
@ -5099,11 +5055,6 @@
"count": 5
}
},
"web/app/education-apply/verify-state-modal.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/forgot-password/ForgotPasswordForm.spec.tsx": {
"ts/no-explicit-any": {
"count": 5
@ -5325,11 +5276,6 @@
"count": 1
}
},
"web/plugins/dev-proxy/server.spec.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/scripts/component-analyzer.js": {
"regexp/no-unused-capturing-group": {
"count": 6
@ -5383,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

@ -21,10 +21,14 @@ NEXT_PUBLIC_SOCKET_URL=ws://localhost:5001
# The frontend keeps requesting http://localhost:5001 directly,
# the proxy server will forward the request to the target server,
# so that you don't need to run a separate backend server and use online API in development.
# Supported values: dify, enterprise.
# Defaults to dify. Enterprise target listens on port 8082 by default.
HONO_PROXY_TARGET=dify
HONO_PROXY_HOST=127.0.0.1
HONO_PROXY_PORT=5001
HONO_PROXY_PORT=
HONO_CONSOLE_API_PROXY_TARGET=
HONO_PUBLIC_API_PROXY_TARGET=
HONO_ENTERPRISE_API_PROXY_TARGET=
# The API PREFIX for MARKETPLACE
NEXT_PUBLIC_MARKETPLACE_API_PREFIX=https://marketplace.dify.ai/api/v1

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

@ -57,6 +57,8 @@ pnpm -C web run dev
pnpm -C web run dev:vinext
# (optional) start the dev proxy server so that you can use online API in development
pnpm -C web run dev:proxy
# (optional) start the dev proxy for the Enterprise frontend; it listens on 8082 by default
pnpm -C web run dev:proxy -- --target enterprise
```
Open <http://localhost:3000> with your browser to see the result.

View File

@ -13,6 +13,7 @@ export const baseProviderContextValue: ProviderContextState = {
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,

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

@ -1,10 +1,8 @@
'use client'
import {
useEffect,
useMemo,
} from 'react'
import { useEffect } from 'react'
import EducationApplyPage from '@/app/education-apply/education-apply-page'
import RootLoading from '@/app/loading'
import { useProviderContext } from '@/context/provider-context'
import {
useRouter,
@ -13,17 +11,24 @@ import {
export default function EducationApply() {
const router = useRouter()
const { enableEducationPlan } = useProviderContext()
const {
enableEducationPlan,
isFetchedPlanInfo,
isLoadingEducationAccountInfo,
} = useProviderContext()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const showEducationApplyPage = useMemo(() => {
return enableEducationPlan && token
}, [enableEducationPlan, token])
useEffect(() => {
if (!showEducationApplyPage)
if (!isFetchedPlanInfo)
return
if (!enableEducationPlan || !token)
router.replace('/')
}, [showEducationApplyPage, router])
}, [enableEducationPlan, isFetchedPlanInfo, router, token])
if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo)
return <RootLoading />
return <EducationApplyPage />
}

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

@ -31,6 +31,7 @@ const defaultProviderContext = {
isAPIKeySet: false,
plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,

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

@ -0,0 +1,37 @@
'use client'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { fetchSubscriptionUrls } from '@/service/billing'
import { Plan } from '../type'
export const useEducationDiscount = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager } = useAppContext()
const [isEducationDiscountLoading, setIsEducationDiscountLoading] = useState(false)
const handleEducationDiscount = useCallback(async () => {
if (isEducationDiscountLoading)
return
if (!isCurrentWorkspaceManager) {
toast.error(t('buyPermissionDeniedTip', { ns: 'billing' }))
return
}
setIsEducationDiscountLoading(true)
try {
const res = await fetchSubscriptionUrls(Plan.professional, 'year')
window.location.href = res.url
}
finally {
setIsEducationDiscountLoading(false)
}
}, [isCurrentWorkspaceManager, isEducationDiscountLoading, t])
return {
handleEducationDiscount,
isEducationDiscountLoading,
}
}

View File

@ -1,11 +1,15 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { fetchSubscriptionUrls } from '@/service/billing'
import { Plan, SelfHostedPlan } from '../../type'
import PlanComp from '../index'
let currentPath = '/billing'
const push = vi.fn()
let isCurrentWorkspaceManager = true
let assignedHref = ''
const originalLocation = window.location
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push }),
@ -27,10 +31,16 @@ vi.mock('@/context/provider-context', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { email: 'user@example.com' },
isCurrentWorkspaceManager: true,
isCurrentWorkspaceManager,
}),
}))
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: vi.fn(),
}))
const fetchSubscriptionUrlsMock = vi.mocked(fetchSubscriptionUrls)
const mutateAsyncMock = vi.fn()
let isPending = false
vi.mock('@/service/use-education', () => ({
@ -78,10 +88,26 @@ describe('PlanComp', () => {
},
}
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() {
return assignedHref
},
set href(value: string) {
assignedHref = value
},
} as unknown as Location,
})
})
beforeEach(() => {
vi.clearAllMocks()
currentPath = '/billing'
isPending = false
isCurrentWorkspaceManager = true
assignedHref = ''
providerContextMock.mockReturnValue({
plan: planMock,
enableEducationPlan: true,
@ -90,6 +116,14 @@ describe('PlanComp', () => {
})
mutateAsyncMock.mockReset()
mutateAsyncMock.mockResolvedValue({ token: 'token' })
fetchSubscriptionUrlsMock.mockResolvedValue({ url: 'https://subscription.example' })
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
it('renders plan info and handles education verify success', async () => {
@ -170,6 +204,49 @@ describe('PlanComp', () => {
expect(screen.getByText('education.toVerified'))!.toBeInTheDocument()
})
it('shows education discount button and keeps upgrade button for education accounts', async () => {
providerContextMock.mockReturnValue({
plan: { ...planMock, type: Plan.sandbox },
enableEducationPlan: true,
allowRefreshEducationVerify: false,
isEducationAccount: true,
})
render(<PlanComp loc="billing-page" />)
fireEvent.click(screen.getByText('education.useEducationDiscount'))
await waitFor(() => {
expect(fetchSubscriptionUrlsMock).toHaveBeenCalledWith(Plan.professional, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
expect(screen.getByTestId('plan-upgrade-btn'))!.toBeInTheDocument()
})
it('does not show education discount button for non-sandbox education accounts', () => {
providerContextMock.mockReturnValue({
plan: planMock,
enableEducationPlan: true,
allowRefreshEducationVerify: false,
isEducationAccount: true,
})
render(<PlanComp loc="billing-page" />)
expect(screen.queryByText('education.useEducationDiscount')).not.toBeInTheDocument()
})
it('does not show education discount button for non-manager sandbox education accounts', () => {
isCurrentWorkspaceManager = false
providerContextMock.mockReturnValue({
plan: { ...planMock, type: Plan.sandbox },
enableEducationPlan: true,
allowRefreshEducationVerify: false,
isEducationAccount: true,
})
render(<PlanComp loc="billing-page" />)
expect(screen.queryByText('education.useEducationDiscount')).not.toBeInTheDocument()
})
it('renders enterprise plan without upgrade button', () => {
providerContextMock.mockReturnValue({
plan: { ...planMock, type: SelfHostedPlan.enterprise },

View File

@ -23,6 +23,7 @@ import { useEducationVerify } from '@/service/use-education'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import { Loading } from '../../base/icons/src/public/thought'
import { NUM_INFINITE } from '../config'
import { useEducationDiscount } from '../hooks/use-education-discount'
import { Plan, SelfHostedPlan } from '../type'
import UpgradeBtn from '../upgrade-btn'
import AppsInfo from '../usage-info/apps-info'
@ -39,12 +40,13 @@ const PlanComp: FC<Props> = ({
const { t } = useTranslation()
const router = useRouter()
const path = usePathname()
const { userProfile } = useAppContext()
const { userProfile, isCurrentWorkspaceManager } = useAppContext()
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext()
const isAboutToExpire = allowRefreshEducationVerify
const {
type,
} = plan
const isEnterprisePlan = String(type) === SelfHostedPlan.enterprise
const {
usage,
@ -65,6 +67,7 @@ const PlanComp: FC<Props> = ({
})()
const [showModal, setShowModal] = React.useState(false)
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const { mutateAsync, isPending } = useEducationVerify()
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const unmountedRef = useUnmountedRef()
@ -97,7 +100,7 @@ const PlanComp: FC<Props> = ({
{plan.type === Plan.team && (
<Team />
)}
{(plan.type as any) === SelfHostedPlan.enterprise && (
{isEnterprisePlan && (
<Enterprise />
)}
<div className="mt-1 flex items-center">
@ -115,7 +118,14 @@ const PlanComp: FC<Props> = ({
{isPending && <Loading className="ml-1 animate-spin-slow" />}
</Button>
)}
{(plan.type as any) !== SelfHostedPlan.enterprise && (
{enableEducationPlan && isEducationAccount && type === Plan.sandbox && isCurrentWorkspaceManager && (
<Button variant="ghost" onClick={handleEducationDiscount} disabled={isEducationDiscountLoading}>
<RiGraduationCapLine className="mr-1 h-4 w-4" />
{t('useEducationDiscount', { ns: 'education' })}
{isEducationDiscountLoading && <Loading className="ml-1 animate-spin-slow" />}
</Button>
)}
{!isEnterprisePlan && (
<UpgradeBtn
className="shrink-0"
isPlain={type === Plan.team}

View File

@ -60,6 +60,8 @@ describe('Pricing', () => {
usage: buildUsage(),
total: buildUsage(),
},
enableEducationPlan: false,
isEducationAccount: false,
})
;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage)
})
@ -72,6 +74,39 @@ describe('Pricing', () => {
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
})
it('should default to yearly billing for education accounts', () => {
;(useProviderContext as Mock).mockReturnValue({
plan: {
type: Plan.sandbox,
usage: buildUsage(),
total: buildUsage(),
},
enableEducationPlan: true,
isEducationAccount: true,
})
render(<Pricing onCancel={vi.fn()} />)
expect(screen.getByRole('switch')).toBeChecked()
})
it('should not default to yearly billing for non-manager education accounts', () => {
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: false })
;(useProviderContext as Mock).mockReturnValue({
plan: {
type: Plan.sandbox,
usage: buildUsage(),
total: buildUsage(),
},
enableEducationPlan: true,
isEducationAccount: true,
})
render(<Pricing onCancel={vi.fn()} />)
expect(screen.getByRole('switch')).not.toBeChecked()
})
})
describe('Props', () => {

View File

@ -39,9 +39,11 @@ const pricingScrollAreaClassNames = {
const Pricing: FC<PricingProps> = ({
onCancel,
}) => {
const { plan } = useProviderContext()
const { plan, enableEducationPlan, isEducationAccount } = useProviderContext()
const { isCurrentWorkspaceManager } = useAppContext()
const [planRange, setPlanRange] = React.useState<PlanRange>(PlanRange.monthly)
const shouldDefaultToYearly = isCurrentWorkspaceManager && enableEducationPlan && isEducationAccount
const [selectedPlanRange, setSelectedPlanRange] = React.useState<PlanRange>()
const planRange = selectedPlanRange ?? (shouldDefaultToYearly ? PlanRange.yearly : PlanRange.monthly)
const [currentCategory, setCurrentCategory] = useState<Category>(CategoryEnum.CLOUD)
const canPay = isCurrentWorkspaceManager
@ -73,7 +75,7 @@ const Pricing: FC<PricingProps> = ({
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
onChangePlanRange={setSelectedPlanRange}
/>
<Plans
plan={plan}

View File

@ -3,6 +3,7 @@ import { toast, ToastHost } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
@ -15,6 +16,10 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: vi.fn(),
}))
@ -38,6 +43,7 @@ vi.mock('../../../assets', () => ({
}))
const mockUseAppContext = useAppContext as Mock
const mockUseProviderContext = useProviderContext as Mock
const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock
const mockBillingInvoices = consoleClient.billing.invoices as Mock
const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock
@ -72,6 +78,10 @@ beforeEach(() => {
vi.clearAllMocks()
toast.dismiss()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
mockUseProviderContext.mockReturnValue({
enableEducationPlan: false,
isEducationAccount: false,
})
mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open()))
mockBillingInvoices.mockResolvedValue({ url: 'https://billing.example' })
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' })
@ -260,6 +270,127 @@ describe('CloudPlanItem', () => {
})
})
it('should use education discount checkout for yearly professional plan when education account is active', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'education.useEducationDiscount' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should show default CTA and hide warning when current user is not workspace manager', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay={false}
/>,
)
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'education.useEducationDiscount' })).not.toBeInTheDocument()
expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument()
})
it('should hide education unsupported warning when current user is not workspace manager', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay={false}
/>,
)
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))!.toBeInTheDocument()
expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument()
})
it('should show education unsupported warning below the button without changing button text or blocking checkout', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
expect(button)!.not.toBeDisabled()
expect(screen.getByText('education.planNotSupportEducationDiscount'))!.toBeInTheDocument()
fireEvent.click(button)
expect(screen.getByText('education.educationPricingConfirm.title'))!.toBeInTheDocument()
expect(screen.getByText(/^education\.educationPricingConfirm\.description/))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.close' }))!.not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))!.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should close the unsupported plan confirm without checkout when canceled', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))
await waitFor(() => {
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
})
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
expect(assignedHref).toBe('')
})
// Covers L62-63: loading guard prevents double click
it('should ignore second click while loading', async () => {
// Make the first fetch hang until we resolve it

View File

@ -1,6 +1,5 @@
import type { BasicPlan } from '../../../type'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightLine } from '@remixicon/react'
import * as React from 'react'
import { Plan } from '../../../type'
@ -24,6 +23,7 @@ type ButtonProps = {
isPlanDisabled: boolean
btnText: string
handleGetPayUrl: () => void
warningText?: string
}
const Button = ({
@ -31,22 +31,30 @@ const Button = ({
isPlanDisabled,
btnText,
handleGetPayUrl,
warningText,
}: ButtonProps) => {
return (
<button
type="button"
disabled={isPlanDisabled}
className={cn(
'flex items-center gap-x-2 py-3 pr-4 pl-5 system-xl-semibold',
BUTTON_CLASSNAME[plan].btnClassname,
isPlanDisabled && BUTTON_CLASSNAME[plan].btnDisabledClassname,
isPlanDisabled && 'cursor-not-allowed',
<div className="relative">
<button
type="button"
disabled={isPlanDisabled}
className={cn(
'flex w-full items-center gap-x-2 py-3 pr-4 pl-5 system-xl-semibold',
BUTTON_CLASSNAME[plan].btnClassname,
isPlanDisabled && BUTTON_CLASSNAME[plan].btnDisabledClassname,
isPlanDisabled && 'cursor-not-allowed',
)}
onClick={handleGetPayUrl}
>
<span className="grow text-start">{btnText}</span>
{!isPlanDisabled && <span className="i-ri-arrow-right-line size-5 shrink-0" />}
</button>
{warningText && (
<div className="absolute top-full right-0 left-0 mt-1.5 text-left system-2xs-medium text-text-tertiary">
{warningText}
</div>
)}
onClick={handleGetPayUrl}
>
<span className="grow text-start">{btnText}</span>
{!isPlanDisabled && <RiArrowRightLine className="size-5 shrink-0" />}
</button>
</div>
)
}

View File

@ -1,15 +1,26 @@
'use client'
import type { FC } from 'react'
import type { BasicPlan } from '../../../type'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
import { ALL_PLANS } from '../../../config'
import { useEducationDiscount } from '../../../hooks/use-education-discount'
import { Plan } from '../../../type'
import { Professional, Sandbox, Team } from '../../assets'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
@ -22,6 +33,10 @@ const ICON_MAP = {
[Plan.team]: <Team />,
}
type ConfirmType = {
type: 'info' | 'warning'
}
type CloudPlanItemProps = {
currentPlan: BasicPlan
plan: BasicPlan
@ -33,6 +48,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
plan,
currentPlan,
planRange,
canPay,
}) => {
const { t } = useTranslation()
const [loading, setLoading] = React.useState(false)
@ -45,9 +61,23 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
const isCurrentPaidPlan = isCurrent && !isFreePlan
const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level
const { isCurrentWorkspaceManager } = useAppContext()
const { enableEducationPlan, isEducationAccount } = useProviderContext()
const isEducationDiscountMode = enableEducationPlan && isEducationAccount
const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear
const selectedPlanName = t(`${i18nPrefix}.name`, { ns: 'billing' })
const selectedBillingPeriod = t(`educationPricingConfirm.billingPeriod.${isYear ? 'yearly' : 'monthly'}`, { ns: 'education' })
const educationDiscountWarningText = canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan
? t('planNotSupportEducationDiscount', { ns: 'education' })
: undefined
const openAsyncWindow = useAsyncWindowOpen()
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const [showEducationPricingConfirm, setShowEducationPricingConfirm] = React.useState(false)
const educationPricingConfirmInfo: ConfirmType = { type: 'warning' }
const btnText = useMemo(() => {
if (canPay && isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrent)
return t('useEducationDiscount', { ns: 'education' })
if (isCurrent)
return t('plansCommon.currentPlan', { ns: 'billing' })
@ -56,15 +86,20 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
[Plan.professional]: t('plansCommon.startBuilding', { ns: 'billing' }),
[Plan.team]: t('plansCommon.getStarted', { ns: 'billing' }),
})[plan]
}, [isCurrent, plan, t])
}, [canPay, isCurrent, isEducationDiscountMode, isEducationDiscountSupportedPlan, plan, t])
const handleGetPayUrl = async () => {
if (loading)
const handlePayCurrentPlan = async () => {
if (loading || isEducationDiscountLoading)
return
if (isPlanDisabled)
return
if (isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrentPaidPlan) {
await handleEducationDiscount()
return
}
if (!isCurrentWorkspaceManager) {
toast.error(t('buyPermissionDeniedTip', { ns: 'billing' }))
return
@ -96,6 +131,18 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
setLoading(false)
}
}
const handleGetPayUrl = async () => {
if (educationDiscountWarningText && !isPlanDisabled) {
setShowEducationPricingConfirm(true)
return
}
await handlePayCurrentPlan()
}
const handleContinueCurrentPlan = async () => {
setShowEducationPricingConfirm(false)
await handlePayCurrentPlan()
}
return (
<div className="flex min-w-0 flex-1 flex-col pb-3">
<div className="flex flex-col px-5 py-4">
@ -146,9 +193,46 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
isPlanDisabled={isPlanDisabled}
btnText={btnText}
handleGetPayUrl={handleGetPayUrl}
warningText={educationDiscountWarningText}
/>
</div>
<List plan={plan} />
<AlertDialog
open={showEducationPricingConfirm}
onOpenChange={setShowEducationPricingConfirm}
>
{showEducationPricingConfirm && <div className="fixed inset-0 z-1002 bg-background-overlay"></div>}
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('educationPricingConfirm.title', { ns: 'education' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('educationPricingConfirm.description', {
ns: 'education',
planName: selectedPlanName,
billingPeriod: selectedBillingPeriod,
})}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton
onClick={() => setShowEducationPricingConfirm(false)}
disabled={loading}
>
{t('educationPricingConfirm.cancel', { ns: 'education' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone={educationPricingConfirmInfo.type !== 'info' ? 'destructive' : 'default'}
onClick={handleContinueCurrentPlan}
disabled={loading}
loading={loading}
>
{t('educationPricingConfirm.continue', { ns: 'education' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

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

@ -1,4 +1,5 @@
import type { Plan } from '@/app/components/billing/type'
import type { IWorkspace } from '@/models/common'
import {
Select,
SelectContent,
@ -9,12 +10,58 @@ import {
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import PlanBadge from '@/app/components/header/plan-badge'
import { useWorkspacesContext } from '@/context/workspace-context'
import { switchWorkspace } from '@/service/common'
import { basePath } from '@/utils/var'
type WorkplaceSelectorContentProps = {
workspaces: IWorkspace[]
popupClassName?: string
}
type WorkplaceSelectorItemProps = {
workspace: IWorkspace
}
const WorkplaceSelectorItem = memo(({
workspace,
}: WorkplaceSelectorItemProps) => (
<SelectItem value={workspace.id} className="gap-2 py-1 pr-2 pl-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspace.name[0]?.toLocaleUpperCase()}
</span>
</div>
<SelectItemText className="system-md-regular">{workspace.name}</SelectItemText>
<PlanBadge plan={workspace.plan as Plan} />
</SelectItem>
))
WorkplaceSelectorItem.displayName = 'WorkplaceSelectorItem'
export const WorkplaceSelectorContent = memo(({
workspaces,
popupClassName = 'w-[280px] transition-none data-starting-style:scale-100 data-starting-style:opacity-100 data-ending-style:scale-100 data-ending-style:opacity-100',
}: WorkplaceSelectorContentProps) => {
const { t } = useTranslation()
return (
<SelectContent popupClassName={popupClassName}>
<SelectGroup>
<SelectLabel>
{t('userProfile.workspace', { ns: 'common' })}
</SelectLabel>
{workspaces.map(workspace => (
<WorkplaceSelectorItem key={workspace.id} workspace={workspace} />
))}
</SelectGroup>
</SelectContent>
)
})
WorkplaceSelectorContent.displayName = 'WorkplaceSelectorContent'
const WorkplaceSelector = () => {
const { t } = useTranslation()
const { workspaces } = useWorkspacesContext()
@ -55,24 +102,7 @@ const WorkplaceSelector = () => {
</div>
</div>
</SelectTrigger>
<SelectContent popupClassName="w-[280px]">
<SelectGroup>
<SelectLabel>
{t('userProfile.workspace', { ns: 'common' })}
</SelectLabel>
{workspaces.map(workspace => (
<SelectItem key={workspace.id} value={workspace.id} className="gap-2 py-1 pr-2 pl-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspace.name[0]?.toLocaleUpperCase()}
</span>
</div>
<SelectItemText className="system-md-regular">{workspace.name}</SelectItemText>
<PlanBadge plan={workspace.plan as Plan} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
<WorkplaceSelectorContent workspaces={workspaces} />
</Select>
)
}

View File

@ -0,0 +1,95 @@
'use client'
import type { ReactNode } from 'react'
import type { Plan as PlanType } from '@/app/components/billing/type'
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
import {
Select,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { WorkplaceSelectorContent } from '@/app/components/header/account-dropdown/workplace-selector'
import PlanBadge from '@/app/components/header/plan-badge'
type AppliedEducationContentProps = {
workspaces: IWorkspace[]
currentWorkspace: ICurrentWorkspace
plan: PlanType
action: ReactNode
onSwitchWorkspace: (tenantId: string) => void
}
const AppliedEducationContent = ({
workspaces,
currentWorkspace,
plan,
action,
onSwitchWorkspace,
}: AppliedEducationContentProps) => {
const { t } = useTranslation()
const currentWorkspaceInList = workspaces.find(workspace => workspace.current)
const workspacePlan = Object.values(Plan).includes(currentWorkspaceInList?.plan as Plan)
? currentWorkspaceInList?.plan as Plan
: Object.values(Plan).includes(plan as Plan)
? plan as Plan
: Plan.sandbox
const workspaceName = currentWorkspaceInList?.name || currentWorkspace?.name
const workspaceId = currentWorkspaceInList?.id || currentWorkspace?.id
return (
<div className="flex w-full flex-col gap-4">
<div className="rounded-lg border border-effects-highlight bg-background-default-subtle px-3">
<div className="flex items-center gap-2">
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-state-success-solid text-text-primary-on-surface">
<span className="i-ri-check-line h-3.5 w-3.5" />
</div>
<div>
<div className="text-text-secondary">
{t('applied.step1.description', { ns: 'education' })}
</div>
</div>
</div>
</div>
<div className="rounded-lg px-3">
<div className="mb-3.5 flex items-center gap-2">
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-components-icon-bg-blue-solid system-xs-semibold text-text-primary-on-surface">
2
</div>
<div>
<div className="system-xl-medium text-text-secondary">
{t('applied.step2.description', { ns: 'education' })}
</div>
</div>
</div>
<div className="ml-7">
<Select
value={workspaceId ?? ''}
onValueChange={(value) => {
if (value)
onSwitchWorkspace(value)
}}
>
<SelectTrigger className="h-12! w-fit max-w-full min-w-[280px] cursor-pointer justify-between rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3! py-1.5! hover:bg-state-base-hover">
<span className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-components-icon-bg-blue-solid text-[14px]">
<span className="bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspaceName?.[0]?.toLocaleUpperCase()}
</span>
</span>
<span className="min-w-0 truncate system-md-semibold text-text-primary">{workspaceName}</span>
<PlanBadge plan={workspacePlan} />
</span>
</SelectTrigger>
<WorkplaceSelectorContent workspaces={workspaces} />
</Select>
<div className="mt-3 pr-5">
{action}
</div>
</div>
</div>
</div>
)
}
export default AppliedEducationContent

View File

@ -1,57 +1,79 @@
'use client'
import type { ReactNode } from 'react'
import type { Plan as PlanType } from '@/app/components/billing/type'
import type { ICurrentWorkspace } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { RiExternalLinkLine } from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount'
import { Plan } from '@/app/components/billing/type'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { WorkspaceProvider } from '@/context/workspace-context-provider'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import {
useRouter,
useSearchParams,
} from '@/next/navigation'
import { consoleClient } from '@/service/client'
import { switchWorkspace } from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import {
useEducationAdd,
useInvalidateEducationStatus,
} from '@/service/use-education'
import DifyLogo from '../components/base/logo/dify-logo'
import AppliedEducationContent from './applied-education-content'
import RoleSelector from './role-selector'
import SearchInput from './search-input'
import UserInfo from './user-info'
import Confirm from './verify-state-modal'
const EducationApplyAge = () => {
const AppliedEducationCase = {
eligible: 'eligible',
activeSubscription: 'activeSubscription',
noPaymentPermission: 'noPaymentPermission',
} as const
const EducationApplyAgeContent = () => {
const { t } = useTranslation()
const [schoolName, setSchoolName] = useState('')
const [role, setRole] = useState('Student')
const [ageChecked, setAgeChecked] = useState(false)
const [inSchoolChecked, setInSchoolChecked] = useState(false)
const [hasSubmittedEducation, setHasSubmittedEducation] = useState(false)
const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false)
const {
isPending,
mutateAsync: educationAdd,
} = useEducationAdd({ onSuccess: noop })
const [modalShow, setShowModal] = useState<undefined | { title: string, desc: string, onConfirm?: () => void }>(undefined)
const { onPlanInfoChanged } = useProviderContext()
const { onPlanInfoChanged, isEducationAccount, plan } = useProviderContext()
const { currentWorkspace, isCurrentWorkspaceManager } = useAppContext()
const updateEducationStatus = useInvalidateEducationStatus()
const router = useRouter()
const docLink = useDocLink()
const handleModalConfirm = () => {
setShowModal(undefined)
onPlanInfoChanged()
updateEducationStatus()
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
router.replace('/')
}
const { handleEducationDiscount } = useEducationDiscount()
const router = useRouter()
const openAsyncWindow = useAsyncWindowOpen()
const queryClient = useQueryClient()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const appliedEducationCase = (() => {
if (!isCurrentWorkspaceManager)
return AppliedEducationCase.noPaymentPermission
if (plan.type === Plan.sandbox)
return AppliedEducationCase.eligible
return AppliedEducationCase.activeSubscription
})()
const handleSubmit = () => {
educationAdd({
token: token || '',
@ -59,17 +81,113 @@ const EducationApplyAge = () => {
institution: schoolName,
}).then((res) => {
if (res.message === 'success') {
setShowModal({
title: t('successTitle', { ns: 'education' }),
desc: t('successContent', { ns: 'education' }),
onConfirm: handleModalConfirm,
})
onPlanInfoChanged()
updateEducationStatus()
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
setHasSubmittedEducation(true)
}
else {
toast.error(t('submitError', { ns: 'education' }))
}
})
}
const handleOpenBillingPortal = async () => {
if (isOpeningBillingPortal)
return
setIsOpeningBillingPortal(true)
try {
await openAsyncWindow(async () => {
const res = await consoleClient.billing.invoices()
if (res.url)
return res.url
throw new Error('Failed to open billing page')
}, {
onError: (err) => {
toast.error(err.message || String(err))
},
})
}
finally {
setIsOpeningBillingPortal(false)
}
}
const handleReturnHome = () => {
router.push('/')
}
const renderBackToDifyButton = () => (
<Button variant="ghost-accent" onClick={handleReturnHome}>
<span className="mr-1 i-ri-arrow-left-line h-4 w-4" />
{t('applied.noPaymentPermission.returnHome', { ns: 'education' })}
</Button>
)
const handleSwitchWorkspace = async (tenantId: string) => {
if (tenantId === currentWorkspace?.id)
return
try {
await switchWorkspace({ url: '/workspaces/switch', body: { tenant_id: tenantId } })
await Promise.all([
queryClient.invalidateQueries({ queryKey: commonQueryKeys.currentWorkspace }),
queryClient.invalidateQueries({ queryKey: commonQueryKeys.workspaces }),
])
onPlanInfoChanged()
updateEducationStatus()
}
catch {
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
}
}
const renderAppliedEducationAction = () => {
if (appliedEducationCase === AppliedEducationCase.eligible) {
return (
<Button variant="primary" onClick={handleEducationDiscount}>
{t('useEducationDiscount', { ns: 'education' })}
</Button>
)
}
if (appliedEducationCase === AppliedEducationCase.activeSubscription) {
return (
<div className="flex w-full flex-col items-start gap-3">
<div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5">
<span className="mt-0.5 mr-2 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning-secondary" />
<div className="system-md-regular text-text-warning">
<Trans
i18nKey="applied.activeSubscription.description"
ns="education"
components={{
stripeLink: (
<button
type="button"
className="text-text-accent hover:underline disabled:cursor-not-allowed disabled:text-text-disabled"
onClick={handleOpenBillingPortal}
disabled={isOpeningBillingPortal}
/>
),
}}
/>
</div>
</div>
{renderBackToDifyButton()}
</div>
)
}
return (
<div className="flex w-full flex-col items-start gap-3">
<div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5">
<span className="mt-0.5 mr-2 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning-secondary" />
<div className="system-md-regular text-text-warning">
{t('applied.noPaymentPermission.description', { ns: 'education' })}
</div>
</div>
{renderBackToDifyButton()}
</div>
)
}
return (
<div className="fixed inset-0 z-31 overflow-y-auto bg-background-body p-6">
@ -89,94 +207,141 @@ const EducationApplyAge = () => {
<div className="mb-2 title-5xl-bold shadow-xs">{t('toVerified', { ns: 'education' })}</div>
<div className="system-md-medium shadow-xs">
{t('toVerifiedTip.front', { ns: 'education' })}
&nbsp;
&nbsp;
<span className="system-md-semibold underline">{t('toVerifiedTip.coupon', { ns: 'education' })}</span>
&nbsp;
&nbsp;
{t('toVerifiedTip.end', { ns: 'education' })}
</div>
</div>
<div className="mb-7">
<UserInfo />
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.schoolName.title', { ns: 'education' })}
</div>
<SearchInput
value={schoolName}
onChange={setSchoolName}
/>
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.schoolRole.title', { ns: 'education' })}
</div>
<RoleSelector
value={role}
onChange={setRole}
/>
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.terms.title', { ns: 'education' })}
</div>
<div className="mb-1 system-md-regular text-text-tertiary">
{t('form.terms.desc.front', { ns: 'education' })}
&nbsp;
<a href="https://dify.ai/terms" target="_blank" rel="noopener noreferrer" className="text-text-secondary hover:underline">{t('form.terms.desc.termsOfService', { ns: 'education' })}</a>
&nbsp;
{t('form.terms.desc.and', { ns: 'education' })}
&nbsp;
<a href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer" className="text-text-secondary hover:underline">{t('form.terms.desc.privacyPolicy', { ns: 'education' })}</a>
{t('form.terms.desc.end', { ns: 'education' })}
</div>
<div className="py-2 system-md-regular text-text-primary">
<div className="mb-2 flex">
<Checkbox
className="mr-2 shrink-0"
checked={ageChecked}
onCheck={() => setAgeChecked(!ageChecked)}
/>
{t('form.terms.option.age', { ns: 'education' })}
</div>
<div className="flex">
<Checkbox
className="mr-2 shrink-0"
checked={inSchoolChecked}
onCheck={() => setInSchoolChecked(!inSchoolChecked)}
/>
{t('form.terms.option.inSchool', { ns: 'education' })}
</div>
</div>
</div>
<Button
variant="primary"
disabled={!ageChecked || !inSchoolChecked || !schoolName || !role || isPending}
onClick={handleSubmit}
>
{t('submit', { ns: 'education' })}
</Button>
<div className="mt-5 mb-4 h-px bg-linear-to-r from-[rgba(16,24,40,0.08)]"></div>
<a
className="flex items-center system-xs-regular text-text-accent"
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
target="_blank"
rel="noopener noreferrer"
>
{t('learn', { ns: 'education' })}
<RiExternalLinkLine className="ml-1 h-3 w-3" />
</a>
{isEducationAccount || hasSubmittedEducation
? (
<div className="flex">
<AppliedEducationWorkspaceBlock
currentWorkspace={currentWorkspace}
plan={plan.type}
action={renderAppliedEducationAction()}
onSwitchWorkspace={(value) => {
void handleSwitchWorkspace(value)
}}
/>
</div>
)
: (
<>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.schoolName.title', { ns: 'education' })}
</div>
<SearchInput
value={schoolName}
onChange={setSchoolName}
/>
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.schoolRole.title', { ns: 'education' })}
</div>
<RoleSelector
value={role}
onChange={setRole}
/>
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.terms.title', { ns: 'education' })}
</div>
<div className="mb-1 system-md-regular text-text-tertiary">
{t('form.terms.desc.front', { ns: 'education' })}
&nbsp;
<a href="https://dify.ai/terms" target="_blank" className="text-text-secondary hover:underline">{t('form.terms.desc.termsOfService', { ns: 'education' })}</a>
&nbsp;
{t('form.terms.desc.and', { ns: 'education' })}
&nbsp;
<a href="https://dify.ai/privacy" target="_blank" className="text-text-secondary hover:underline">{t('form.terms.desc.privacyPolicy', { ns: 'education' })}</a>
{t('form.terms.desc.end', { ns: 'education' })}
</div>
<div className="py-2 system-md-regular text-text-primary">
<div className="mb-2 flex">
<Checkbox
className="mr-2 shrink-0"
checked={ageChecked}
onCheck={() => setAgeChecked(!ageChecked)}
/>
{t('form.terms.option.age', { ns: 'education' })}
</div>
<div className="flex">
<Checkbox
className="mr-2 shrink-0"
checked={inSchoolChecked}
onCheck={() => setInSchoolChecked(!inSchoolChecked)}
/>
{t('form.terms.option.inSchool', { ns: 'education' })}
</div>
</div>
</div>
<Button
variant="primary"
disabled={!ageChecked || !inSchoolChecked || !schoolName || !role || isPending}
onClick={handleSubmit}
>
{t('submit', { ns: 'education' })}
</Button>
<div className="mt-5 mb-4 h-px bg-linear-to-r from-[rgba(16,24,40,0.08)]"></div>
<a
className="flex items-center system-xs-regular text-text-accent"
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
target="_blank"
>
{t('learn', { ns: 'education' })}
<span className="ml-1 i-ri-external-link-line h-3 w-3" />
</a>
</>
)}
</div>
</div>
<Confirm
isShow={!!modalShow}
title={modalShow?.title || ''}
content={modalShow?.desc}
onConfirm={modalShow?.onConfirm || noop}
onCancel={modalShow?.onConfirm || noop}
/>
</div>
)
}
type AppliedEducationWorkspaceBlockProps = {
currentWorkspace: ICurrentWorkspace
plan: PlanType
action: ReactNode
onSwitchWorkspace: (tenantId: string) => void
}
function AppliedEducationWorkspaceContent({
currentWorkspace,
plan,
action,
onSwitchWorkspace,
}: AppliedEducationWorkspaceBlockProps) {
const { workspaces } = useWorkspacesContext()
return (
<AppliedEducationContent
workspaces={workspaces}
currentWorkspace={currentWorkspace}
plan={plan}
action={action}
onSwitchWorkspace={onSwitchWorkspace}
/>
)
}
function AppliedEducationWorkspaceBlock(props: AppliedEducationWorkspaceBlockProps) {
return (
<WorkspaceProvider>
<AppliedEducationWorkspaceContent {...props} />
</WorkspaceProvider>
)
}
const EducationApplyAge = () => <EducationApplyAgeContent />
export default EducationApplyAge
type AppliedEducationCase = typeof AppliedEducationCase[keyof typeof AppliedEducationCase]

View File

@ -3,7 +3,7 @@ import {
RiExternalLinkLine,
} from '@remixicon/react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
@ -18,6 +18,7 @@ type IConfirm = {
maskClosable?: boolean
email?: string
showLink?: boolean
confirmText?: string
}
function Confirm({
@ -29,6 +30,7 @@ function Confirm({
maskClosable = true,
showLink,
email,
confirmText,
}: IConfirm) {
const { t } = useTranslation()
const docLink = useDocLink()
@ -52,26 +54,24 @@ function Confirm({
}
}, [onCancel])
const handleClickOutside = (event: MouseEvent) => {
const handleClickOutside = useCallback((event: MouseEvent) => {
if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node))
onCancel()
}
}, [maskClosable, onCancel])
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [maskClosable])
}, [handleClickOutside])
useEffect(() => {
if (isShow) {
setIsVisible(true)
}
else {
const timer = setTimeout(() => setIsVisible(false), 200)
return () => clearTimeout(timer)
}
const timer = setTimeout(() => {
setIsVisible(isShow)
}, isShow ? 0 : 200)
return () => clearTimeout(timer)
}, [isShow])
if (!isVisible)
@ -106,7 +106,7 @@ function Confirm({
</>
)}
</div>
<Button variant="primary" className="w-20!" onClick={onConfirm}>{t('operation.ok', { ns: 'common' })}</Button>
<Button variant="primary" className={confirmText ? 'min-w-20!' : 'w-20!'} onClick={onConfirm}>{confirmText || t('operation.ok', { ns: 'common' })}</Button>
</div>
</div>
</div>

View File

@ -38,6 +38,7 @@ export const ProviderContextProvider = ({
const [plan, setPlan] = useState(defaultPlan)
const [isFetchedPlan, setIsFetchedPlan] = useState(false)
const [isFetchedPlanInfo, setIsFetchedPlanInfo] = useState(false)
const [enableBilling, setEnableBilling] = useState(true)
const [enableReplaceWebAppLogo, setEnableReplaceWebAppLogo] = useState(false)
const [modelLoadBalancingEnabled, setModelLoadBalancingEnabled] = useState(false)
@ -103,6 +104,9 @@ export const ProviderContextProvider = ({
setIsEducationWorkspace(false)
setEnableReplaceWebAppLogo(false)
}
finally {
setIsFetchedPlanInfo(true)
}
}
useEffect(() => {
fetchPlan()
@ -150,6 +154,7 @@ export const ProviderContextProvider = ({
supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [],
plan,
isFetchedPlan,
isFetchedPlanInfo,
enableBilling,
onPlanInfoChanged: fetchPlan,
enableReplaceWebAppLogo,

View File

@ -20,6 +20,7 @@ export type ProviderContextState = {
reset: UsageResetInfo
}
isFetchedPlan: boolean
isFetchedPlanInfo: boolean
enableBilling: boolean
onPlanInfoChanged: () => void
enableReplaceWebAppLogo: boolean
@ -53,6 +54,7 @@ export const baseProviderContextValue: ProviderContextState = {
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,

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,5 +1,25 @@
{
"applied.activeSubscription.description": "لديك اشتراك نشط. يمكنك استخدام الخصم التعليمي بعد انتهاء صلاحية اشتراكك. تأكيد اشتراكك في <stripeLink>Stripe</stripeLink>.",
"applied.description": "تهانينا! لقد قدمت بنجاح طلباً للحصول على الخصم التعليمي.",
"applied.noPaymentPermission.description": "ليس لديك صلاحية الدفع في هذه مساحة العمل. يرجى التبديل إلى مساحة عمل حيث يمكنك إدارة الفوترة لاستخدام الخصم التعليمي.",
"applied.noPaymentPermission.returnHome": "العودة إلى Dify",
"applied.step1.description": "لقد قدمت بنجاح طلباً للحصول على الخصم التعليمي.",
"applied.step1.title": "الخطوة 1",
"applied.step2.description": "اختر مساحة العمل التي تريد استخدامها مع الخصم التعليمي.",
"applied.step2.title": "الخطوة 2",
"applied.tabs.activeSubscription": "في الاشتراك",
"applied.tabs.eligible": "يمكن الشراء",
"applied.tabs.noPaymentPermission": "لا توجد صلاحية دفع",
"applied.title": "تم تطبيق الخصم التعليمي",
"applied.workspace.plan": "خطة مدفوعة",
"applied.workspace.title": "مساحة العمل الحالية",
"currentSigned": "تم تسجيل الدخول حاليًا باسم",
"educationPricingConfirm.billingPeriod.monthly": "شهري",
"educationPricingConfirm.billingPeriod.yearly": "سنوي",
"educationPricingConfirm.cancel": "إلغاء",
"educationPricingConfirm.continue": "المتابعة بدون خصم",
"educationPricingConfirm.description": "خطتك {{planName}} {{billingPeriod}} لا تدعم الخصم التعليمي. فقط خطة Professional السنوية مؤهلة.",
"educationPricingConfirm.title": "الخصم التعليمي غير متاح",
"emailLabel": "بريدك الإلكتروني الحالي",
"form.schoolName.placeholder": "أدخل الاسم الرسمي الكامل لمدرستك",
"form.schoolName.title": "اسم مدرستك",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "تحقق مرة أخرى الآن للحصول على كوبون جديد للعام الدراسي القادم. سنضيفه إلى حسابك ويمكنك استخدامه للترقية التالية.",
"notice.stillInEducation.isAboutToExpire": "تحقق مرة أخرى الآن للحصول على كوبون جديد للعام الدراسي القادم. سيتم حفظه في حسابك وجاهز للاستخدام في تجديدك التالي.",
"notice.stillInEducation.title": "هل ما زلت في التعليم؟",
"planNotSupportEducationDiscount": "غير مؤهل لأسعار التعليم",
"rejectContent": "لسوء الحظ، أنت غير مؤهل للحصول على حالة التحقق التعليمي وبالتالي لا يمكنك الحصول على كوبون حصري 100٪ لخطة Dify Professional إذا كنت تستخدم عنوان البريد الإلكتروني هذا.",
"rejectTitle": "تم رفض التحقق التعليمي الخاص بك في Dify",
"submit": "إرسال",
@ -40,5 +61,6 @@
"toVerified": "احصل على التحقق التعليمي",
"toVerifiedTip.coupon": "كوبون حصري 100٪",
"toVerifiedTip.end": "لخطة Dify الاحترافية.",
"toVerifiedTip.front": "أنت الآن مؤهل للحصول على حالة التحقق التعليمي. يرجى إدخال معلومات التعليم الخاصة بك أدناه لإكمال العملية والحصول على"
"toVerifiedTip.front": "أنت الآن مؤهل للحصول على حالة التحقق التعليمي. يرجى إدخال معلومات التعليم الخاصة بك أدناه لإكمال العملية والحصول على",
"useEducationDiscount": "استخدام الخصم التعليمي"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Sie haben ein aktives Abonnement. Sie können den Bildungsrabatt verwenden, nachdem Ihr Abonnement abläuft. Bestätigen Sie Ihr Abonnement in <stripeLink>Stripe</stripeLink>.",
"applied.description": "Herzlichen Glückwunsch! Sie haben erfolgreich den Bildungsrabatt beantragt.",
"applied.noPaymentPermission.description": "Sie haben keine Zahlungsberechtigung in diesem Arbeitsbereich. Bitte wechseln Sie zu einem Arbeitsbereich, in dem Sie die Abrechnung verwalten können, um den Bildungsrabatt zu nutzen.",
"applied.noPaymentPermission.returnHome": "Zurück zu Dify",
"applied.step1.description": "Sie haben erfolgreich den Bildungsrabatt beantragt.",
"applied.step1.title": "Schritt 1",
"applied.step2.description": "Wählen Sie den Arbeitsbereich aus, den Sie mit dem Bildungsrabatt verwenden möchten.",
"applied.step2.title": "Schritt 2",
"applied.tabs.activeSubscription": "Im Abonnement",
"applied.tabs.eligible": "Kann kaufen",
"applied.tabs.noPaymentPermission": "Keine Zahlungsberechtigung",
"applied.title": "Bildungsrabatt angewendet",
"applied.workspace.plan": "Bezahlter Plan",
"applied.workspace.title": "Aktueller Arbeitsbereich",
"currentSigned": "DERZEIT ANGEMELDET ALS",
"educationPricingConfirm.billingPeriod.monthly": "monatlich",
"educationPricingConfirm.billingPeriod.yearly": "jährlich",
"educationPricingConfirm.cancel": "Abbrechen",
"educationPricingConfirm.continue": "Ohne Rabatt fortfahren",
"educationPricingConfirm.description": "Ihr {{planName}} {{billingPeriod}} Plan unterstützt den Bildungsrabatt nicht. Nur der Professional-Jahresplan ist berechtigt.",
"educationPricingConfirm.title": "Bildungsrabatt nicht verfügbar",
"emailLabel": "Ihre aktuelle E-Mail",
"form.schoolName.placeholder": "Geben Sie den offiziellen, unabgekürzten Namen Ihrer Schule ein.",
"form.schoolName.title": "Ihr Schulname",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Überprüfen Sie jetzt erneut, um einen neuen Gutschein für das kommende akademische Jahr zu erhalten. Wir fügen ihn Ihrem Konto hinzu und Sie können ihn für das nächste Upgrade verwenden.",
"notice.stillInEducation.isAboutToExpire": "Überprüfen Sie jetzt erneut, um einen neuen Gutschein für das kommende Studienjahr zu erhalten. Er wird in Ihrem Konto gespeichert und ist bereit zur Nutzung bei Ihrer nächsten Verlängerung.",
"notice.stillInEducation.title": "Immer noch in der Ausbildung?",
"planNotSupportEducationDiscount": "Nicht für Bildungspreise berechtigt",
"rejectContent": "Leider sind Sie nicht für den Status \"Education Verified\" berechtigt und können daher den exklusiven 100%-Gutschein für den Dify Professional Plan nicht erhalten, wenn Sie diese E-Mail-Adresse verwenden.",
"rejectTitle": "Ihre Dify-Ausbildungsüberprüfung wurde abgelehnt.",
"submit": "Einreichen",
@ -40,5 +61,6 @@
"toVerified": "Bildung überprüfen lassen",
"toVerifiedTip.coupon": "exklusiver 100% Gutschein",
"toVerifiedTip.end": "für den Dify Professional Plan.",
"toVerifiedTip.front": "Sie sind jetzt berechtigt, den Status „Bildung verifiziert“ zu erhalten. Bitte geben Sie unten Ihre Bildungsinformationen ein, um den Prozess abzuschließen und eine Zu erhalten."
"toVerifiedTip.front": "Sie sind jetzt berechtigt, den Status Bildung verifiziert zu erhalten. Bitte geben Sie unten Ihre Bildungsinformationen ein, um den Prozess abzuschließen und eine zu erhalten.",
"useEducationDiscount": "Bildungsrabatt verwenden"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "You have an active subscription. You can use the education discount after your subscription expires. Confirm your subscription in <stripeLink>Stripe</stripeLink>.",
"applied.description": "Congratulations! You've successfully applied for the education discount.",
"applied.noPaymentPermission.description": "You don't have payment permission in this workspace. Please switch to a workspace where you can manage billing to use the education discount.",
"applied.noPaymentPermission.returnHome": "Back to Dify",
"applied.step1.description": "You've successfully applied for the education discount.",
"applied.step1.title": "Step 1",
"applied.step2.description": "Select the workspace you want to use the education discount with.",
"applied.step2.title": "Step 2",
"applied.tabs.activeSubscription": "In subscription",
"applied.tabs.eligible": "Can buy",
"applied.tabs.noPaymentPermission": "No payment permission",
"applied.title": "Education discount applied",
"applied.workspace.plan": "Paid plan",
"applied.workspace.title": "Current Workspace",
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "monthly",
"educationPricingConfirm.billingPeriod.yearly": "annual",
"educationPricingConfirm.cancel": "Cancel",
"educationPricingConfirm.continue": "Continue without discount",
"educationPricingConfirm.description": "Your {{planName}} {{billingPeriod}} plan doesn't support the education discount. Only the Professional annual plan is eligible.",
"educationPricingConfirm.title": "Education discount not available",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Re-verify now to get a new coupon for the upcoming academic year. We'll add it to your account and you can use it for the next upgrade.",
"notice.stillInEducation.isAboutToExpire": "Re-verify now to get a new coupon for the upcoming academic year. It'll be saved to your account and ready to use at your next renewal.",
"notice.stillInEducation.title": "Still in education?",
"planNotSupportEducationDiscount": "Not eligible for education pricing",
"rejectContent": "Unfortunately, you are not eligible for Education Verified status and therefore cannot receive the exclusive 100% coupon for the Dify Professional Plan if you use this email address.",
"rejectTitle": "Your Dify Educational Verification Has Been Rejected",
"submit": "Submit",
@ -40,5 +61,6 @@
"toVerified": "Get Education Verified",
"toVerifiedTip.coupon": "exclusive 100% coupon",
"toVerifiedTip.end": "for the Dify Professional Plan.",
"toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an"
"toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an",
"useEducationDiscount": "Use education discount"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Tienes una suscripción activa. Puedes usar el descuento educativo después de que expire tu suscripción. Confirma tu suscripción en <stripeLink>Stripe</stripeLink>.",
"applied.description": "¡Felicitaciones! Has solicitado exitosamente el descuento educativo.",
"applied.noPaymentPermission.description": "No tienes permiso de pago en este workspace. Por favor, cambia a un workspace donde puedas gestionar la facturación para usar el descuento educativo.",
"applied.noPaymentPermission.returnHome": "Volver a Dify",
"applied.step1.description": "Has solicitado exitosamente el descuento educativo.",
"applied.step1.title": "Paso 1",
"applied.step2.description": "Selecciona el workspace que deseas usar con el descuento educativo.",
"applied.step2.title": "Paso 2",
"applied.tabs.activeSubscription": "En suscripción",
"applied.tabs.eligible": "Puede comprar",
"applied.tabs.noPaymentPermission": "Sin permiso de pago",
"applied.title": "Descuento educativo aplicado",
"applied.workspace.plan": "Plan de pago",
"applied.workspace.title": "Workspace actual",
"currentSigned": "ACTUALMENTE CONECTADO COMO",
"educationPricingConfirm.billingPeriod.monthly": "mensual",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Cancelar",
"educationPricingConfirm.continue": "Continuar sin descuento",
"educationPricingConfirm.description": "Tu plan {{planName}} {{billingPeriod}} no admite el descuento educativo. Solo el plan Professional anual es elegible.",
"educationPricingConfirm.title": "Descuento educativo no disponible",
"emailLabel": "Tu correo electrónico actual",
"form.schoolName.placeholder": "Ingrese el nombre oficial y completo de su escuela",
"form.schoolName.title": "El nombre de tu escuela",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Verifica de nuevo ahora para obtener un nuevo cupón para el próximo año académico. Lo añadiremos a tu cuenta y podrás usarlo para la próxima actualización.",
"notice.stillInEducation.isAboutToExpire": "Verifica de nuevo ahora para obtener un nuevo cupón para el próximo año académico. Se guardará en tu cuenta y estará listo para usar en tu próxima renovación.",
"notice.stillInEducation.title": "¿Aún en educación?",
"planNotSupportEducationDiscount": "No elegible para precios educativos",
"rejectContent": "Desafortunadamente, no eres elegible para el estado de Educación Verificada y, por lo tanto, no puedes recibir el exclusivo cupón del 100% para el Plan Profesional de Dify si utilizas esta dirección de correo electrónico.",
"rejectTitle": "Su verificación educativa de Dify ha sido rechazada.",
"submit": "Enviar",
@ -40,5 +61,6 @@
"toVerified": "Verifica la educación",
"toVerifiedTip.coupon": "cupón exclusivo del 100%",
"toVerifiedTip.end": "para el Plan Profesional de Dify.",
"toVerifiedTip.front": "Ahora eres elegible para el estado de Educación Verificada. Por favor, introduce tu información educativa a continuación para completar el proceso y recibir un"
"toVerifiedTip.front": "Ahora eres elegible para el estado de Educación Verificada. Por favor, introduce tu información educativa a continuación para completar el proceso y recibir un",
"useEducationDiscount": "Usar descuento educativo"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "شما یک اشتراک فعال دارید. پس از انقضای اشتراک می‌توانید از تخفیف آموزشی استفاده کنید. اشتراک خود را در <stripeLink>Stripe</stripeLink> تأیید کنید.",
"applied.description": "تبریک می‌گوییم! درخواست تخفیف آموزشی شما با موفقیت ثبت شد.",
"applied.noPaymentPermission.description": "شما در این workspace مجوز پرداخت ندارید. لطفاً به workspaceای بروید که بتوانید صورتحساب را مدیریت کنید تا از تخفیف آموزشی استفاده کنید.",
"applied.noPaymentPermission.returnHome": "بازگشت به Dify",
"applied.step1.description": "درخواست تخفیف آموزشی شما با موفقیت ثبت شد.",
"applied.step1.title": "مرحله ۱",
"applied.step2.description": "workspaceای را که می‌خواهید با تخفیف آموزشی استفاده کنید انتخاب کنید.",
"applied.step2.title": "مرحله ۲",
"applied.tabs.activeSubscription": "در اشتراک",
"applied.tabs.eligible": "می‌تواند خرید کند",
"applied.tabs.noPaymentPermission": "بدون مجوز پرداخت",
"applied.title": "تخفیف آموزشی اعمال شد",
"applied.workspace.plan": "طرح پولی",
"applied.workspace.title": "Workspace فعلی",
"currentSigned": "اکنون به عنوان",
"educationPricingConfirm.billingPeriod.monthly": "ماهانه",
"educationPricingConfirm.billingPeriod.yearly": "سالانه",
"educationPricingConfirm.cancel": "لغو",
"educationPricingConfirm.continue": "ادامه بدون تخفیف",
"educationPricingConfirm.description": "طرح {{planName}} {{billingPeriod}} شما از تخفیف آموزشی پشتیبانی نمی‌کند. فقط طرح سالانه Professional واجد شرایط است.",
"educationPricingConfirm.title": "تخفیف آموزشی در دسترس نیست",
"emailLabel": "ایمیل فعلی شما",
"form.schoolName.placeholder": "نام رسمی و کامل مدرسه خود را وارد کنید",
"form.schoolName.title": "نام مدرسه شما",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "هم‌اکنون دوباره تأیید کنید تا یک کوپن جدید برای سال تحصیلی آینده دریافت کنید. ما آن را به حساب شما اضافه خواهیم کرد و می‌توانید از آن برای ارتقاء بعدی استفاده کنید.",
"notice.stillInEducation.isAboutToExpire": "در حال حاضر دوباره تأیید کنید تا یک کوپن جدید برای سال تحصیلی آینده دریافت کنید. این کوپن به حساب شما ذخیره خواهد شد و در زمان تمدید بعدی شما آماده استفاده است.",
"notice.stillInEducation.title": "آیا هنوز در حال تحصیل هستید؟",
"planNotSupportEducationDiscount": "واجد شرایط قیمت‌گذاری آموزشی نیست",
"rejectContent": "متاسفانه، شما واجد شرایط وضعیت تأیید شده آموزشی نیستید و به همین دلیل نمی‌توانید کوپن انحصاری ۱۰۰٪ برای طرح حرفه‌ای Dify را در صورت استفاده از این آدرس ایمیل دریافت کنید.",
"rejectTitle": "تأییدیه آموزشی دیفی شما رد شده است",
"submit": "ارسال",
@ -40,5 +61,6 @@
"toVerified": "تحصیلات خود را تأیید کنید",
"toVerifiedTip.coupon": "کوپن انحصاری ۱۰۰٪",
"toVerifiedTip.end": "برای طرح حرفه‌ای دیفی.",
"toVerifiedTip.front": "شما اکنون برای وضعیت تأیید شده آموزشی واجد شرایط هستید. لطفاً اطلاعات تحصیلی خود را در زیر وارد کنید تا فرآیند را کامل کرده و یک دریافت کنید."
"toVerifiedTip.front": "شما اکنون برای وضعیت تأیید شده آموزشی واجد شرایط هستید. لطفاً اطلاعات تحصیلی خود را در زیر وارد کنید تا فرآیند را کامل کرده و یک دریافت کنید.",
"useEducationDiscount": "استفاده از تخفیف آموزشی"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Vous avez un abonnement actif. Vous pouvez utiliser la remise éducative après l'expiration de votre abonnement. Confirmez votre abonnement dans <stripeLink>Stripe</stripeLink>.",
"applied.description": "Félicitations ! Vous avez fait la demande de remise éducative avec succès.",
"applied.noPaymentPermission.description": "Vous n'avez pas la permission de payer dans cet espace de travail. Veuillez passer à un espace de travail où vous pouvez gérer la facturation pour utiliser la remise éducative.",
"applied.noPaymentPermission.returnHome": "Retour à Dify",
"applied.step1.description": "Vous avez fait la demande de remise éducative avec succès.",
"applied.step1.title": "Étape 1",
"applied.step2.description": "Sélectionnez l'espace de travail que vous souhaitez utiliser avec la remise éducative.",
"applied.step2.title": "Étape 2",
"applied.tabs.activeSubscription": "En abonnement",
"applied.tabs.eligible": "Peut acheter",
"applied.tabs.noPaymentPermission": "Pas de permission de paiement",
"applied.title": "Remise éducative appliquée",
"applied.workspace.plan": "Plan payant",
"applied.workspace.title": "Espace de travail actuel",
"currentSigned": "ACTUELLEMENT CONNECTÉ EN TANT QUE",
"educationPricingConfirm.billingPeriod.monthly": "mensuel",
"educationPricingConfirm.billingPeriod.yearly": "annuel",
"educationPricingConfirm.cancel": "Annuler",
"educationPricingConfirm.continue": "Continuer sans remise",
"educationPricingConfirm.description": "Votre plan {{planName}} {{billingPeriod}} ne prend pas en charge la remise éducative. Seul le plan Professional annuel est éligible.",
"educationPricingConfirm.title": "Remise éducative non disponible",
"emailLabel": "Votre email actuel",
"form.schoolName.placeholder": "Entrez le nom officiel et complet de votre école",
"form.schoolName.title": "Le nom de votre école",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Veuillez vérifier à nouveau maintenant pour obtenir un nouveau coupon pour la prochaine année académique. Nous l'ajouterons à votre compte et vous pourrez l'utiliser pour la prochaine mise à niveau.",
"notice.stillInEducation.isAboutToExpire": "Vérifiez de nouveau maintenant pour obtenir un nouveau coupon pour la prochaine année académique. Il sera enregistré dans votre compte et prêt à être utilisé lors de votre prochain renouvellement.",
"notice.stillInEducation.title": "Encore dans l'éducation ?",
"planNotSupportEducationDiscount": "Non éligible aux tarifs éducatifs",
"rejectContent": "Malheureusement, vous n'êtes pas éligible au statut Éducation Vérifié et ne pouvez donc pas recevoir le coupon exclusif de 100 % pour le Plan Professionnel Dify si vous utilisez cette adresse e-mail.",
"rejectTitle": "Votre vérification éducative Dify a été rejetée.",
"submit": "Soumettre",
@ -40,5 +61,6 @@
"toVerified": "Faire vérifier l'éducation",
"toVerifiedTip.coupon": "coupon exclusif 100%",
"toVerifiedTip.end": "pour le Plan Professionnel Dify.",
"toVerifiedTip.front": "Vous êtes maintenant éligible pour le statut Vérifié en Éducation. Veuillez entrer vos informations éducatives ci-dessous pour compléter le processus et recevoir un"
"toVerifiedTip.front": "Vous êtes maintenant éligible pour le statut Vérifié en Éducation. Veuillez entrer vos informations éducatives ci-dessous pour compléter le processus et recevoir un",
"useEducationDiscount": "Utiliser la remise éducative"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "आपके पास एक सक्रिय सदस्यता है। आपकी सदस्यता समाप्त होने के बाद आप शिक्षा छूट का उपयोग कर सकते हैं। <stripeLink>Stripe</stripeLink> में अपनी सदस्यता की पुष्टि करें।",
"applied.description": "बधाई हो! आपने शिक्षा छूट के लिए सफलतापूर्वक आवेदन किया है।",
"applied.noPaymentPermission.description": "इस workspace में आपके पास भुगतान की अनुमति नहीं है। शिक्षा छूट का उपयोग करने के लिए कृपया ऐसे workspace पर स्विच करें जहाँ आप बिलिंग प्रबंधित कर सकते हैं।",
"applied.noPaymentPermission.returnHome": "Dify पर वापस जाएं",
"applied.step1.description": "आपने शिक्षा छूट के लिए सफलतापूर्वक आवेदन किया है।",
"applied.step1.title": "चरण 1",
"applied.step2.description": "वह workspace चुनें जिसे आप शिक्षा छूट के साथ उपयोग करना चाहते हैं।",
"applied.step2.title": "चरण 2",
"applied.tabs.activeSubscription": "सदस्यता में",
"applied.tabs.eligible": "खरीद सकते हैं",
"applied.tabs.noPaymentPermission": "भुगतान की अनुमति नहीं",
"applied.title": "शिक्षा छूट लागू की गई",
"applied.workspace.plan": "भुगतान योजना",
"applied.workspace.title": "वर्तमान Workspace",
"currentSigned": "वर्तमान में साइन इन किया गया है के रूप में",
"educationPricingConfirm.billingPeriod.monthly": "मासिक",
"educationPricingConfirm.billingPeriod.yearly": "वार्षिक",
"educationPricingConfirm.cancel": "रद्द करें",
"educationPricingConfirm.continue": "छूट के बिना जारी रखें",
"educationPricingConfirm.description": "आपका {{planName}} {{billingPeriod}} प्लान शिक्षा छूट का समर्थन नहीं करता। केवल Professional वार्षिक प्लान पात्र है।",
"educationPricingConfirm.title": "शिक्षा छूट उपलब्ध नहीं",
"emailLabel": "आपका वर्तमान ईमेल",
"form.schoolName.placeholder": "अपनी स्कूल का आधिकारिक, बिना संक्षिप्त नाम दर्ज करें",
"form.schoolName.title": "आपके स्कूल का नाम",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "अब पुनः सत्यापित करें ताकि आप आगामी शैक्षणिक वर्ष के लिए एक नया कूपन प्राप्त कर सकें। हम इसे आपके खाते में जोड़ देंगे और आप इसे अगले अपग्रेड के लिए उपयोग कर सकेंगे।",
"notice.stillInEducation.isAboutToExpire": "अब फिर से सत्यापित करें ताकि आगामी शैक्षणिक वर्ष के लिए एक नया कूपन मिल सके। यह आपके खाते में सहेजा जाएगा और आपकी अगली नवीनीकरण पर उपयोग के लिए तैयार होगा।",
"notice.stillInEducation.title": "क्या आप अभी भी शिक्षा में हैं?",
"planNotSupportEducationDiscount": "शिक्षा मूल्य निर्धारण के लिए पात्र नहीं",
"rejectContent": "दुर्भाग्यवश, आप शिक्षा सत्यापित स्थिति के लिए योग्य नहीं हैं और इसलिए यदि आप इस ईमेल पते का उपयोग करते हैं, तो आप डिफाई प्रोफेशनल योजना के लिए विशेष 100% कूपन प्राप्त नहीं कर सकते।",
"rejectTitle": "आपकी डिफाई शैक्षणिक सत्यापन को अस्वीकृत कर दिया गया है",
"submit": "सबमिट करें",
@ -40,5 +61,6 @@
"toVerified": "शिक्षा की पुष्टि कराएँ",
"toVerifiedTip.coupon": "विशेष 100% कूपन",
"toVerifiedTip.end": "Dify प्रोफेशनल योजना के लिए।",
"toVerifiedTip.front": "आप अब शिक्षा सत्यापित स्थिति के लिए योग्य हैं। कृपया नीचे अपनी शिक्षा की जानकारी प्रदान करें ताकि प्रक्रिया को पूरा किया जा सके और एक प्राप्त हो सके"
"toVerifiedTip.front": "आप अब शिक्षा सत्यापित स्थिति के लिए योग्य हैं। कृपया नीचे अपनी शिक्षा की जानकारी प्रदान करें ताकि प्रक्रिया को पूरा किया जा सके और एक प्राप्त हो सके",
"useEducationDiscount": "शिक्षा छूट का उपयोग करें"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Anda memiliki langganan aktif. Anda dapat menggunakan diskon pendidikan setelah langganan Anda berakhir. Konfirmasi langganan Anda di <stripeLink>Stripe</stripeLink>.",
"applied.description": "Selamat! Anda telah berhasil mengajukan diskon pendidikan.",
"applied.noPaymentPermission.description": "Anda tidak memiliki izin pembayaran di workspace ini. Silakan beralih ke workspace di mana Anda dapat mengelola penagihan untuk menggunakan diskon pendidikan.",
"applied.noPaymentPermission.returnHome": "Kembali ke Dify",
"applied.step1.description": "Anda telah berhasil mengajukan diskon pendidikan.",
"applied.step1.title": "Langkah 1",
"applied.step2.description": "Pilih workspace yang ingin Anda gunakan dengan diskon pendidikan.",
"applied.step2.title": "Langkah 2",
"applied.tabs.activeSubscription": "Dalam langganan",
"applied.tabs.eligible": "Dapat membeli",
"applied.tabs.noPaymentPermission": "Tidak ada izin pembayaran",
"applied.title": "Diskon pendidikan diterapkan",
"applied.workspace.plan": "Paket berbayar",
"applied.workspace.title": "Workspace saat ini",
"currentSigned": "SAAT INI MASUK SEBAGAI",
"educationPricingConfirm.billingPeriod.monthly": "bulanan",
"educationPricingConfirm.billingPeriod.yearly": "tahunan",
"educationPricingConfirm.cancel": "Batal",
"educationPricingConfirm.continue": "Lanjutkan tanpa diskon",
"educationPricingConfirm.description": "Paket {{planName}} {{billingPeriod}} Anda tidak mendukung diskon pendidikan. Hanya paket Professional tahunan yang memenuhi syarat.",
"educationPricingConfirm.title": "Diskon pendidikan tidak tersedia",
"emailLabel": "Email Anda saat ini",
"form.schoolName.placeholder": "Masukkan nama resmi sekolah Anda yang tidak disingkat",
"form.schoolName.title": "Nama Sekolah Anda",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Verifikasi ulang sekarang untuk mendapatkan kupon baru untuk tahun akademik mendatang. Kami akan menambahkannya ke akun Anda dan Anda dapat menggunakannya untuk peningkatan berikutnya.",
"notice.stillInEducation.isAboutToExpire": "Verifikasi ulang sekarang untuk mendapatkan kupon baru untuk tahun akademik mendatang. Ini akan disimpan ke akun Anda dan siap digunakan pada perpanjangan berikutnya.",
"notice.stillInEducation.title": "Masih dalam pendidikan?",
"planNotSupportEducationDiscount": "Tidak memenuhi syarat untuk harga pendidikan",
"rejectContent": "Sayangnya, Anda tidak memenuhi syarat untuk status Education Verified dan oleh karena itu tidak dapat menerima kupon 100% eksklusif untuk Paket Dify Professional jika Anda menggunakan alamat email ini.",
"rejectTitle": "Verifikasi Pendidikan Dify Anda telah ditolak",
"submit": "Kirim",
@ -40,5 +61,6 @@
"toVerified": "Dapatkan Pendidikan Terverifikasi",
"toVerifiedTip.coupon": "kupon eksklusif 100%",
"toVerifiedTip.end": "untuk Paket Profesional Dify.",
"toVerifiedTip.front": "Anda sekarang memenuhi syarat untuk status Terverifikasi Pendidikan. Silakan masukkan informasi pendidikan Anda di bawah ini untuk menyelesaikan proses dan menerima"
"toVerifiedTip.front": "Anda sekarang memenuhi syarat untuk status Terverifikasi Pendidikan. Silakan masukkan informasi pendidikan Anda di bawah ini untuk menyelesaikan proses dan menerima",
"useEducationDiscount": "Gunakan diskon pendidikan"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Hai un abbonamento attivo. Puoi utilizzare lo sconto educativo dopo la scadenza dell'abbonamento. Conferma il tuo abbonamento su <stripeLink>Stripe</stripeLink>.",
"applied.description": "Congratulazioni! Hai fatto domanda per lo sconto educativo con successo.",
"applied.noPaymentPermission.description": "Non hai il permesso di pagamento in questo workspace. Passa a un workspace in cui puoi gestire la fatturazione per utilizzare lo sconto educativo.",
"applied.noPaymentPermission.returnHome": "Torna a Dify",
"applied.step1.description": "Hai fatto domanda per lo sconto educativo con successo.",
"applied.step1.title": "Passo 1",
"applied.step2.description": "Seleziona il workspace che vuoi utilizzare con lo sconto educativo.",
"applied.step2.title": "Passo 2",
"applied.tabs.activeSubscription": "In abbonamento",
"applied.tabs.eligible": "Può acquistare",
"applied.tabs.noPaymentPermission": "Nessun permesso di pagamento",
"applied.title": "Sconto educativo applicato",
"applied.workspace.plan": "Piano a pagamento",
"applied.workspace.title": "Workspace corrente",
"currentSigned": "ATTUALMENTE ACCEDUTO COME",
"educationPricingConfirm.billingPeriod.monthly": "mensile",
"educationPricingConfirm.billingPeriod.yearly": "annuale",
"educationPricingConfirm.cancel": "Annulla",
"educationPricingConfirm.continue": "Continua senza sconto",
"educationPricingConfirm.description": "Il tuo piano {{planName}} {{billingPeriod}} non supporta lo sconto educativo. Solo il piano Professional annuale è idoneo.",
"educationPricingConfirm.title": "Sconto educativo non disponibile",
"emailLabel": "La tua email attuale",
"form.schoolName.placeholder": "Inserisci il nome ufficiale e completo della tua scuola",
"form.schoolName.title": "Il Nome della tua Scuola",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Verifica di nuovo ora per ottenere un nuovo coupon per il prossimo anno accademico. Lo aggiungeremo al tuo account e potrai usarlo per il prossimo aggiornamento.",
"notice.stillInEducation.isAboutToExpire": "Verifica di nuovo ora per ottenere un nuovo coupon per il prossimo anno accademico. Sarà salvato nel tuo account e pronto per essere utilizzato al tuo prossimo rinnovo.",
"notice.stillInEducation.title": "Ancora in formazione?",
"planNotSupportEducationDiscount": "Non idoneo per i prezzi educativi",
"rejectContent": "Sfortunatamente, non sei idoneo per lo status di Educazione Verificata e quindi non puoi ricevere il coupon esclusivo del 100% per il Piano Professionale Dify se usi questo indirizzo email.",
"rejectTitle": "La tua verifica educativa Dify è stata rifiutata.",
"submit": "Invia",
@ -40,5 +61,6 @@
"toVerified": "Fai verificare la tua istruzione",
"toVerifiedTip.coupon": "coupon esclusivo al 100%",
"toVerifiedTip.end": "per il Piano Professionale Dify.",
"toVerifiedTip.front": "Ora sei idoneo per lo stato di Educazione Verificata. Per favore, inserisci le tue informazioni educative qui sotto per completare il processo e ricevere un"
"toVerifiedTip.front": "Ora sei idoneo per lo stato di Educazione Verificata. Per favore, inserisci le tue informazioni educative qui sotto per completare il processo e ricevere un",
"useEducationDiscount": "Utilizza lo sconto educativo"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "現在有効なサブスクリプションがあります。サブスクリプションの有効期限が切れた後、教育割引を使用できます。<stripeLink>Stripe</stripeLink> でサブスクリプションを確認してください。",
"applied.description": "おめでとうございます!教育割引の申請が成功しました。",
"applied.noPaymentPermission.description": "このワークスペースでは支払い権限がありません。教育割引を使用するには、請求を管理できるワークスペースに切り替えてください。",
"applied.noPaymentPermission.returnHome": "Dify に戻る",
"applied.step1.description": "教育割引の申請が成功しました。",
"applied.step1.title": "ステップ 1",
"applied.step2.description": "教育割引を使用するワークスペースを選択してください。",
"applied.step2.title": "ステップ 2",
"applied.tabs.activeSubscription": "サブスクリプション中",
"applied.tabs.eligible": "購入可能",
"applied.tabs.noPaymentPermission": "支払い権限なし",
"applied.title": "教育割引が適用されました",
"applied.workspace.plan": "有料プラン",
"applied.workspace.title": "現在のワークスペース",
"currentSigned": "現在ログイン中のアカウントは",
"educationPricingConfirm.billingPeriod.monthly": "月次",
"educationPricingConfirm.billingPeriod.yearly": "年次",
"educationPricingConfirm.cancel": "キャンセル",
"educationPricingConfirm.continue": "割引なしで続行",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} プランは教育割引に対応していません。Professional 年次プランのみが対象です。",
"educationPricingConfirm.title": "教育割引は利用できません",
"emailLabel": "現在のメールアドレス",
"form.schoolName.placeholder": "学校の正式名称(省略不可)を入力してください。",
"form.schoolName.title": "学校名",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "今すぐ再認証して、次の学年度向けの教育クーポンを取得してください。クーポンはあなたのアカウントに追加され、次回のアップグレード時にご利用いただけます。",
"notice.stillInEducation.isAboutToExpire": "今すぐ再認証して、次の学年度向けの教育クーポンを取得してください。クーポンは個人のアカウントに保存され、次回の更新時に使用できます。",
"notice.stillInEducation.title": "まだ在学中ですか?",
"planNotSupportEducationDiscount": "教育価格の対象外",
"rejectContent": "申し訳ございませんが、このメールアドレスでは 教育認証 の資格を取得できず、Dify プロフェッショナルプランの 100割引クーポン を受け取ることはできません。",
"rejectTitle": "Dify 教育認証が拒否されました",
"submit": "送信",
@ -40,5 +61,6 @@
"toVerified": "教育認証を取得",
"toVerifiedTip.coupon": "100割引クーポン",
"toVerifiedTip.end": "を受け取ることができます。",
"toVerifiedTip.front": "現在、教育認証ステータスを取得する資格があります。以下に教育情報を入力し、認証プロセスを完了すると、Dify プロフェッショナルプランの"
"toVerifiedTip.front": "現在、教育認証ステータスを取得する資格があります。以下に教育情報を入力し、認証プロセスを完了すると、Dify プロフェッショナルプランの",
"useEducationDiscount": "教育割引を使用"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "현재 활성 구독이 있습니다. 구독이 만료된 후 교육 할인을 사용할 수 있습니다. <stripeLink>Stripe</stripeLink>에서 구독을 확인하세요.",
"applied.description": "축하합니다! 교육 할인 신청이 성공적으로 완료되었습니다.",
"applied.noPaymentPermission.description": "이 워크스페이스에서 결제 권한이 없습니다. 교육 할인을 사용하려면 청구를 관리할 수 있는 워크스페이스로 전환하세요.",
"applied.noPaymentPermission.returnHome": "Dify로 돌아가기",
"applied.step1.description": "교육 할인 신청이 성공적으로 완료되었습니다.",
"applied.step1.title": "1단계",
"applied.step2.description": "교육 할인을 사용할 워크스페이스를 선택하세요.",
"applied.step2.title": "2단계",
"applied.tabs.activeSubscription": "구독 중",
"applied.tabs.eligible": "구매 가능",
"applied.tabs.noPaymentPermission": "결제 권한 없음",
"applied.title": "교육 할인 적용됨",
"applied.workspace.plan": "유료 플랜",
"applied.workspace.title": "현재 워크스페이스",
"currentSigned": "현재 로그인 중입니다",
"educationPricingConfirm.billingPeriod.monthly": "월간",
"educationPricingConfirm.billingPeriod.yearly": "연간",
"educationPricingConfirm.cancel": "취소",
"educationPricingConfirm.continue": "할인 없이 계속",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} 플랜은 교육 할인을 지원하지 않습니다. Professional 연간 플랜만 자격이 있습니다.",
"educationPricingConfirm.title": "교육 할인 불가",
"emailLabel": "현재 이메일",
"form.schoolName.placeholder": "귀하의 학교의 공식 약어가 아닌 전체 이름을 입력하세요.",
"form.schoolName.title": "당신의 학교 이름",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "지금 다시 확인하여 다가오는 학년도에 사용할 새 쿠폰을 받아보세요. 우리는 그것을 귀하의 계정에 추가하며, 다음 업그레이드에 사용할 수 있습니다.",
"notice.stillInEducation.isAboutToExpire": "새로운 학년을 위한 쿠폰을 받으시려면 지금 다시 인증하십시오. 쿠폰은 귀하의 계정에 저장되어 다음 갱신 시 사용할 수 있습니다.",
"notice.stillInEducation.title": "아직 학업 중이신가요?",
"planNotSupportEducationDiscount": "교육 가격 대상 아님",
"rejectContent": "안타깝게도, 귀하는 교육 인증 상태에 적합하지 않으므로 이 이메일 주소를 사용할 경우 Dify Professional Plan 의 독점 100% 쿠폰을 받을 수 없습니다.",
"rejectTitle": "귀하의 Dify 교육 인증이 거부되었습니다.",
"submit": "제출",
@ -40,5 +61,6 @@
"toVerified": "교육 인증 받기",
"toVerifiedTip.coupon": "독점 100% 쿠폰",
"toVerifiedTip.end": "Dify 프로페셔널 플랜을 위해.",
"toVerifiedTip.front": "당신은 이제 교육 인증 상태를 받을 자격이 있습니다. 아래에 귀하의 교육 정보를 입력하여 과정을 완료하고 인증을 받으십시오."
"toVerifiedTip.front": "당신은 이제 교육 인증 상태를 받을 자격이 있습니다. 아래에 귀하의 교육 정보를 입력하여 과정을 완료하고 인증을 받으십시오.",
"useEducationDiscount": "교육 할인 사용"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "U heeft een actief abonnement. U kunt de onderwijskorting gebruiken nadat uw abonnement is verlopen. Bevestig uw abonnement in <stripeLink>Stripe</stripeLink>.",
"applied.description": "Gefeliciteerd! U heeft met succes de onderwijskorting aangevraagd.",
"applied.noPaymentPermission.description": "U heeft geen betalingsrechten in deze werkruimte. Schakel over naar een werkruimte waar u facturering kunt beheren om de onderwijskorting te gebruiken.",
"applied.noPaymentPermission.returnHome": "Terug naar Dify",
"applied.step1.description": "U heeft met succes de onderwijskorting aangevraagd.",
"applied.step1.title": "Stap 1",
"applied.step2.description": "Selecteer de werkruimte die u wilt gebruiken met de onderwijskorting.",
"applied.step2.title": "Stap 2",
"applied.tabs.activeSubscription": "In abonnement",
"applied.tabs.eligible": "Kan kopen",
"applied.tabs.noPaymentPermission": "Geen betalingsrechten",
"applied.title": "Onderwijskorting toegepast",
"applied.workspace.plan": "Betaald plan",
"applied.workspace.title": "Huidige werkruimte",
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "maandelijks",
"educationPricingConfirm.billingPeriod.yearly": "jaarlijks",
"educationPricingConfirm.cancel": "Annuleren",
"educationPricingConfirm.continue": "Doorgaan zonder korting",
"educationPricingConfirm.description": "Uw {{planName}} {{billingPeriod}} abonnement ondersteunt de onderwijskorting niet. Alleen het jaarlijkse Professional abonnement komt in aanmerking.",
"educationPricingConfirm.title": "Onderwijskorting niet beschikbaar",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Re-verify now to get a new coupon for the upcoming academic year. We'll add it to your account and you can use it for the next upgrade.",
"notice.stillInEducation.isAboutToExpire": "Re-verify now to get a new coupon for the upcoming academic year. It'll be saved to your account and ready to use at your next renewal.",
"notice.stillInEducation.title": "Still in education?",
"planNotSupportEducationDiscount": "Niet in aanmerking voor onderwijsprijzen",
"rejectContent": "Unfortunately, you are not eligible for Education Verified status and therefore cannot receive the exclusive 100% coupon for the Dify Professional Plan if you use this email address.",
"rejectTitle": "Your Dify Educational Verification Has Been Rejected",
"submit": "Submit",
@ -40,5 +61,6 @@
"toVerified": "Get Education Verified",
"toVerifiedTip.coupon": "exclusive 100% coupon",
"toVerifiedTip.end": "for the Dify Professional Plan.",
"toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an"
"toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an",
"useEducationDiscount": "Gebruik onderwijskorting"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Masz aktywną subskrypcję. Możesz skorzystać z rabatu edukacyjnego po wygaśnięciu subskrypcji. Potwierdź subskrypcję w <stripeLink>Stripe</stripeLink>.",
"applied.description": "Gratulacje! Pomyślnie złożono wniosek o rabat edukacyjny.",
"applied.noPaymentPermission.description": "Nie masz uprawnień do płatności w tym obszarze roboczym. Przejdź do obszaru roboczego, w którym możesz zarządzać rozliczeniami, aby skorzystać z rabatu edukacyjnego.",
"applied.noPaymentPermission.returnHome": "Powrót do Dify",
"applied.step1.description": "Pomyślnie złożono wniosek o rabat edukacyjny.",
"applied.step1.title": "Krok 1",
"applied.step2.description": "Wybierz obszar roboczy, który chcesz używać z rabatem edukacyjnym.",
"applied.step2.title": "Krok 2",
"applied.tabs.activeSubscription": "W subskrypcji",
"applied.tabs.eligible": "Może kupić",
"applied.tabs.noPaymentPermission": "Brak uprawnień do płatności",
"applied.title": "Rabat edukacyjny zastosowany",
"applied.workspace.plan": "Plan płatny",
"applied.workspace.title": "Aktualny obszar roboczy",
"currentSigned": "AKTUALNIE ZALOGOWANY JAKO",
"educationPricingConfirm.billingPeriod.monthly": "miesięcznie",
"educationPricingConfirm.billingPeriod.yearly": "rocznie",
"educationPricingConfirm.cancel": "Anuluj",
"educationPricingConfirm.continue": "Kontynuuj bez rabatu",
"educationPricingConfirm.description": "Twój plan {{planName}} {{billingPeriod}} nie obsługuje rabatu edukacyjnego. Tylko roczny plan Professional jest uprawniony.",
"educationPricingConfirm.title": "Rabat edukacyjny niedostępny",
"emailLabel": "Twój aktualny email",
"form.schoolName.placeholder": "Wpisz oficjalną, pełną nazwę swojej szkoły",
"form.schoolName.title": "Nazwa Twojej Szkoły",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Sprawdź ponownie teraz, aby otrzymać nowy kupon na nadchodzący rok akademicki. Dodamy go do twojego konta i będziesz mógł go użyć przy następnej aktualizacji.",
"notice.stillInEducation.isAboutToExpire": "Zweryfikuj ponownie teraz, aby otrzymać nowy kupon na nadchodzący rok akademicki. Zostanie zapisany na Twoim koncie i gotowy do użycia przy następnej odnowie.",
"notice.stillInEducation.title": "Wciąż w edukacji?",
"planNotSupportEducationDiscount": "Nie kwalifikuje się do cen edukacyjnych",
"rejectContent": "Niestety, nie kwalifikujesz się do statusu Zweryfikowanej Edukacji i w związku z tym nie możesz otrzymać ekskluzywnego kuponu 100% na plan Dify Professional, jeśli korzystasz z tego adresu e-mail.",
"rejectTitle": "Twoja weryfikacja edukacyjna Dify została odrzucona",
"submit": "Zatwierdź",
@ -40,5 +61,6 @@
"toVerified": "Uzyskaj potwierdzenie edukacji",
"toVerifiedTip.coupon": "ekskluzywny kupon 100%",
"toVerifiedTip.end": "dla Profesjonalnego Planu Dify.",
"toVerifiedTip.front": "Teraz jesteś uprawniony do statusu zweryfikowanej edukacji. Proszę wprowadzić swoje informacje edukacyjne poniżej, aby zakończyć proces i otrzymać"
"toVerifiedTip.front": "Teraz jesteś uprawniony do statusu zweryfikowanej edukacji. Proszę wprowadzić swoje informacje edukacyjne poniżej, aby zakończyć proces i otrzymać",
"useEducationDiscount": "Użyj rabatu edukacyjnego"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Você tem uma assinatura ativa. Você pode usar o desconto educacional após o vencimento da assinatura. Confirme sua assinatura no <stripeLink>Stripe</stripeLink>.",
"applied.description": "Parabéns! Você solicitou com sucesso o desconto educacional.",
"applied.noPaymentPermission.description": "Você não tem permissão de pagamento neste workspace. Por favor, mude para um workspace onde você possa gerenciar o faturamento para usar o desconto educacional.",
"applied.noPaymentPermission.returnHome": "Voltar para o Dify",
"applied.step1.description": "Você solicitou com sucesso o desconto educacional.",
"applied.step1.title": "Passo 1",
"applied.step2.description": "Selecione o workspace que deseja usar com o desconto educacional.",
"applied.step2.title": "Passo 2",
"applied.tabs.activeSubscription": "Em assinatura",
"applied.tabs.eligible": "Pode comprar",
"applied.tabs.noPaymentPermission": "Sem permissão de pagamento",
"applied.title": "Desconto educacional aplicado",
"applied.workspace.plan": "Plano pago",
"applied.workspace.title": "Workspace atual",
"currentSigned": "ATUALMENTE CONECTADO COMO",
"educationPricingConfirm.billingPeriod.monthly": "mensal",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Cancelar",
"educationPricingConfirm.continue": "Continuar sem desconto",
"educationPricingConfirm.description": "Seu plano {{planName}} {{billingPeriod}} não suporta o desconto educacional. Apenas o plano Professional anual é elegível.",
"educationPricingConfirm.title": "Desconto educacional não disponível",
"emailLabel": "Seu e-mail atual",
"form.schoolName.placeholder": "Digite o nome oficial e não abreviado da sua escola",
"form.schoolName.title": "O nome da sua escola",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Reveja agora para obter um novo cupom para o próximo ano acadêmico. Nós o adicionaremos à sua conta e você poderá usá-lo na próxima atualização.",
"notice.stillInEducation.isAboutToExpire": "Verifique novamente agora para receber um novo cupom para o próximo ano acadêmico. Ele será salvo na sua conta e estará pronto para ser usado na sua próxima renovação.",
"notice.stillInEducation.title": "Ainda na educação?",
"planNotSupportEducationDiscount": "Não elegível para preço educacional",
"rejectContent": "Infelizmente, você não é elegível para o status de Educação Verificada e, portanto, não pode receber o cupom exclusivo de 100% para o Plano Profissional Dify se usar este endereço de e-mail.",
"rejectTitle": "A sua verificação educacional Dify foi rejeitada.",
"submit": "Enviar",
@ -40,5 +61,6 @@
"toVerified": "Verifique a Educação",
"toVerifiedTip.coupon": "cupom exclusivo de 100%",
"toVerifiedTip.end": "para o Plano Profissional Dify.",
"toVerifiedTip.front": "Você agora está elegível para o status de Educação Verificada. Por favor, insira suas informações educacionais abaixo para concluir o processo e receber um"
"toVerifiedTip.front": "Você agora está elegível para o status de Educação Verificada. Por favor, insira suas informações educacionais abaixo para concluir o processo e receber um",
"useEducationDiscount": "Usar desconto educacional"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Ai un abonament activ. Poți folosi reducerea educațională după expirarea abonamentului. Confirmați abonamentul în <stripeLink>Stripe</stripeLink>.",
"applied.description": "Felicitări! Ai aplicat cu succes pentru reducerea educațională.",
"applied.noPaymentPermission.description": "Nu ai permisiunea de plată în acest workspace. Te rugăm să treci la un workspace unde poți gestiona facturarea pentru a folosi reducerea educațională.",
"applied.noPaymentPermission.returnHome": "Înapoi la Dify",
"applied.step1.description": "Ai aplicat cu succes pentru reducerea educațională.",
"applied.step1.title": "Pasul 1",
"applied.step2.description": "Selectează workspace-ul pe care dorești să-l utilizezi cu reducerea educațională.",
"applied.step2.title": "Pasul 2",
"applied.tabs.activeSubscription": "În abonament",
"applied.tabs.eligible": "Poate cumpăra",
"applied.tabs.noPaymentPermission": "Fără permisiune de plată",
"applied.title": "Reducere educațională aplicată",
"applied.workspace.plan": "Plan plătit",
"applied.workspace.title": "Workspace-ul curent",
"currentSigned": "CONEXIUNE ÎN PREZENT CA",
"educationPricingConfirm.billingPeriod.monthly": "lunar",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Anulează",
"educationPricingConfirm.continue": "Continuă fără reducere",
"educationPricingConfirm.description": "Planul tău {{planName}} {{billingPeriod}} nu suportă reducerea educațională. Doar planul Professional anual este eligibil.",
"educationPricingConfirm.title": "Reducerea educațională nu este disponibilă",
"emailLabel": "Emailul tău curent",
"form.schoolName.placeholder": "Introduceți numele oficial, neabbreviat al școlii dumneavoastră",
"form.schoolName.title": "Numele Școlii Tale",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Re-verificați acum pentru a obține un nou cupon pentru următorul an academic. Vom adăuga acest cupon în contul dvs. și îl puteți folosi pentru următoarea actualizare.",
"notice.stillInEducation.isAboutToExpire": "Re-verifică acum pentru a obține un nou cupon pentru anul universitar următor. Va fi salvat în contul tău și gata de utilizat la următoarea reînnoire.",
"notice.stillInEducation.title": "Încă în educație?",
"planNotSupportEducationDiscount": "Nu este eligibil pentru prețuri educaționale",
"rejectContent": "Din păcate, nu ești eligibil pentru statutul de Verificat Educațional și, prin urmare, nu poți primi cuponul exclusiv de 100% pentru Planul Profesional Dify dacă folosești această adresă de email.",
"rejectTitle": "Verificarea educațională Dify a fost respinsă",
"submit": "Trimite",
@ -40,5 +61,6 @@
"toVerified": "Obțineți verificarea educației",
"toVerifiedTip.coupon": "cupom exclusiv 100%",
"toVerifiedTip.end": "pentru Planul Profesional Dify.",
"toVerifiedTip.front": "Sunteți acum eligibil pentru statutul de Educație Verificată. Vă rugăm să introduceți informațiile despre educația dumneavoastră mai jos pentru a finaliza procesul și a primi un"
"toVerifiedTip.front": "Sunteți acum eligibil pentru statutul de Educație Verificată. Vă rugăm să introduceți informațiile despre educația dumneavoastră mai jos pentru a finaliza procesul și a primi un",
"useEducationDiscount": "Folosește reducerea educațională"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "У вас есть активная подписка. Вы можете использовать образовательную скидку после истечения срока действия подписки. Подтвердите подписку в <stripeLink>Stripe</stripeLink>.",
"applied.description": "Поздравляем! Вы успешно подали заявку на образовательную скидку.",
"applied.noPaymentPermission.description": "У вас нет прав на оплату в этом рабочем пространстве. Пожалуйста, переключитесь в рабочее пространство, где вы можете управлять выставлением счетов, чтобы использовать образовательную скидку.",
"applied.noPaymentPermission.returnHome": "Вернуться в Dify",
"applied.step1.description": "Вы успешно подали заявку на образовательную скидку.",
"applied.step1.title": "Шаг 1",
"applied.step2.description": "Выберите рабочее пространство, которое хотите использовать с образовательной скидкой.",
"applied.step2.title": "Шаг 2",
"applied.tabs.activeSubscription": "В подписке",
"applied.tabs.eligible": "Можно купить",
"applied.tabs.noPaymentPermission": "Нет прав на оплату",
"applied.title": "Образовательная скидка применена",
"applied.workspace.plan": "Платный план",
"applied.workspace.title": "Текущее рабочее пространство",
"currentSigned": "В ДАННЫЙ МОМЕНТ ВХОД В ПРОФИЛЬ КАК",
"educationPricingConfirm.billingPeriod.monthly": "ежемесячно",
"educationPricingConfirm.billingPeriod.yearly": "ежегодно",
"educationPricingConfirm.cancel": "Отмена",
"educationPricingConfirm.continue": "Продолжить без скидки",
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не поддерживает образовательную скидку. Только годовой план Professional имеет право на скидку.",
"educationPricingConfirm.title": "Образовательная скидка недоступна",
"emailLabel": "Ваш текущий адрес электронной почты",
"form.schoolName.placeholder": "Введите официальное, полное название вашей школы",
"form.schoolName.title": "Название вашей школы",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Переутвердите сейчас, чтобы получить новый купон на предстоящий учебный год. Мы добавим его на ваш аккаунт, и вы сможете использовать его для следующего обновления.",
"notice.stillInEducation.isAboutToExpire": "Проверьте еще раз, чтобы получить новый купон на предстоящий учебный год. Он будет сохранен в вашем аккаунте и готов к использованию при следующем продлении.",
"notice.stillInEducation.title": "Все еще учишься?",
"planNotSupportEducationDiscount": "Не подходит для образовательной цены",
"rejectContent": "К сожалению, вы не имеете права на статус Проверенного образованием и, следовательно, не можете получить эксклюзивный купон на 100% для профессионального плана Dify, если вы используете этот адрес электронной почты.",
"rejectTitle": "Ваша образовательная проверка Dify была отклонена",
"submit": "Отправить",
@ -40,5 +61,6 @@
"toVerified": "Получите подтверждение образования",
"toVerifiedTip.coupon": "эксклюзивный 100% купон",
"toVerifiedTip.end": "для профессионального плана Dify.",
"toVerifiedTip.front": "Теперь вы имеете право на статус \"Проверенное образование\". Пожалуйста, введите свои образовательные данные ниже, чтобы завершить процесс и получить"
"toVerifiedTip.front": "Теперь вы имеете право на статус \"Проверенное образование\". Пожалуйста, введите свои образовательные данные ниже, чтобы завершить процесс и получить",
"useEducationDiscount": "Использовать образовательную скидку"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Imate aktivno naročnino. Izobraževalni popust lahko uporabite po poteku naročnine. Potrdite naročnino v <stripeLink>Stripe</stripeLink>.",
"applied.description": "Čestitamo! Uspešno ste se prijavili za izobraževalni popust.",
"applied.noPaymentPermission.description": "V tem delovnem prostoru nimate dovoljenja za plačilo. Preklopite na delovni prostor, kjer lahko upravljate obračunavanje, da uporabite izobraževalni popust.",
"applied.noPaymentPermission.returnHome": "Nazaj na Dify",
"applied.step1.description": "Uspešno ste se prijavili za izobraževalni popust.",
"applied.step1.title": "Korak 1",
"applied.step2.description": "Izberite delovni prostor, ki ga želite uporabiti z izobraževalnim popustom.",
"applied.step2.title": "Korak 2",
"applied.tabs.activeSubscription": "V naročnini",
"applied.tabs.eligible": "Lahko kupi",
"applied.tabs.noPaymentPermission": "Brez dovoljenja za plačilo",
"applied.title": "Izobraževalni popust je bil uporabljen",
"applied.workspace.plan": "Plačljiv načrt",
"applied.workspace.title": "Trenutni delovni prostor",
"currentSigned": "Trenutno prijavljen kot",
"educationPricingConfirm.billingPeriod.monthly": "mesečno",
"educationPricingConfirm.billingPeriod.yearly": "letno",
"educationPricingConfirm.cancel": "Prekliči",
"educationPricingConfirm.continue": "Nadaljuj brez popusta",
"educationPricingConfirm.description": "Vaš načrt {{planName}} {{billingPeriod}} ne podpira izobraževalnega popusta. Do popusta je upravičen samo letni načrt Professional.",
"educationPricingConfirm.title": "Izobraževalni popust ni na voljo",
"emailLabel": "Vaš trenutni elektronski naslov",
"form.schoolName.placeholder": "Vpišite uradno, neokrnjeno ime vaše šole",
"form.schoolName.title": "Ime vaše šole",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Ponovno preverite zdaj, da pridobite nov kupon za prihajajoče šolsko leto. Dodali ga bomo vašemu računu in lahko ga uporabite za naslednjo nadgradnjo.",
"notice.stillInEducation.isAboutToExpire": "Ponovno preverite zdaj, da pridobite nov kupon za prihajajoče akademsko leto. Shranjen bo na vašem računu in pripravljen za uporabo ob vaši naslednji obnovitvi.",
"notice.stillInEducation.title": "Še vedno v izobraževanju?",
"planNotSupportEducationDiscount": "Ni upravičen do izobraževalnih cen",
"rejectContent": "Na žalost niste upravičeni do statusa Verificirane izobrazbe in zato ne morete prejeti ekskluzivnega 100-odstotnega kupona za Dify profesionalni načrt, če uporabljate ta e-poštni naslov.",
"rejectTitle": "Vaša Dify izobraževalna verifikacija je bila zavrnjena.",
"submit": "Predloži",
@ -40,5 +61,6 @@
"toVerified": "Preverite izobrazbo",
"toVerifiedTip.coupon": "izključno 100% kupon",
"toVerifiedTip.end": "za profesionalni načrt Dify.",
"toVerifiedTip.front": "Zdaj ste upravičeni do statusa Preverjeno izobraževanje. Prosimo, vnesite svoje izobraževalne podatke spodaj, da zaključite postopek in prejmete"
"toVerifiedTip.front": "Zdaj ste upravičeni do statusa Preverjeno izobraževanje. Prosimo, vnesite svoje izobraževalne podatke spodaj, da zaključite postopek in prejmete",
"useEducationDiscount": "Uporabi izobraževalni popust"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "คุณมีการสมัครสมาชิกที่ยังใช้งานอยู่ คุณสามารถใช้ส่วนลดการศึกษาได้หลังจากการสมัครสมาชิกของคุณหมดอายุ ยืนยันการสมัครสมาชิกของคุณใน <stripeLink>Stripe</stripeLink>",
"applied.description": "ยินดีด้วย! คุณได้สมัครรับส่วนลดการศึกษาสำเร็จแล้ว",
"applied.noPaymentPermission.description": "คุณไม่มีสิทธิ์การชำระเงินในพื้นที่ทำงานนี้ โปรดเปลี่ยนไปยังพื้นที่ทำงานที่คุณสามารถจัดการการเรียกเก็บเงินเพื่อใช้ส่วนลดการศึกษา",
"applied.noPaymentPermission.returnHome": "กลับไปที่ Dify",
"applied.step1.description": "คุณได้สมัครรับส่วนลดการศึกษาสำเร็จแล้ว",
"applied.step1.title": "ขั้นตอนที่ 1",
"applied.step2.description": "เลือกพื้นที่ทำงานที่คุณต้องการใช้กับส่วนลดการศึกษา",
"applied.step2.title": "ขั้นตอนที่ 2",
"applied.tabs.activeSubscription": "อยู่ในการสมัครสมาชิก",
"applied.tabs.eligible": "สามารถซื้อได้",
"applied.tabs.noPaymentPermission": "ไม่มีสิทธิ์ชำระเงิน",
"applied.title": "ใช้ส่วนลดการศึกษาแล้ว",
"applied.workspace.plan": "แผนชำระเงิน",
"applied.workspace.title": "พื้นที่ทำงานปัจจุบัน",
"currentSigned": "ลงชื่อเข้าใช้ในฐานะ",
"educationPricingConfirm.billingPeriod.monthly": "รายเดือน",
"educationPricingConfirm.billingPeriod.yearly": "รายปี",
"educationPricingConfirm.cancel": "ยกเลิก",
"educationPricingConfirm.continue": "ดำเนินการต่อโดยไม่มีส่วนลด",
"educationPricingConfirm.description": "แผน {{planName}} {{billingPeriod}} ของคุณไม่รองรับส่วนลดการศึกษา เฉพาะแผน Professional รายปีเท่านั้นที่มีสิทธิ์",
"educationPricingConfirm.title": "ส่วนลดการศึกษาไม่พร้อมใช้งาน",
"emailLabel": "อีเมลปัจจุบันของคุณ",
"form.schoolName.placeholder": "กรุณาใส่ชื่อของโรงเรียนอย่างเป็นทางการที่ไม่มีการย่อ",
"form.schoolName.title": "ชื่อโรงเรียนของคุณ",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "ตรวจสอบอีกครั้งตอนนี้เพื่อรับคูปองใหม่สำหรับปีการศึกษาใหม่ เราจะเพิ่มมันเข้ากับบัญชีของคุณและคุณสามารถใช้มันสำหรับการอัปเกรดครั้งถัดไปได้",
"notice.stillInEducation.isAboutToExpire": "ตรวจสอบอีกครั้งเดี๋ยวนี้เพื่อรับคูปองใหม่สำหรับปีการศึกษาที่จะมาถึง มันจะถูกบันทึกในบัญชีของคุณและพร้อมใช้งานในการต่ออายุครั้งถัดไปของคุณ.",
"notice.stillInEducation.title": "ยังอยู่ในวัยเรียนใช่ไหม?",
"planNotSupportEducationDiscount": "ไม่มีสิทธิ์รับราคาการศึกษา",
"rejectContent": "น่าเสียดายที่คุณไม่มีสิทธิ์ได้รับสถานะการตรวจสอบการศึกษาและดังนั้นคุณจึงไม่สามารถรับคูปองพิเศษ 100% สำหรับแผนมืออาชีพ Dify หากคุณใช้ที่อยู่อีเมลนี้.",
"rejectTitle": "การตรวจสอบการศึกษา Dify ของคุณถูกปฏิเสธ",
"submit": "ส่ง",
@ -40,5 +61,6 @@
"toVerified": "ตรวจสอบการศึกษา",
"toVerifiedTip.coupon": "คูปองพิเศษ 100%",
"toVerifiedTip.end": "สำหรับแผนมืออาชีพของ Dify.",
"toVerifiedTip.front": "คุณมีสิทธิ์ได้รับสถานะการตรวจสอบการศึกษาแล้ว กรุณากรอกข้อมูลการศึกษาของคุณด้านล่างเพื่อดำเนินการให้เสร็จสิ้นและรับสิทธิ์"
"toVerifiedTip.front": "คุณมีสิทธิ์ได้รับสถานะการตรวจสอบการศึกษาแล้ว กรุณากรอกข้อมูลการศึกษาของคุณด้านล่างเพื่อดำเนินการให้เสร็จสิ้นและรับสิทธิ์",
"useEducationDiscount": "ใช้ส่วนลดการศึกษา"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Aktif bir aboneliğiniz var. Aboneliğinizin süresi dolduktan sonra eğitim indirimini kullanabilirsiniz. Aboneliğinizi <stripeLink>Stripe</stripeLink>'da onaylayın.",
"applied.description": "Tebrikler! Eğitim indirimi için başarıyla başvurdunuz.",
"applied.noPaymentPermission.description": "Bu workspace'te ödeme izniniz yok. Eğitim indirimini kullanmak için lütfen faturalamayı yönetebileceğiniz bir workspace'e geçin.",
"applied.noPaymentPermission.returnHome": "Dify'e geri dön",
"applied.step1.description": "Eğitim indirimi için başarıyla başvurdunuz.",
"applied.step1.title": "Adım 1",
"applied.step2.description": "Eğitim indirimiyle kullanmak istediğiniz workspace'i seçin.",
"applied.step2.title": "Adım 2",
"applied.tabs.activeSubscription": "Abonelikte",
"applied.tabs.eligible": "Satın alabilir",
"applied.tabs.noPaymentPermission": "Ödeme izni yok",
"applied.title": "Eğitim indirimi uygulandı",
"applied.workspace.plan": "Ücretli plan",
"applied.workspace.title": "Mevcut Workspace",
"currentSigned": "ŞU ANDA GİRİŞ YAPILDIĞI KİŞİ",
"educationPricingConfirm.billingPeriod.monthly": "aylık",
"educationPricingConfirm.billingPeriod.yearly": "yıllık",
"educationPricingConfirm.cancel": "İptal",
"educationPricingConfirm.continue": "İndirim olmadan devam et",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} planınız eğitim indirimini desteklemiyor. Yalnızca Professional yıllık plan uygun.",
"educationPricingConfirm.title": "Eğitim indirimi mevcut değil",
"emailLabel": "Şu anki e-posta adresin",
"form.schoolName.placeholder": "Okulunuzun resmi, kısaltılmamış adını girin",
"form.schoolName.title": "Okulunuzun Adı",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Şimdi yeniden doğrulayın, böylece yaklaşan akademik yıl için yeni bir kupon alın. Bu kuponu hesabınıza ekleyeceğiz ve sonraki yükseltme için kullanabilirsiniz.",
"notice.stillInEducation.isAboutToExpire": "Şimdi yeniden doğrulayın ve gelecek akademik yıl için yeni bir kupon alın. Bu, hesabınıza kaydedilecek ve bir sonraki yenilemenizde kullanıma hazır olacak.",
"notice.stillInEducation.title": "Hala eğitimde misin?",
"planNotSupportEducationDiscount": "Eğitim fiyatlandırması için uygun değil",
"rejectContent": "Maalesef, Eğitim Doğrulama statüsüne uygun değilsiniz ve bu nedenle bu e-posta adresini kullanıyorsanız Dify Profesyonel Planı için özel %100 kuponu alamazsınız.",
"rejectTitle": "Dify Eğitim Doğrulamanız Rededildi",
"submit": "Gönder",
@ -40,5 +61,6 @@
"toVerified": "Eğitim Bilgilerinizi Doğrulayın",
"toVerifiedTip.coupon": "özel %100 kupon",
"toVerifiedTip.end": "Dify Profesyonel Planı için.",
"toVerifiedTip.front": "Artık Eğitim Doğrulandı statüsüne uygun oldunuz. Lütfen süreci tamamlamak ve bir almak için eğitim bilgilerinizi aşağıya girin."
"toVerifiedTip.front": "Artık Eğitim Doğrulandı statüsüne uygun oldunuz. Lütfen süreci tamamlamak ve bir almak için eğitim bilgilerinizi aşağıya girin.",
"useEducationDiscount": "Eğitim indirimini kullan"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "У вас є активна підписка. Ви можете скористатися освітньою знижкою після закінчення терміну дії підписки. Підтвердіть підписку в <stripeLink>Stripe</stripeLink>.",
"applied.description": "Вітаємо! Ви успішно подали заявку на освітню знижку.",
"applied.noPaymentPermission.description": "У вас немає прав на оплату в цьому робочому просторі. Будь ласка, перейдіть до робочого простору, де ви можете керувати платіжними даними, щоб скористатися освітньою знижкою.",
"applied.noPaymentPermission.returnHome": "Повернутися до Dify",
"applied.step1.description": "Ви успішно подали заявку на освітню знижку.",
"applied.step1.title": "Крок 1",
"applied.step2.description": "Виберіть робочий простір, який ви хочете використовувати з освітньою знижкою.",
"applied.step2.title": "Крок 2",
"applied.tabs.activeSubscription": "У підписці",
"applied.tabs.eligible": "Можна купити",
"applied.tabs.noPaymentPermission": "Немає прав на оплату",
"applied.title": "Освітню знижку застосовано",
"applied.workspace.plan": "Платний план",
"applied.workspace.title": "Поточний робочий простір",
"currentSigned": "В даний момент ви підписані як",
"educationPricingConfirm.billingPeriod.monthly": "щомісячно",
"educationPricingConfirm.billingPeriod.yearly": "щорічно",
"educationPricingConfirm.cancel": "Скасувати",
"educationPricingConfirm.continue": "Продовжити без знижки",
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не підтримує освітню знижку. Лише річний план Professional має право на знижку.",
"educationPricingConfirm.title": "Освітня знижка недоступна",
"emailLabel": "Ваш поточний електронний лист",
"form.schoolName.placeholder": "Введіть офіційну, повну назву вашої школи",
"form.schoolName.title": "Ваша назва школи",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Перевірте ще раз зараз, щоб отримати новий купон на наступний навчальний рік. Ми додамо його до вашого облікового запису, і ви зможете скористатися ним для наступного оновлення.",
"notice.stillInEducation.isAboutToExpire": "Перевірте ще раз зараз, щоб отримати новий купон на наступний навчальний рік. Він буде збережений у вашому обліковому записі та готовий до використання при наступному поновленні.",
"notice.stillInEducation.title": "Все ще навчаєшся?",
"planNotSupportEducationDiscount": "Не підходить для освітньої ціни",
"rejectContent": "На жаль, ви не відповідаєте вимогам для статусу Education Verified і тому не можете отримати ексклюзивний купон на 100% для професійного плану Dify, якщо використовуєте цю електронну адресу.",
"rejectTitle": "Ваша перевірка освіти Dify була відхилена",
"submit": "Надіслати",
@ -40,5 +61,6 @@
"toVerified": "Отримайте підтвердження освіти",
"toVerifiedTip.coupon": "ексклюзивний купон 100%",
"toVerifiedTip.end": "для професійного плану Dify.",
"toVerifiedTip.front": "Ви тепер маєте право на статус перевіреної освіти. Будь ласка, введіть свою інформацію про освіту нижче, щоб завершити процес і отримати"
"toVerifiedTip.front": "Ви тепер маєте право на статус перевіреної освіти. Будь ласка, введіть свою інформацію про освіту нижче, щоб завершити процес і отримати",
"useEducationDiscount": "Використати освітню знижку"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "Bạn có một gói đăng ký đang hoạt động. Bạn có thể sử dụng giảm giá giáo dục sau khi gói đăng ký hết hạn. Xác nhận gói đăng ký của bạn trên <stripeLink>Stripe</stripeLink>.",
"applied.description": "Chúc mừng! Bạn đã đăng ký giảm giá giáo dục thành công.",
"applied.noPaymentPermission.description": "Bạn không có quyền thanh toán trong workspace này. Vui lòng chuyển sang workspace mà bạn có thể quản lý thanh toán để sử dụng giảm giá giáo dục.",
"applied.noPaymentPermission.returnHome": "Quay lại Dify",
"applied.step1.description": "Bạn đã đăng ký giảm giá giáo dục thành công.",
"applied.step1.title": "Bước 1",
"applied.step2.description": "Chọn workspace bạn muốn sử dụng với giảm giá giáo dục.",
"applied.step2.title": "Bước 2",
"applied.tabs.activeSubscription": "Đang đăng ký",
"applied.tabs.eligible": "Có thể mua",
"applied.tabs.noPaymentPermission": "Không có quyền thanh toán",
"applied.title": "Giảm giá giáo dục đã áp dụng",
"applied.workspace.plan": "Gói trả phí",
"applied.workspace.title": "Workspace hiện tại",
"currentSigned": "HIỆN ĐANG ĐĂNG NHẬP VÀO",
"educationPricingConfirm.billingPeriod.monthly": "hàng tháng",
"educationPricingConfirm.billingPeriod.yearly": "hàng năm",
"educationPricingConfirm.cancel": "Hủy",
"educationPricingConfirm.continue": "Tiếp tục không có giảm giá",
"educationPricingConfirm.description": "Gói {{planName}} {{billingPeriod}} của bạn không hỗ trợ giảm giá giáo dục. Chỉ gói Professional hàng năm mới được áp dụng.",
"educationPricingConfirm.title": "Giảm giá giáo dục không khả dụng",
"emailLabel": "Email hiện tại của bạn",
"form.schoolName.placeholder": "Nhập tên chính thức, không viết tắt của trường bạn",
"form.schoolName.title": "Tên Trường Của Bạn",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Xác minh lại ngay bây giờ để nhận một phiếu giảm giá mới cho năm học sắp tới. Chúng tôi sẽ thêm nó vào tài khoản của bạn và bạn có thể sử dụng nó cho lần nâng cấp tiếp theo.",
"notice.stillInEducation.isAboutToExpire": "Xác minh lại ngay bây giờ để nhận một phiếu giảm giá mới cho năm học sắp tới. Nó sẽ được lưu vào tài khoản của bạn và sẵn sàng sử dụng khi bạn gia hạn tiếp theo.",
"notice.stillInEducation.title": "Vẫn đang học tập?",
"planNotSupportEducationDiscount": "Không đủ điều kiện cho giá giáo dục",
"rejectContent": "Rất tiếc, bạn không đủ điều kiện để nhận trạng thái Xác minh Giáo dục và do đó không thể nhận được mã giảm giá độc quyền 100% cho Kế hoạch Chuyên nghiệp Dify nếu bạn sử dụng địa chỉ email này.",
"rejectTitle": "Yêu cầu xác minh giáo dục Dify của bạn đã bị từ chối",
"submit": "Gửi",
@ -40,5 +61,6 @@
"toVerified": "Xác thực giáo dục",
"toVerifiedTip.coupon": "mã giảm giá độc quyền 100%",
"toVerifiedTip.end": "cho Kế hoạch Chuyên nghiệp Dify.",
"toVerifiedTip.front": "Bạn hiện đủ điều kiện để có trạng thái Xác minh Giáo dục. Vui lòng nhập thông tin giáo dục của bạn bên dưới để hoàn tất quá trình và nhận một"
"toVerifiedTip.front": "Bạn hiện đủ điều kiện để có trạng thái Xác minh Giáo dục. Vui lòng nhập thông tin giáo dục của bạn bên dưới để hoàn tất quá trình và nhận một",
"useEducationDiscount": "Sử dụng giảm giá giáo dục"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "你当前有生效中的订阅。订阅到期后即可使用教育优惠。请前往 <stripeLink>Stripe</stripeLink> 确认你的订阅。",
"applied.description": "您已成功申请教育优惠。",
"applied.noPaymentPermission.description": "你没有此工作空间的付款权限。请切换到你可以管理账单的工作空间,以使用教育优惠。",
"applied.noPaymentPermission.returnHome": "返回 Dify",
"applied.step1.description": "您已成功申请教育优惠。",
"applied.step1.title": "第一步",
"applied.step2.description": "选择要使用教育优惠的 workspace。",
"applied.step2.title": "第二步",
"applied.tabs.activeSubscription": "在订阅中",
"applied.tabs.eligible": "能买",
"applied.tabs.noPaymentPermission": "无付款权限",
"applied.title": "教育优惠申请成功",
"applied.workspace.plan": "付费计划",
"applied.workspace.title": "当前 Workspace",
"currentSigned": "您当前登录的账户是",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用优惠继续",
"educationPricingConfirm.description": "你的 {{planName}} 计划{{billingPeriod}}不支持教育优惠。只有 Professional 的年付计划符合条件。",
"educationPricingConfirm.title": "教育优惠不适用于该计划",
"emailLabel": "您当前的邮箱",
"form.schoolName.placeholder": "请输入您的学校的官方全称(不得缩写)",
"form.schoolName.title": "您的学校名称",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "立即重新认证,获取新学年的教育优惠券。优惠券将发放至您的账户,并可在下次升级时使用。",
"notice.stillInEducation.isAboutToExpire": "立即重新验证,获取新学年的教育优惠券。优惠券将发放至您的账户,并可在下次续订时使用。",
"notice.stillInEducation.title": "仍在就读?",
"planNotSupportEducationDiscount": "不适用教育优惠价格",
"rejectContent": "非常遗憾,您无法使用此电子邮件以获得教育版认证资格,也无法领取 Dify Professional 版的 100% 独家优惠券。",
"rejectTitle": "您的 Dify 教育版认证已被拒绝",
"submit": "提交",
@ -40,5 +61,6 @@
"toVerified": "获取教育版认证",
"toVerifiedTip.coupon": "100% 独家优惠券",
"toVerifiedTip.end": "。",
"toVerifiedTip.front": "您现在符合教育版认证的资格。请在下方输入您的教育信息,以完成认证流程,并领取 Dify Professional 版的"
"toVerifiedTip.front": "您现在符合教育版认证的资格。请在下方输入您的教育信息,以完成认证流程,并领取 Dify Professional 版的",
"useEducationDiscount": "使用教育优惠"
}

View File

@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "你目前有生效中的訂閱。訂閱到期後即可使用教育優惠。請前往 <stripeLink>Stripe</stripeLink> 確認你的訂閱。",
"applied.description": "恭喜!您已成功申請教育優惠。",
"applied.noPaymentPermission.description": "你沒有此工作空間的付款權限。請切換到你可以管理帳單的工作空間,以使用教育優惠。",
"applied.noPaymentPermission.returnHome": "返回 Dify",
"applied.step1.description": "您已成功申請教育優惠。",
"applied.step1.title": "第一步",
"applied.step2.description": "選擇要使用教育優惠的 workspace。",
"applied.step2.title": "第二步",
"applied.tabs.activeSubscription": "在訂閱中",
"applied.tabs.eligible": "能買",
"applied.tabs.noPaymentPermission": "無付款權限",
"applied.title": "教育優惠申請成功",
"applied.workspace.plan": "付費方案",
"applied.workspace.title": "目前 Workspace",
"currentSigned": "當前以以下身份登入",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用優惠繼續",
"educationPricingConfirm.description": "你的 {{planName}} 方案{{billingPeriod}}不支援教育優惠。只有 Professional 的年付方案符合資格。",
"educationPricingConfirm.title": "教育優惠不適用於此方案",
"emailLabel": "您當前的電子郵件",
"form.schoolName.placeholder": "請輸入您學校的正式全名",
"form.schoolName.title": "你的學校名稱",
@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "立即重新驗證,以獲得即將到來的學年新優惠券。我們會將其新增到您的帳戶中,您可以用於下一次升級。",
"notice.stillInEducation.isAboutToExpire": "現在重新驗證以獲得即將到來的學年新優惠券。它將保存在您的帳戶中,並在下次續訂時隨時可以使用。",
"notice.stillInEducation.title": "仍在接受教育嗎?",
"planNotSupportEducationDiscount": "不適用教育優惠價格",
"rejectContent": "不幸的是,您不符合教育驗證狀態,因此如果您使用此電子郵件地址,將無法獲得 Dify 專業計劃的 100% 獨家優惠券。",
"rejectTitle": "您的 Dify 教育驗證已被拒絕",
"submit": "提交",
@ -40,5 +61,6 @@
"toVerified": "獲取教育證明",
"toVerifiedTip.coupon": "獨家 100% 優惠券",
"toVerifiedTip.end": "用於 Dify 專業計劃。",
"toVerifiedTip.front": "您現在符合教育驗證狀態的資格。請在下面輸入您的教育資訊以完成此流程並獲得一個"
"toVerifiedTip.front": "您現在符合教育驗證狀態的資格。請在下面輸入您的教育資訊以完成此流程並獲得一個",
"useEducationDiscount": "使用教育優惠"
}

Some files were not shown because too many files have changed in this diff Show More