From 29f34848cd90f09af19d64ad155b149987992cb1 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Thu, 7 May 2026 22:08:23 -0700 Subject: [PATCH 1/6] fix(tools): scope builtin tool default-credential clear to tenant (#35887) --- .../console/workspace/tool_providers.py | 4 ++-- .../tools/builtin_tools_manage_service.py | 5 ++--- .../test_builtin_tools_manage_service.py | 22 +++++++++++++++++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 34c9534de8..e653c9064c 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -876,10 +876,10 @@ class ToolBuiltinProviderSetDefaultApi(Resource): @login_required @account_initialization_required def post(self, provider): - current_user, current_tenant_id = current_account_with_tenant() + _, current_tenant_id = current_account_with_tenant() payload = BuiltinProviderDefaultCredentialPayload.model_validate(console_ns.payload or {}) return BuiltinToolManageService.set_default_provider( - tenant_id=current_tenant_id, user_id=current_user.id, provider=provider, id=payload.id + tenant_id=current_tenant_id, provider=provider, id=payload.id ) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index b8242ab3a5..20de1f4058 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -408,7 +408,7 @@ class BuiltinToolManageService: return {"result": "success"} @staticmethod - def set_default_provider(tenant_id: str, user_id: str, provider: str, id: str): + def set_default_provider(tenant_id: str, provider: str, id: str): """ set default provider """ @@ -422,12 +422,11 @@ class BuiltinToolManageService: if target_provider is None: raise ValueError("provider not found") - # clear default provider + # clear default provider (tenant-scoped: only one default per provider per workspace) session.execute( update(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.user_id == user_id, BuiltinToolProvider.provider == provider, BuiltinToolProvider.is_default.is_(True), ) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index ce0d94398d..c210db580e 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -180,7 +180,7 @@ class TestSetDefaultProvider: session.scalar.return_value = None with pytest.raises(ValueError, match="provider not found"): - BuiltinToolManageService.set_default_provider("t", "u", "p", "id") + BuiltinToolManageService.set_default_provider("t", "p", "id") @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") @@ -189,11 +189,29 @@ class TestSetDefaultProvider: target = MagicMock() session.scalar.return_value = target - result = BuiltinToolManageService.set_default_provider("t", "u", "p", "id") + result = BuiltinToolManageService.set_default_provider("t", "p", "id") assert result == {"result": "success"} assert target.is_default is True + @patch(f"{MODULE}.sessionmaker") + @patch(f"{MODULE}.db") + def test_clear_default_is_tenant_scoped_not_user_scoped(self, mock_db, mock_sm_cls): + # Regression: clearing prior defaults must NOT filter by user_id, otherwise + # two workspace members can each leave their own credential as default at + # the same time (the default flag is tenant-scoped, not per-user). + session = _mock_sessionmaker(mock_sm_cls) + session.scalar.return_value = MagicMock() + + BuiltinToolManageService.set_default_provider("tenant-1", "google", "cred-id") + + session.execute.assert_called_once() + update_stmt = session.execute.call_args.args[0] + compiled = str(update_stmt.compile(compile_kwargs={"literal_binds": True})) + assert "user_id" not in compiled + assert "tenant_id" in compiled + assert "provider" in compiled + class TestUpdateBuiltinToolProvider: @patch(f"{MODULE}.sessionmaker") From 927a17804bc00023da7edf14345d7a4ed40bbb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Fri, 8 May 2026 14:04:07 +0800 Subject: [PATCH 2/6] feat: support configurable explore app categories (#35723) --- api/constants/recommended_apps.json | 44 ++++++++--------- .../console/explore/recommended_app.py | 2 +- ...d8c9b731_add_recommended_app_categories.py | 26 ++++++++++ api/models/model.py | 1 + api/services/recommend_app/category_order.py | 49 +++++++++++++++++++ .../database/database_retrieval.py | 13 +++-- .../recommend_app/test_database_retrieval.py | 49 +++++++++++++++++++ .../console/explore/test_recommended_app.py | 3 +- .../recommend_app/test_category_order.py | 26 ++++++++++ .../explore/explore-app-list-flow.test.tsx | 32 ++++++++++-- .../app-card/__tests__/index.spec.tsx | 4 +- .../app/create-app-dialog/app-card/index.tsx | 2 +- .../app-list/__tests__/index.spec.tsx | 2 +- .../app/create-app-dialog/app-list/index.tsx | 2 +- .../components/apps/__tests__/index.spec.tsx | 2 +- web/app/components/apps/index.tsx | 2 +- .../explore/app-card/__tests__/index.spec.tsx | 4 +- web/app/components/explore/app-card/index.tsx | 2 +- .../explore/app-list/__tests__/index.spec.tsx | 6 +-- web/app/components/explore/app-list/index.tsx | 7 ++- web/app/components/explore/category.tsx | 7 ++- .../explore/try-app/__tests__/index.spec.tsx | 18 +++---- .../try-app/app-info/__tests__/index.spec.tsx | 9 ++-- .../explore/try-app/app-info/index.tsx | 18 +++++-- web/app/components/explore/try-app/index.tsx | 6 +-- web/models/explore.ts | 4 +- 26 files changed, 270 insertions(+), 70 deletions(-) create mode 100644 api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py create mode 100644 api/services/recommend_app/category_order.py create mode 100644 api/tests/unit_tests/services/recommend_app/test_category_order.py diff --git a/api/constants/recommended_apps.json b/api/constants/recommended_apps.json index 3779fb0180..3d728f1b2e 100644 --- a/api/constants/recommended_apps.json +++ b/api/constants/recommended_apps.json @@ -19,7 +19,7 @@ "name": "Website Generator" }, "app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041", - "category": "Programming", + "categories": ["Programming"], "copyright": null, "description": null, "is_listed": true, @@ -35,7 +35,7 @@ "name": "Investment Analysis Report Copilot" }, "app_id": "a23b57fa-85da-49c0-a571-3aff375976c1", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "Welcome to your personalized Investment Analysis Copilot service, where we delve into the depths of stock analysis to provide you with comprehensive insights. \n", "is_listed": true, @@ -51,7 +51,7 @@ "name": "Workflow Planning Assistant " }, "app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ", "is_listed": true, @@ -67,7 +67,7 @@ "name": "Automated Email Reply " }, "app_id": "e9d92058-7d20-4904-892f-75d90bef7587", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Reply emails using Gmail API. It will automatically retrieve email in your inbox and create a response in Gmail. \nConfigure your Gmail API in Google Cloud Console. ", "is_listed": true, @@ -83,7 +83,7 @@ "name": "Book Translation " }, "app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A workflow designed to translate a full book up to 15000 tokens per run. Uses Code node to separate text into chunks and Iteration to translate each chunk. ", "is_listed": true, @@ -99,7 +99,7 @@ "name": "Python bug fixer" }, "app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e", - "category": "Programming", + "categories": ["Programming"], "copyright": null, "description": null, "is_listed": true, @@ -115,7 +115,7 @@ "name": "Code Interpreter" }, "app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "Code interpreter, clarifying the syntax and semantics of the code.", "is_listed": true, @@ -131,7 +131,7 @@ "name": "SVG Logo Design " }, "app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "Hello, I am your creative partner in bringing ideas to vivid life! I can assist you in creating stunning designs by leveraging abilities of DALL·E 3. ", "is_listed": true, @@ -147,7 +147,7 @@ "name": "Long Story Generator (Iteration) " }, "app_id": "5efb98d7-176b-419c-b6ef-50767391ab62", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A workflow demonstrating how to use Iteration node to generate long article that is longer than the context length of LLMs. ", "is_listed": true, @@ -163,7 +163,7 @@ "name": "Text Summarization Workflow" }, "app_id": "f00c4531-6551-45ee-808f-1d7903099515", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.", "is_listed": true, @@ -179,7 +179,7 @@ "name": "YouTube Channel Data Analysis" }, "app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ", "is_listed": true, @@ -195,7 +195,7 @@ "name": "Article Grading Bot" }, "app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f", - "category": "Writing", + "categories": ["Writing"], "copyright": null, "description": "Assess the quality of articles and text based on user defined criteria. ", "is_listed": true, @@ -211,7 +211,7 @@ "name": "SEO Blog Generator" }, "app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.", "is_listed": true, @@ -227,7 +227,7 @@ "name": "SQL Creator" }, "app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "Write SQL from natural language by pasting in your schema with the request.Please describe your query requirements in natural language and select the target database type.", "is_listed": true, @@ -243,7 +243,7 @@ "name": "Sentiment Analysis " }, "app_id": "f06bf86b-d50c-4895-a942-35112dbe4189", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.", "is_listed": true, @@ -259,7 +259,7 @@ "name": "Strategic Consulting Expert" }, "app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2", - "category": "Assistant", + "categories": ["Assistant"], "copyright": "Copyright 2023 Dify", "description": "I can answer your questions related to strategic marketing.", "is_listed": true, @@ -275,7 +275,7 @@ "name": "Code Converter" }, "app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "This is an application that provides the ability to convert code snippets in multiple programming languages. You can input the code you wish to convert, select the target programming language, and get the desired output.", "is_listed": true, @@ -291,7 +291,7 @@ "name": "Question Classifier + Knowledge + Chatbot " }, "app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.", "is_listed": true, @@ -307,7 +307,7 @@ "name": "AI Front-end interviewer" }, "app_id": "127efead-8944-4e20-ba9d-12402eb345e0", - "category": "HR", + "categories": ["HR"], "copyright": "Copyright 2023 Dify", "description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.", "is_listed": true, @@ -323,7 +323,7 @@ "name": "Knowledge Retrieval + Chatbot " }, "app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, A chatbot with a knowledge base. ", "is_listed": true, @@ -339,7 +339,7 @@ "name": "Email Assistant Workflow " }, "app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.", "is_listed": true, @@ -355,7 +355,7 @@ "name": "Customer Review Analysis Workflow " }, "app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.", "is_listed": true, diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index 55bd679b48..fa65c8daf1 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -52,7 +52,7 @@ class RecommendedAppResponse(ResponseModel): copyright: str | None = None privacy_policy: str | None = None custom_disclaimer: str | None = None - category: str | None = None + categories: list[str] = Field(default_factory=list) position: int | None = None is_listed: bool | None = None can_trial: bool | None = None diff --git a/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py b/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py new file mode 100644 index 0000000000..eee58b6310 --- /dev/null +++ b/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py @@ -0,0 +1,26 @@ +"""add recommended app categories + +Revision ID: a4f2d8c9b731 +Revises: 227822d22895 +Create Date: 2026-04-29 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a4f2d8c9b731" +down_revision = "227822d22895" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("categories", sa.JSON(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.drop_column("categories") diff --git a/api/models/model.py b/api/models/model.py index 25c330b062..f7f90465cf 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -878,6 +878,7 @@ class RecommendedApp(TypeBase): copyright: Mapped[str] = mapped_column(String(255), nullable=False) privacy_policy: Mapped[str] = mapped_column(String(255), nullable=False) category: Mapped[str] = mapped_column(String(255), nullable=False) + categories: Mapped[list[str] | None] = mapped_column(sa.JSON, nullable=True, default=None) custom_disclaimer: Mapped[str] = mapped_column(LongText, default="") position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) diff --git a/api/services/recommend_app/category_order.py b/api/services/recommend_app/category_order.py new file mode 100644 index 0000000000..be6b112aa4 --- /dev/null +++ b/api/services/recommend_app/category_order.py @@ -0,0 +1,49 @@ +"""Apply Redis-backed category ordering for DB-backed Explore apps.""" + +import json +import logging +from collections.abc import Collection +from typing import Any + +from extensions.ext_redis import redis_client + +logger = logging.getLogger(__name__) + +EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order" + + +def _category_order_key(language: str) -> str: + return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}" + + +def get_explore_app_category_order(language: str) -> list[str]: + try: + raw_categories = redis_client.get(_category_order_key(language)) + except Exception: + logger.exception("Failed to read explore app category order from Redis.") + return [] + + if not raw_categories: + return [] + + if isinstance(raw_categories, bytes): + raw_categories = raw_categories.decode("utf-8") + + try: + categories: Any = json.loads(raw_categories) + except (TypeError, json.JSONDecodeError): + logger.warning("Invalid explore app category order payload for language %s.", language) + return [] + + if not isinstance(categories, list): + return [] + + return [category for category in categories if isinstance(category, str)] + + +def order_categories(categories: Collection[str], language: str) -> list[str]: + configured_order = get_explore_app_category_order(language) + if configured_order: + return configured_order + + return sorted(categories) diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index 1df5fd13b6..ac870f0700 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -6,6 +6,7 @@ from constants.languages import languages from extensions.ext_database import db from models.model import App, RecommendedApp from services.app_dsl_service import AppDslService +from services.recommend_app.category_order import order_categories from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType @@ -18,7 +19,7 @@ class RecommendedAppItemDict(TypedDict): copyright: Any privacy_policy: Any custom_disclaimer: str - category: str + categories: list[str] position: int is_listed: bool @@ -80,6 +81,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): if not site: continue + app_categories = recommended_app.categories or [] recommended_app_result: RecommendedAppItemDict = { "id": recommended_app.id, "app": recommended_app.app, @@ -88,15 +90,18 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): "copyright": site.copyright, "privacy_policy": site.privacy_policy, "custom_disclaimer": site.custom_disclaimer, - "category": recommended_app.category, + "categories": app_categories, "position": recommended_app.position, "is_listed": recommended_app.is_listed, } recommended_apps_result.append(recommended_app_result) - categories.add(recommended_app.category) + categories.update(app_categories) - return RecommendedAppsResultDict(recommended_apps=recommended_apps_result, categories=sorted(categories)) + return RecommendedAppsResultDict( + recommended_apps=recommended_apps_result, + categories=order_categories(categories, language), + ) @classmethod def fetch_recommended_app_detail_from_db(cls, app_id: str) -> RecommendedAppDetailDict | None: diff --git a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py index 724dd19f92..11e864176a 100644 --- a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py @@ -47,6 +47,7 @@ def _create_recommended_app( *, app_id: str, category: str = "chat", + categories: list[str] | None = None, language: str = "en-US", is_listed: bool = True, position: int = 1, @@ -57,6 +58,7 @@ def _create_recommended_app( copyright="copy", privacy_policy="pp", category=category, + categories=[category] if categories is None else categories, language=language, is_listed=is_listed, position=position, @@ -113,6 +115,53 @@ class TestFetchRecommendedAppsFromDb: assert "assistant" in result["categories"] assert "writing" in result["categories"] + def test_returns_multiple_categories_for_one_app( + self, flask_app_with_containers, db_session_with_containers: Session + ): + tenant_id = str(uuid4()) + created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=created_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=created_app.id, + category="writing", + categories=["writing", "assistant"], + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US") + + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) + assert recommended_app["categories"] == ["writing", "assistant"] + assert "writing" in result["categories"] + assert "assistant" in result["categories"] + + def test_ignores_legacy_category_when_categories_are_empty( + self, + flask_app_with_containers, + db_session_with_containers: Session, + ): + legacy_category = f"legacy-empty-{uuid4()}" + tenant_id = str(uuid4()) + created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=created_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=created_app.id, + category=legacy_category, + categories=[], + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US") + + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) + assert "category" not in recommended_app + assert recommended_app["categories"] == [] + assert legacy_category not in result["categories"] + def test_falls_back_to_default_language_when_empty( self, flask_app_with_containers, db_session_with_containers: Session ): diff --git a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py index 557fded37e..89cbea5ddc 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py @@ -126,7 +126,7 @@ class TestRecommendedAppResponseModels: }, "app_id": "app-1", "description": "desc", - "category": "cat", + "categories": ["cat", "other"], "position": 1, "is_listed": True, "can_trial": False, @@ -137,4 +137,5 @@ class TestRecommendedAppResponseModels: ).model_dump(mode="json") assert response["recommended_apps"][0]["app_id"] == "app-1" + assert response["recommended_apps"][0]["categories"] == ["cat", "other"] assert response["categories"] == ["cat"] diff --git a/api/tests/unit_tests/services/recommend_app/test_category_order.py b/api/tests/unit_tests/services/recommend_app/test_category_order.py new file mode 100644 index 0000000000..3b94021f26 --- /dev/null +++ b/api/tests/unit_tests/services/recommend_app/test_category_order.py @@ -0,0 +1,26 @@ +import json +from unittest.mock import patch + +from services.recommend_app.category_order import get_explore_app_category_order, order_categories + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_get_explore_app_category_order_returns_redis_list(mock_get): + mock_get.return_value = json.dumps(["C", "A", "B"]).encode() + + assert get_explore_app_category_order("en-US") == ["C", "A", "B"] + mock_get.assert_called_once_with("explore:apps:category_order:en-US") + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_order_categories_uses_redis_order_as_source_of_truth(mock_get): + mock_get.return_value = json.dumps(["C", "A", "B"]).encode() + + assert order_categories({"A", "B", "C", "D"}, "en-US") == ["C", "A", "B"] + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_order_categories_falls_back_to_sorted_categories_without_redis_order(mock_get): + mock_get.return_value = None + + assert order_categories({"B", "A", "C"}, "en-US") == ["A", "B", "C"] diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index 6af17119be..96798ac6a9 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -127,7 +127,7 @@ const createApp = (overrides: Partial = {}): App => ({ copyright: overrides.copyright ?? '', privacy_policy: overrides.privacy_policy ?? null, custom_disclaimer: overrides.custom_disclaimer ?? null, - category: overrides.category ?? 'Writing', + categories: overrides.categories ?? ['Writing'], position: overrides.position ?? 1, is_listed: overrides.is_listed ?? true, install_count: overrides.install_count ?? 0, @@ -165,9 +165,9 @@ describe('Explore App List Flow', () => { mockExploreData = { categories: ['Writing', 'Translate', 'Programming'], allList: [ - createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }), - createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }), - createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }), + createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, categories: ['Writing'] }), + createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, categories: ['Translate'] }), + createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, categories: ['Programming'] }), ], } }) @@ -190,6 +190,30 @@ describe('Explore App List Flow', () => { expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() }) + it('should only use categories when filtering by selected category', () => { + mockTabValue = 'Writing' + mockExploreData = { + categories: ['Writing', 'Translate'], + allList: [ + createApp({ + app_id: 'app-1', + app: { ...createApp().app, name: 'Active Writer' }, + categories: ['Writing'], + }), + createApp({ + app_id: 'app-2', + app: { ...createApp().app, id: 'app-id-2', name: 'Legacy Writer' }, + categories: [], + }), + ], + } + + renderAppList() + + expect(screen.getByText('Active Writer')).toBeInTheDocument() + expect(screen.queryByText('Legacy Writer')).not.toBeInTheDocument() + }) + it('should filter apps by search keyword', async () => { renderAppList() diff --git a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx index 16971f77d5..d1b7dedac3 100644 --- a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx @@ -35,7 +35,7 @@ const mockApp: App = { copyright: 'Test Corp', privacy_policy: null, custom_disclaimer: null, - category: 'Assistant', + categories: ['Assistant'], position: 1, is_listed: true, install_count: 100, @@ -253,7 +253,7 @@ describe('AppCard', () => { template_id: mockApp.app_id, template_name: mockApp.app.name, template_mode: mockApp.app.mode, - template_category: mockApp.category, + template_categories: mockApp.categories, page: 'studio', }) expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index e710e21436..27232b0350 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -35,7 +35,7 @@ const AppCard = ({ template_id: app.app_id, template_name: appBasicInfo.name, template_mode: appBasicInfo.mode, - template_category: app.category, + template_categories: app.categories, page: 'studio', }) setShowTryAppPanel?.(true, { appId: app.app_id, app }) diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 0c6462c2f9..cd6c6b57eb 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -115,7 +115,7 @@ vi.mock('@/next/navigation', () => ({ const createAppEntry = (name: string, category: string) => ({ app_id: name, - category, + categories: [category], app: { id: name, name, diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 1924de3893..b0f0b8ca59 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -74,7 +74,7 @@ const Apps = ({ const filteredByCategory = allList.filter((item) => { if (currCategory === allCategoriesEn) return true - return item.category === currCategory + return item.categories?.includes(currCategory) ?? false }) if (currentType.length === 0) return filteredByCategory diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index 94fa9f3484..7085852e8f 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -31,7 +31,7 @@ const mockFetchAppDetail = vi.mocked(fetchAppDetail) const mockTemplateApp: App = { app_id: 'template-1', - category: 'Assistant', + categories: ['Assistant'], app: { id: 'template-1', mode: AppModeEnum.CHAT, diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 9d74968605..c42132e890 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -151,7 +151,7 @@ const Apps = () => { diff --git a/web/app/components/explore/app-card/__tests__/index.spec.tsx b/web/app/components/explore/app-card/__tests__/index.spec.tsx index 43b8e4fde2..68cb802acf 100644 --- a/web/app/components/explore/app-card/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-card/__tests__/index.spec.tsx @@ -22,7 +22,7 @@ const createApp = (overrides?: Partial): App => ({ copyright: '2024', privacy_policy: null, custom_disclaimer: null, - category: 'Assistant', + categories: ['Assistant'], position: 1, is_listed: true, install_count: 0, @@ -167,7 +167,7 @@ describe('AppCard', () => { template_id: app.app_id, template_name: app.app.name, template_mode: app.app.mode, - template_category: app.category, + template_categories: app.categories, page: 'explore', }) }) diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index afa46f6ed4..3a6d4962e2 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -37,7 +37,7 @@ const AppCard = ({ template_id: app.app_id, template_name: appBasicInfo.name, template_mode: appBasicInfo.mode, - template_category: app.category, + template_categories: app.categories, page: 'explore', }) onTry({ appId: app.app_id, app }) diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 4da92dd568..7b5c02be4c 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -115,7 +115,7 @@ const createApp = (overrides: Partial = {}): App => ({ copyright: overrides.copyright ?? '', privacy_policy: overrides.privacy_policy ?? null, custom_disclaimer: overrides.custom_disclaimer ?? null, - category: overrides.category ?? 'Writing', + categories: overrides.categories ?? ['Writing'], position: overrides.position ?? 1, is_listed: overrides.is_listed ?? true, install_count: overrides.install_count ?? 0, @@ -185,7 +185,7 @@ describe('AppList', () => { it('should render app cards when data is available', () => { mockExploreData = { categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })], } renderAppList() @@ -199,7 +199,7 @@ describe('AppList', () => { it('should filter apps by selected category', () => { mockExploreData = { categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })], } renderAppList(false, undefined, { category: 'Writing' }) diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 0cb0e30d2e..86a0bbc6c0 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -77,7 +77,10 @@ const Apps = ({ const filteredList = useMemo(() => { if (!data) return [] - return data.allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory) + return data.allList.filter(item => ( + currCategory === allCategoriesEn + || item.categories?.includes(currCategory) + )) }, [data, currCategory, allCategoriesEn]) const searchFilteredList = useMemo(() => { @@ -277,7 +280,7 @@ const Apps = ({ diff --git a/web/app/components/explore/category.tsx b/web/app/components/explore/category.tsx index de0f5967e4..ef7ac13cd5 100644 --- a/web/app/components/explore/category.tsx +++ b/web/app/components/explore/category.tsx @@ -33,6 +33,11 @@ const Category: FC = ({ isSelected && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs', ) + const renderCategoryName = (name: AppCategory) => { + const categoryKey = `category.${name}` as keyof typeof exploreI18n + return categoryKey in exploreI18n ? t(categoryKey, { ns: 'explore' }) : name + } + return (
= ({ className={itemClassName(name === value)} onClick={() => onChange(name)} > - {`category.${name}` in exploreI18n ? t(`category.${name}`, { ns: 'explore' }) : name} + {renderCategoryName(name)}
))}
diff --git a/web/app/components/explore/try-app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx index a84989dea9..b93598b64a 100644 --- a/web/app/components/explore/try-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -39,14 +39,14 @@ vi.mock('../app-info', () => ({ default: ({ appId, appDetail, - category, + categories, className, onCreate, - }: { appId: string, appDetail: TryAppInfo, category?: string, className?: string, onCreate: () => void }) => ( + }: { appId: string, appDetail: TryAppInfo, categories?: string[], className?: string, onCreate: () => void }) => (
@@ -283,12 +283,12 @@ describe('TryApp (main index.tsx)', () => { }) }) - describe('category prop', () => { - it('passes category to AppInfo when provided', async () => { + describe('categories prop', () => { + it('passes categories to AppInfo when provided', async () => { render( , @@ -296,11 +296,11 @@ describe('TryApp (main index.tsx)', () => { await waitFor(() => { const appInfo = document.body.querySelector('[data-testid="app-info-component"]') - expect(appInfo).toHaveAttribute('data-category', 'AI Assistant') + expect(appInfo).toHaveAttribute('data-categories', 'AI Assistant,Workflow') }) }) - it('does not pass category to AppInfo when not provided', async () => { + it('does not pass categories to AppInfo when not provided', async () => { render( { await waitFor(() => { const appInfo = document.body.querySelector('[data-testid="app-info-component"]') - expect(appInfo).not.toHaveAttribute('data-category', expect.any(String)) + expect(appInfo).not.toHaveAttribute('data-categories', expect.any(String)) }) }) }) diff --git a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx index 599d66596b..e517dbb980 100644 --- a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx @@ -235,8 +235,8 @@ describe('AppInfo', () => { }) }) - describe('category', () => { - it('renders category when provided', () => { + describe('categories', () => { + it('renders categories when provided', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() @@ -244,16 +244,17 @@ describe('AppInfo', () => { , ) expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument() expect(screen.getByText('AI Assistant')).toBeInTheDocument() + expect(screen.getByText('Workflow')).toBeInTheDocument() }) - it('does not render category section when not provided', () => { + it('does not render categories section when not provided', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() diff --git a/web/app/components/explore/try-app/app-info/index.tsx b/web/app/components/explore/try-app/app-info/index.tsx index a2c8c26e9f..c5749bee7c 100644 --- a/web/app/components/explore/try-app/app-info/index.tsx +++ b/web/app/components/explore/try-app/app-info/index.tsx @@ -12,7 +12,7 @@ import useGetRequirements from './use-get-requirements' type Props = { appId: string appDetail: TryAppInfo - category?: string + categories?: string[] className?: string onCreate: () => void } @@ -52,12 +52,13 @@ const RequirementIcon: FC = ({ iconUrl }) => { const AppInfo: FC = ({ appId, className, - category, + categories, appDetail, onCreate, }) => { const { t } = useTranslation() const mode = appDetail?.mode + const visibleCategories = Array.from(new Set(categories?.filter(Boolean) ?? [])) const { requirements } = useGetRequirements({ appDetail, appId }) return (
@@ -98,10 +99,19 @@ const AppInfo: FC = ({ {t('tryApp.createFromSampleApp', { ns: 'explore' })} - {category && ( + {visibleCategories.length > 0 && (
{t('tryApp.category', { ns: 'explore' })}
-
{category}
+
+ {visibleCategories.map(category => ( + + {category} + + ))} +
)} {requirements.length > 0 && ( diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index 8bb27d9086..eb5ea952da 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -20,7 +20,7 @@ import Tab, { TypeEnum } from './tab' type Props = { appId: string app?: AppType - category?: string + categories?: string[] onClose: () => void onCreate: () => void } @@ -28,7 +28,7 @@ type Props = { const TryApp: FC = ({ appId, app, - category, + categories, onClose, onCreate, }) => { @@ -81,7 +81,7 @@ const TryApp: FC = ({ className="w-[360px] shrink-0" appDetail={appDetail} appId={appId} - category={category} + categories={categories} onCreate={onCreate} />
diff --git a/web/models/explore.ts b/web/models/explore.ts index c05dd1eca1..3a82c589b2 100644 --- a/web/models/explore.ts +++ b/web/models/explore.ts @@ -12,7 +12,7 @@ type AppBasicInfo = { use_icon_as_answer_icon: boolean } -export type AppCategory = 'Writing' | 'Translate' | 'HR' | 'Programming' | 'Assistant' | 'Agent' | 'Recommended' | 'Workflow' +export type AppCategory = string export type App = { app: AppBasicInfo @@ -21,7 +21,7 @@ export type App = { copyright: string privacy_policy: string | null custom_disclaimer: string | null - category: AppCategory + categories: AppCategory[] position: number is_listed: boolean install_count: number From 82f24b336d74585e10b2b14f2f56d2d9f7f52a39 Mon Sep 17 00:00:00 2001 From: shawnYJ Date: Fri, 8 May 2026 15:55:46 +0800 Subject: [PATCH 3/6] fix(workflow): handle file-preview URLs in node output display (#34150) --- web/app/components/workflow/run/node.tsx | 2 ++ web/app/components/workflow/run/result-panel.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index 9895868887..a87922eb54 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -271,6 +271,7 @@ const NodePanel: FC = ({
{processDataTitle}
} language={CodeLanguage.json} value={nodeInfo.process_data} @@ -282,6 +283,7 @@ const NodePanel: FC = ({
{outputTitle}
} language={CodeLanguage.json} value={nodeInfo.outputs} diff --git a/web/app/components/workflow/run/result-panel.tsx b/web/app/components/workflow/run/result-panel.tsx index 0304037d27..699aceae78 100644 --- a/web/app/components/workflow/run/result-panel.tsx +++ b/web/app/components/workflow/run/result-panel.tsx @@ -143,6 +143,7 @@ const ResultPanel: FC = ({ {process_data && ( {t('common.processData', { ns: 'workflow' }).toLocaleUpperCase()}
} language={CodeLanguage.json} value={process_data} @@ -153,6 +154,7 @@ const ResultPanel: FC = ({ {(outputs || status === 'running') && ( {t('common.output', { ns: 'workflow' }).toLocaleUpperCase()}} language={CodeLanguage.json} value={outputs} From 8f93bb36ba9d8895d990a17569d6fe0e67945c40 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 8 May 2026 16:53:32 +0800 Subject: [PATCH 4/6] feat(dify-ui): add drawer (#35917) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 125 ++-- packages/dify-ui/README.md | 27 +- packages/dify-ui/package.json | 4 + .../src/drawer/__tests__/index.spec.tsx | 61 ++ packages/dify-ui/src/drawer/index.tsx | 116 ++++ .../tools/tool-provider-detail-flow.test.tsx | 2 +- .../app-publisher/__tests__/index.spec.tsx | 44 ++ .../app-publisher/__tests__/sections.spec.tsx | 26 +- .../components/app/app-publisher/index.tsx | 64 +- .../components/app/app-publisher/sections.tsx | 38 +- .../chat-with-history/sidebar/operation.tsx | 30 +- .../chat/__tests__/use-chat-layout.spec.tsx | 2 + .../base/chat/chat/use-chat-layout.ts | 52 +- .../src/vender/line/development/index.ts | 1 - .../documents/detail/__tests__/index.spec.tsx | 8 +- .../documents/detail/batch-modal/index.tsx | 60 +- .../detail/completed/__tests__/index.spec.tsx | 13 +- .../__tests__/segment-detail.spec.tsx | 32 +- .../common/__tests__/drawer.spec.tsx | 167 +++-- .../__tests__/full-screen-drawer.spec.tsx | 116 ++-- .../detail/completed/common/drawer.tsx | 187 ++---- .../completed/common/full-screen-drawer.tsx | 33 +- .../__tests__/drawer-group.spec.tsx | 16 +- .../completed/components/drawer-group.tsx | 59 +- .../detail/completed/components/index.ts | 3 - .../hooks/__tests__/use-modal-state.spec.ts | 70 +- .../__tests__/use-segment-list-data.spec.ts | 7 +- .../detail/completed/hooks/use-modal-state.ts | 35 +- .../completed/hooks/use-segment-list-data.ts | 7 +- .../documents/detail/completed/index.tsx | 10 +- .../detail/completed/segment-detail.tsx | 24 +- .../datasets/documents/detail/index.tsx | 14 +- .../segment-add/__tests__/index.spec.tsx | 51 +- .../documents/detail/segment-add/index.tsx | 109 ++-- .../__tests__/api-key-modal.spec.tsx | 4 - .../__tests__/oauth-client-settings.spec.tsx | 4 - .../plugin-auth/authorize/api-key-modal.tsx | 3 +- .../authorize/oauth-client-settings.tsx | 3 +- .../edit/apikey-edit-modal.tsx | 3 +- .../edit/manual-edit-modal.tsx | 3 +- .../edit/oauth-edit-modal.tsx | 3 +- .../readme-panel/__tests__/entrance.spec.tsx | 49 +- .../readme-panel/__tests__/index.spec.tsx | 597 ++++-------------- .../readme-panel/__tests__/store.spec.ts | 54 +- .../plugins/readme-panel/content.tsx | 81 +++ .../plugins/readme-panel/dialog.tsx | 52 ++ .../plugins/readme-panel/drawer.tsx | 62 ++ .../plugins/readme-panel/entrance.tsx | 30 +- .../components/plugins/readme-panel/index.tsx | 142 +---- .../components/plugins/readme-panel/store.ts | 39 +- .../share/text-generation/menu-dropdown.tsx | 12 +- .../tools/labels/__tests__/selector.spec.tsx | 30 +- web/app/components/tools/labels/selector.tsx | 28 +- .../tools/provider/__tests__/detail.spec.tsx | 16 +- web/app/components/tools/provider/detail.tsx | 20 +- .../__tests__/configure-button.spec.tsx | 420 +++++------- .../workflow-tool/__tests__/index.spec.tsx | 35 +- .../tools/workflow-tool/configure-button.tsx | 81 +-- .../__tests__/use-configure-button.spec.ts | 76 +-- .../hooks/use-configure-button.ts | 32 +- .../components/tools/workflow-tool/index.tsx | 112 ++-- .../__tests__/features-trigger.spec.tsx | 2 +- web/docs/overlay-migration.md | 15 +- web/eslint.constants.mjs | 9 + web/models/datasets.ts | 3 +- web/types/dataset.ts | 8 + 66 files changed, 1686 insertions(+), 1955 deletions(-) create mode 100644 packages/dify-ui/src/drawer/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/drawer/index.tsx delete mode 100644 web/app/components/datasets/documents/detail/completed/components/index.ts create mode 100644 web/app/components/plugins/readme-panel/content.tsx create mode 100644 web/app/components/plugins/readme-panel/dialog.tsx create mode 100644 web/app/components/plugins/readme-panel/drawer.tsx create mode 100644 web/types/dataset.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b5e67df509..cb41ef5f83 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -202,6 +202,11 @@ "count": 1 } }, + "web/app/components/app/annotation/add-annotation-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -230,6 +235,11 @@ "count": 1 } }, + "web/app/components/app/annotation/edit-annotation-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/annotation/header-opts/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -252,6 +262,9 @@ "erasable-syntax-only/enums": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 5 }, @@ -269,11 +282,6 @@ "count": 4 } }, - "web/app/components/app/app-publisher/index.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "web/app/components/app/app-publisher/version-info-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -344,6 +352,9 @@ } }, "web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/exhaustive-deps": { "count": 1 }, @@ -401,6 +412,16 @@ "count": 2 } }, + "web/app/components/app/configuration/configuration-view.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/app/components/app/configuration/dataset-config/card-item/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/configuration/dataset-config/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -531,6 +552,9 @@ } }, "web/app/components/app/log/list.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 6 }, @@ -580,6 +604,9 @@ } }, "web/app/components/app/workflow-log/list.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 2 } @@ -904,6 +931,11 @@ "count": 1 } }, + "web/app/components/base/drawer-plus/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/base/emoji-picker/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1029,6 +1061,11 @@ "count": 3 } }, + "web/app/components/base/float-right-container/index.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "web/app/components/base/form/components/base/base-form.tsx": { "ts/no-explicit-any": { "count": 6 @@ -1233,7 +1270,7 @@ }, "web/app/components/base/icons/src/vender/line/development/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 2 + "count": 1 } }, "web/app/components/base/icons/src/vender/line/editor/index.ts": { @@ -2144,14 +2181,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/batch-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": { "react/set-state-in-effect": { "count": 1 @@ -2162,11 +2191,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/components/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 3 - } - }, "web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -2231,14 +2255,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/segment-add/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": { "ts/no-explicit-any": { "count": 6 @@ -2280,6 +2296,9 @@ } }, "web/app/components/datasets/hit-testing/index.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/unsupported-syntax": { "count": 1 } @@ -2319,7 +2338,7 @@ }, "web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": { "no-restricted-imports": { - "count": 2 + "count": 3 } }, "web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": { @@ -2813,10 +2832,18 @@ } }, "web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 7 } }, + "web/app/components/plugins/plugin-detail-panel/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/model-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2838,6 +2865,9 @@ } }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } @@ -2896,6 +2926,9 @@ } }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 5 } @@ -2933,16 +2966,6 @@ "count": 1 } }, - "web/app/components/plugins/readme-panel/index.tsx": { - "react/unsupported-syntax": { - "count": 1 - } - }, - "web/app/components/plugins/readme-panel/store.ts": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -3170,7 +3193,7 @@ }, "web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { @@ -3179,6 +3202,9 @@ } }, "web/app/components/tools/edit-custom-collection-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 4 }, @@ -3187,6 +3213,9 @@ } }, "web/app/components/tools/edit-custom-collection-modal/test-api.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -3196,6 +3225,11 @@ "count": 1 } }, + "web/app/components/tools/mcp/detail/provider-detail.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/tools/mcp/mcp-server-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -3224,12 +3258,20 @@ "count": 1 } }, + "web/app/components/tools/provider/detail.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/tools/provider/empty.tsx": { "ts/no-explicit-any": { "count": 1 } }, "web/app/components/tools/setting/build-in/config-credentials.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } @@ -4061,6 +4103,11 @@ "count": 1 } }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index bdeeec33cb..2915fe5db7 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel: import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import '@langgenius/dify-ui/styles.css' // once, in the app root ``` @@ -36,12 +37,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported ## Primitives -| Category | Subpath | Notes | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | -| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | -| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | -| Media | `./avatar`, `./button` | Button exposes `cva` variants. | +| Category | Subpath | Notes | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | +| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | +| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | +| Media | `./avatar`, `./button` | Button exposes `cva` variants. | Utilities: @@ -65,7 +66,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s ## Overlay & portal contract -All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually. +Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure. ### Root isolation requirement @@ -83,19 +84,19 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites. -| Layer | z-index | Where | -| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | -| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | -| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | +| Layer | z-index | Where | +| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | +| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | +| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | -Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. +Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`. ### Rules - Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated. -- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal. +- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal. - When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites. ## Development diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 20e94c7dee..894e92bfd6 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -37,6 +37,10 @@ "types": "./src/dialog/index.tsx", "import": "./src/dialog/index.tsx" }, + "./drawer": { + "types": "./src/drawer/index.tsx", + "import": "./src/drawer/index.tsx" + }, "./dropdown-menu": { "types": "./src/dropdown-menu/index.tsx", "import": "./src/dropdown-menu/index.tsx" diff --git a/packages/dify-ui/src/drawer/__tests__/index.spec.tsx b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx new file mode 100644 index 0000000000..8c3a93f02c --- /dev/null +++ b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx @@ -0,0 +1,61 @@ +import { render } from 'vitest-browser-react' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerTrigger, + DrawerViewport, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Drawer wrapper', () => { + describe('User Interactions', () => { + it('should open a portalled drawer and close it with the default close button', async () => { + const screen = await render( + + Open settings + + + + + Settings + Configure the current workspace. + +

Workspace controls

+ +
+
+
+
+
, + ) + + expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument() + + asHTMLElement(screen.getByRole('button', { name: 'Open settings' }).element()).click() + + await vi.waitFor(() => { + expect(document.body.querySelector('[role="dialog"]')).toBeInTheDocument() + }) + + const dialog = asHTMLElement(document.body.querySelector('[role="dialog"]')!) + expect(document.body).toContainElement(dialog) + expect(screen.container).not.toContainElement(dialog) + await expect.element(dialog).toHaveTextContent('Workspace controls') + await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument() + await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-1002') + + asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click() + + await vi.waitFor(() => { + expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/packages/dify-ui/src/drawer/index.tsx b/packages/dify-ui/src/drawer/index.tsx new file mode 100644 index 0000000000..c63bc8174e --- /dev/null +++ b/packages/dify-ui/src/drawer/index.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { ReactNode } from 'react' +import { Drawer as BaseDrawer } from '@base-ui/react/drawer' +import { cn } from '../cn' + +export const Drawer = BaseDrawer.Root +export const DrawerProvider = BaseDrawer.Provider +export const DrawerIndent = BaseDrawer.Indent +export const DrawerIndentBackground = BaseDrawer.IndentBackground +export const DrawerTrigger = BaseDrawer.Trigger +export const DrawerSwipeArea = BaseDrawer.SwipeArea +export const DrawerPortal = BaseDrawer.Portal +export const DrawerTitle = BaseDrawer.Title +export const DrawerDescription = BaseDrawer.Description +export const DrawerClose = BaseDrawer.Close +export const createDrawerHandle = BaseDrawer.createHandle + +export type DrawerRootProps = BaseDrawer.Root.Props +export type DrawerRootActions = BaseDrawer.Root.Actions +export type DrawerRootChangeEventDetails = BaseDrawer.Root.ChangeEventDetails +export type DrawerRootChangeEventReason = BaseDrawer.Root.ChangeEventReason +export type DrawerRootSnapPoint = BaseDrawer.Root.SnapPoint +export type DrawerRootSnapPointChangeEventDetails = BaseDrawer.Root.SnapPointChangeEventDetails +export type DrawerRootSnapPointChangeEventReason = BaseDrawer.Root.SnapPointChangeEventReason +export type DrawerTriggerProps = BaseDrawer.Trigger.Props + +export function DrawerBackdrop({ + className, + ...props +}: BaseDrawer.Backdrop.Props) { + return ( + + ) +} + +export function DrawerViewport({ + className, + ...props +}: BaseDrawer.Viewport.Props) { + return ( + + ) +} + +export function DrawerPopup({ + className, + ...props +}: BaseDrawer.Popup.Props) { + return ( + + ) +} + +export function DrawerContent({ + className, + ...props +}: BaseDrawer.Content.Props) { + return ( + + ) +} + +type DrawerCloseButtonProps = Omit & { + children?: ReactNode +} + +export function DrawerCloseButton({ + className, + children, + type = 'button', + 'aria-label': ariaLabel = 'Close drawer', + ...props +}: DrawerCloseButtonProps) { + return ( + + {children ?? + ) +} diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index 9cf4772152..c0dd6da1c5 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({ })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => ( + WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index cbfd679ace..1fad833933 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -91,6 +91,21 @@ vi.mock('@/service/use-workflow', () => ({ useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, })) +vi.mock('@/service/use-tools', () => ({ + useWorkflowToolDetailByAppID: () => ({ + data: undefined, + isLoading: false, + }), + useInvalidateAllWorkflowTools: () => vi.fn(), + useInvalidateWorkflowToolDetailByAppID: () => vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), @@ -121,6 +136,15 @@ vi.mock('../../app-access-control', () => ({ ), })) +vi.mock('@/app/components/tools/workflow-tool', () => ({ + WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => ( +
+ workflow tool drawer + +
+ ), +})) + vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('../sections', () => ({ @@ -143,6 +167,7 @@ vi.mock('../sections', () => ({
+
) }, @@ -231,6 +256,25 @@ describe('AppPublisher', () => { expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument() }) + it('should keep workflow tool drawer mounted after closing the publish popover', () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + expect(screen.getByTestId('workflow-tool-drawer')).toBeInTheDocument() + }) + it('should close embedded and access control panels through child callbacks', async () => { render( { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} - handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} missingStartNode={false} - onRefreshData={vi.fn()} - outputs={[]} - published={true} publishedAt={Date.now()} toolPublished workflowToolAvailable={false} + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager workflowToolMessage="workflow-disabled" + onConfigureWorkflowTool={vi.fn()} />, ) @@ -223,17 +222,16 @@ describe('app-publisher sections', () => { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} - handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} missingStartNode - onRefreshData={vi.fn()} - outputs={[]} - published={false} publishedAt={Date.now()} toolPublished={false} workflowToolAvailable + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager + onConfigureWorkflowTool={vi.fn()} />, ) @@ -248,16 +246,16 @@ describe('app-publisher sections', () => { disabledFunctionButton={false} handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} - handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode - inputs={[]} missingStartNode={false} - outputs={[]} - published={false} publishedAt={undefined} toolPublished={false} workflowToolAvailable + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager + onConfigureWorkflowTool={vi.fn()} />, ) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index fe6fe5806f..a066233107 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -5,13 +5,12 @@ import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { RiStoreLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { memo, + use, useCallback, - useContext, useEffect, useMemo, useState, @@ -20,9 +19,12 @@ import { useTranslation } from 'react-i18next' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' +import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' +import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' +import { appDefaultIconBackground } from '@/config' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { AccessMode } from '@/models/access-control' @@ -57,8 +59,8 @@ export type AppPublisherProps = { debugWithMultipleModel?: boolean multipleModelConfigs?: ModelAndParameter[] /** modelAndParameter is passed when debugWithMultipleModel is true */ - onPublish?: (params?: any) => Promise | any - onRestore?: () => Promise | any + onPublish?: AppPublisherPublishHandler + onRestore?: AppPublisherRestoreHandler onToggle?: (state: boolean) => void crossAxisOffset?: number toolPublished?: boolean @@ -74,6 +76,12 @@ export type AppPublisherProps = { const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] +type AppPublisherPublishHandler + = | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise | unknown) + | ((params?: unknown) => Promise | unknown) + +type AppPublisherRestoreHandler = () => Promise | unknown + const AppPublisher = ({ disabled = false, publishDisabled = false, @@ -100,11 +108,12 @@ const AppPublisher = ({ const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) const [showAppAccessControl, setShowAppAccessControl] = useState(false) + const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) - const workflowStore = useContext(WorkflowContext) + const workflowStore = use(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -273,6 +282,31 @@ const AppPublisher = ({ const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined + const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode + const workflowToolPublished = !!toolPublished + const closeWorkflowToolDrawer = useCallback(() => setWorkflowToolDrawerOpen(false), []) + const workflowToolIcon = useMemo(() => ({ + content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', + background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground, + }), [appDetail?.icon, appDetail?.icon_background, appDetail?.icon_type]) + const workflowTool = useConfigureButton({ + enabled: workflowToolVisible, + published: workflowToolPublished, + detailNeedUpdate: workflowToolPublished && published, + workflowAppId: appDetail?.id ?? '', + icon: workflowToolIcon, + name: appDetail?.name ?? '', + description: appDetail?.description ?? '', + inputs, + outputs, + handlePublish, + onRefreshData, + onConfigured: closeWorkflowToolDrawer, + }) + const openWorkflowToolDrawer = useCallback(() => { + handleOpenChange(false) + setWorkflowToolDrawerOpen(true) + }, [handleOpenChange]) const upgradeHighlightStyle = useMemo(() => ({ background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', WebkitBackgroundClip: 'text', @@ -343,23 +377,22 @@ const AppPublisher = ({ handleOpenChange(false) handleOpenInExplore() }} - handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} - inputs={inputs} missingStartNode={missingStartNode} - onRefreshData={onRefreshData} - outputs={outputs} - published={published} publishedAt={publishedAt} toolPublished={toolPublished} workflowToolAvailable={workflowToolAvailable} + workflowToolIsLoading={workflowTool.isLoading} + workflowToolOutdated={workflowTool.outdated} + workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager} workflowToolMessage={workflowToolMessage} + onConfigureWorkflowTool={openWorkflowToolDrawer} /> {systemFeatures.enable_creators_platform && (
} + icon={} disabled={!publishedAt || publishingToMarketplace} onClick={handlePublishToMarketplace} > @@ -380,6 +413,15 @@ const AppPublisher = ({ /> {showAppAccessControl && { setShowAppAccessControl(false) }} />} + {workflowToolDrawerOpen && ( + + )} ) } diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index 57522095ae..36422e0055 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -10,11 +10,9 @@ import { } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' import Loading from '@/app/components/base/loading' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' -import { appDefaultIconBackground } from '@/config' import { AppModeEnum } from '@/types/app' import ShortcutsName from '../../workflow/shortcuts-name' import PublishWithMultipleModel from './publish-with-multiple-model' @@ -46,11 +44,8 @@ type AccessSectionProps = { type ActionsSectionProps = Pick & { appDetail: { @@ -67,9 +62,11 @@ type ActionsSectionProps = Pick void handleOpenInExplore: () => void - handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise - published: boolean + workflowToolIsLoading: boolean + workflowToolOutdated: boolean + workflowToolIsCurrentWorkspaceManager: boolean workflowToolMessage?: string + onConfigureWorkflowTool: () => void } export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => { @@ -256,18 +253,17 @@ export const PublisherActionsSection = ({ disabledFunctionTooltip, handleEmbed, handleOpenInExplore, - handlePublish, hasHumanInputNode = false, hasTriggerNode = false, - inputs, missingStartNode = false, - onRefreshData, - outputs, - published, publishedAt, toolPublished, workflowToolAvailable = true, + workflowToolIsLoading, + workflowToolOutdated, + workflowToolIsCurrentWorkspaceManager, workflowToolMessage, + onConfigureWorkflowTool, }: ActionsSectionProps) => { const { t } = useTranslation() @@ -305,7 +301,7 @@ export const PublisherActionsSection = ({ } + icon={} > {t('common.embedIntoSite', { ns: 'workflow' })} @@ -340,18 +336,10 @@ export const PublisherActionsSection = ({ )} diff --git a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx index a350e3f316..261ad1a280 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx @@ -54,22 +54,22 @@ const Operation: FC = ({ onOpenChange={setOpen} > } + render={( + + + + )} onClick={e => e.stopPropagation()} - > - - - - + /> { act(() => { capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver) + flushAnimationFrames() }) expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px') act(() => { capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver) + flushAnimationFrames() }) expect(screen.getByTestId('chat-footer').style.width).toBe('560px') diff --git a/web/app/components/base/chat/chat/use-chat-layout.ts b/web/app/components/base/chat/chat/use-chat-layout.ts index 712382070d..1983a928ca 100644 --- a/web/app/components/base/chat/chat/use-chat-layout.ts +++ b/web/app/components/base/chat/chat/use-chat-layout.ts @@ -12,6 +12,11 @@ type UseChatLayoutOptions = { sidebarCollapseState?: boolean } +const setStyleValue = (element: HTMLElement, property: 'paddingBottom' | 'width', value: string) => { + if (element.style[property] !== value) + element.style[property] = value +} + export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => { const [width, setWidth] = useState(0) const chatContainerRef = useRef(null) @@ -21,6 +26,9 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO const userScrolledRef = useRef(false) const isAutoScrollingRef = useRef(false) const prevFirstMessageIdRef = useRef(undefined) + const resizeObserverFrameRef = useRef(null) + const pendingFooterBlockSizeRef = useRef(null) + const pendingContainerInlineSizeRef = useRef(null) const handleScrollToBottom = useCallback(() => { if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { @@ -34,16 +42,39 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO }, [chatList.length]) const handleWindowResize = useCallback(() => { - if (chatContainerRef.current) - setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8) + if (chatContainerRef.current) { + const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8 + setWidth(currentWidth => currentWidth === nextWidth ? currentWidth : nextWidth) + } if (chatContainerRef.current && chatFooterRef.current) - chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` + setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`) if (chatContainerInnerRef.current && chatFooterInnerRef.current) - chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` + setStyleValue(chatFooterInnerRef.current, 'width', `${chatContainerInnerRef.current.clientWidth}px`) }, []) + const scheduleResizeObserverUpdate = useCallback(() => { + if (resizeObserverFrameRef.current !== null) + return + + resizeObserverFrameRef.current = requestAnimationFrame(() => { + resizeObserverFrameRef.current = null + + const footerBlockSize = pendingFooterBlockSizeRef.current + pendingFooterBlockSizeRef.current = null + if (footerBlockSize !== null && chatContainerRef.current) { + setStyleValue(chatContainerRef.current, 'paddingBottom', `${footerBlockSize}px`) + handleScrollToBottom() + } + + const containerInlineSize = pendingContainerInlineSizeRef.current + pendingContainerInlineSizeRef.current = null + if (containerInlineSize !== null && chatFooterRef.current) + setStyleValue(chatFooterRef.current, 'width', `${containerInlineSize}px`) + }) + }, [handleScrollToBottom]) + useEffect(() => { handleScrollToBottom() const animationFrame = requestAnimationFrame(handleWindowResize) @@ -77,26 +108,31 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO const resizeContainerObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { blockSize } = entry.borderBoxSize[0]! - chatContainerRef.current!.style.paddingBottom = `${blockSize}px` - handleScrollToBottom() + pendingFooterBlockSizeRef.current = blockSize } + scheduleResizeObserverUpdate() }) resizeContainerObserver.observe(chatFooterRef.current) const resizeFooterObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { inlineSize } = entry.borderBoxSize[0]! - chatFooterRef.current!.style.width = `${inlineSize}px` + pendingContainerInlineSizeRef.current = inlineSize } + scheduleResizeObserverUpdate() }) resizeFooterObserver.observe(chatContainerRef.current) return () => { + if (resizeObserverFrameRef.current !== null) { + cancelAnimationFrame(resizeObserverFrameRef.current) + resizeObserverFrameRef.current = null + } resizeContainerObserver.disconnect() resizeFooterObserver.disconnect() } } - }, [handleScrollToBottom]) + }, [scheduleResizeObserverUpdate]) useEffect(() => { const setUserScrolled = () => { diff --git a/web/app/components/base/icons/src/vender/line/development/index.ts b/web/app/components/base/icons/src/vender/line/development/index.ts index 7c3c48aa5e..4278370eec 100644 --- a/web/app/components/base/icons/src/vender/line/development/index.ts +++ b/web/app/components/base/icons/src/vender/line/development/index.ts @@ -1,2 +1 @@ export { default as BracketsX } from './BracketsX' -export { default as CodeBrowser } from './CodeBrowser' diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index dc0dd438ce..900c12a416 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -120,18 +120,12 @@ vi.mock('../document-title', () => ({ })) vi.mock('../segment-add', () => ({ - default: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => ( + SegmentAdd: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
), - ProcessStatus: { - WAITING: 'waiting', - PROCESSING: 'processing', - ERROR: 'error', - COMPLETED: 'completed', - }, })) vi.mock('../../components/operations', () => ({ diff --git a/web/app/components/datasets/documents/detail/batch-modal/index.tsx b/web/app/components/datasets/documents/detail/batch-modal/index.tsx index c0d9a58e98..4e190ef3fd 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/index.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/index.tsx @@ -2,12 +2,15 @@ import type { FC } from 'react' import type { ChunkingMode, FileItem } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' -import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import * as React from 'react' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import CSVDownloader from './csv-downloader' import CSVUploader from './csv-uploader' @@ -18,8 +21,9 @@ type IBatchModalProps = { onConfirm: (file: FileItem) => void } -const BatchModal: FC = ({ - isShow, +type BatchModalContentProps = Omit + +const BatchModalContent: FC = ({ docForm, onCancel, onConfirm, @@ -35,17 +39,13 @@ const BatchModal: FC = ({ onConfirm(currentCSV) } - useEffect(() => { - if (!isShow) - setCurrentCSV(undefined) - }, [isShow]) - return ( - -
{t('list.batchModal.title', { ns: 'datasetDocuments' })}
-
- -
+ + {t('list.batchModal.title', { ns: 'datasetDocuments' })} + = ({ {t('list.batchModal.run', { ns: 'datasetDocuments' })}
- + ) } + +const BatchModal: FC = ({ + isShow, + docForm, + onCancel, + onConfirm, +}) => { + return ( + !open && onCancel()} + disablePointerDismissal + > + {isShow + ? ( + + ) + : null} + + ) +} + export default React.memo(BatchModal) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx index f50b405c6f..900c974252 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx @@ -137,9 +137,8 @@ vi.mock('../hooks/use-child-segment-data', () => ({ }, })) -// Mock child components to simplify testing -vi.mock('../components', () => ({ - MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: { +vi.mock('../components/menu-bar', () => ({ + default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: { totalText: string onInputChange: (value: string) => void inputValue: string @@ -167,7 +166,13 @@ vi.mock('../components', () => ({ )}
), +})) + +vi.mock('../components/drawer-group', () => ({ DrawerGroup: () =>
, +})) + +vi.mock('../components/segment-list-content', () => ({ FullDocModeContent: () =>
, GeneralModeContent: () =>
, })) @@ -563,7 +568,7 @@ describe('Edge Cases', () => { expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument() }) - it('should handle ProcessStatus.COMPLETED importStatus', () => { + it('should handle completed importStatus', () => { render(, { wrapper: createWrapper() }) expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument() diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx index 4e17cd39b3..1f10053596 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode } from '@/models/datasets' -import SegmentDetail from '../segment-detail' +import { SegmentDetail } from '../segment-detail' // Mock dataset detail context let mockIndexingTechnique = IndexingType.QUALIFIED @@ -167,7 +167,6 @@ describe('SegmentDetail', () => { onCancel: vi.fn(), isEditMode: false, docForm: ChunkingMode.text, - onModalStateChange: vi.fn(), } describe('Rendering', () => { @@ -352,35 +351,12 @@ describe('SegmentDetail', () => { expect(screen.getByTestId('regeneration-modal'))!.toBeInTheDocument() }) - it('should call onModalStateChange when regeneration modal opens', () => { - const mockOnModalStateChange = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByTestId('regenerate-btn')) - - expect(mockOnModalStateChange).toHaveBeenCalledWith(true) - }) - it('should close modal when cancel is clicked', () => { - const mockOnModalStateChange = vi.fn() - render( - , - ) + render() fireEvent.click(screen.getByTestId('regenerate-btn')) fireEvent.click(screen.getByTestId('cancel-regeneration')) - expect(mockOnModalStateChange).toHaveBeenCalledWith(false) expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument() }) }) @@ -504,22 +480,18 @@ describe('SegmentDetail', () => { it('should close modal and edit drawer when close after regeneration is clicked', () => { const mockOnCancel = vi.fn() - const mockOnModalStateChange = vi.fn() render( , ) - // Open regeneration modal fireEvent.click(screen.getByTestId('regenerate-btn')) fireEvent.click(screen.getByTestId('close-regeneration')) - expect(mockOnModalStateChange).toHaveBeenCalledWith(false) expect(mockOnCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx index d9a87ea3e4..4ba28f3335 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx @@ -1,27 +1,16 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Drawer from '../drawer' +import { CompletedDrawer } from '../drawer' -let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined +( + globalThis as typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean + } +).BASE_UI_ANIMATIONS_DISABLED = true -// Mock useKeyPress: required because tests capture the registered callback -// and invoke it directly to verify ESC key handling behavior. -vi.mock('ahooks', () => ({ - useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => { - capturedKeyPressCallback = cb - }), -})) - -vi.mock('../..', () => ({ - useSegmentListContext: (selector: (state: { - currSegment: { showModal: boolean } - currChildChunk: { showModal: boolean } - }) => unknown) => - selector({ - currSegment: { showModal: false }, - currChildChunk: { showModal: false }, - }), -})) +const getOverlay = () => + Array.from(document.querySelectorAll('[class]')) + .find(element => element.className.includes('bg-background-overlay')) describe('Drawer', () => { const defaultProps = { @@ -31,103 +20,109 @@ describe('Drawer', () => { beforeEach(() => { vi.clearAllMocks() - capturedKeyPressCallback = undefined }) describe('Rendering', () => { it('should return null when open is false', () => { const { container } = render( - + Content - , + , ) expect(container.innerHTML).toBe('') expect(screen.queryByText('Content')).not.toBeInTheDocument() }) - it('should render children in portal when open is true', () => { + it('should render children in the drawer portal when open is true', () => { render( - + Drawer content - , + , ) expect(screen.getByText('Drawer content')).toBeInTheDocument() - }) - - it('should render dialog with role="dialog"', () => { - render( - - Content - , - ) - expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) - // Overlay visibility - describe('Overlay', () => { - it('should show overlay when showOverlay is true', () => { + describe('Variant', () => { + it('should render a panel drawer without overlay by default', () => { render( - + Content - , - ) - - const overlay = document.querySelector('[aria-hidden="true"]') - expect(overlay).toBeInTheDocument() - }) - - it('should hide overlay when showOverlay is false', () => { - render( - - Content - , - ) - - const overlay = document.querySelector('[aria-hidden="true"]') - expect(overlay).not.toBeInTheDocument() - }) - }) - - // aria-modal attribute - describe('aria-modal', () => { - it('should set aria-modal="true" when modal is true', () => { - render( - - Content - , - ) - - expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true') - }) - - it('should set aria-modal="false" when modal is false', () => { - render( - - Content - , + , ) + expect(getOverlay()).toBeUndefined() expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false') }) - }) - // ESC key handling - describe('ESC Key', () => { - it('should call onClose when ESC is pressed and drawer is open', () => { - const onClose = vi.fn() + it('should render a modal drawer with overlay', () => { render( - + Content - , + , ) - expect(capturedKeyPressCallback).toBeDefined() - const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent - capturedKeyPressCallback!(fakeEvent) + expect(getOverlay()).toBeInTheDocument() + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true') + }) + }) + + describe('Dismissal', () => { + it('should call onClose when Escape is pressed', async () => { + const onClose = vi.fn() + render( + + Content + , + ) + + fireEvent.keyDown(document, { key: 'Escape' }) + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + it('should keep a panel drawer open when the underlying page is clicked', () => { + const onClose = vi.fn() + render( + <> + + + Content + + , + ) + + fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' })) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('should keep a panel drawer open when the pointer down starts inside content', () => { + const onClose = vi.fn() + render( + + + , + ) + + fireEvent.pointerDown(screen.getByRole('button', { name: 'Inside' })) + + expect(onClose).not.toHaveBeenCalled() + }) + it('should close a modal drawer when the overlay is clicked', () => { + const onClose = vi.fn() + render( + + Content + , + ) + + fireEvent.click(getOverlay()!) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx index ae870c8e1c..6b6227492a 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx @@ -1,11 +1,11 @@ import type { ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import FullScreenDrawer from '../full-screen-drawer' +import { DocumentDetailDrawer } from '../full-screen-drawer' // Mock the Drawer component since it has high complexity vi.mock('../drawer', () => ({ - default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => { + CompletedDrawer: ({ children, open, panelClassName, panelContentClassName, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, modal: boolean }) => { if (!open) return null return ( @@ -13,8 +13,6 @@ vi.mock('../drawer', () => ({ data-testid="drawer-mock" data-panel-class={panelClassName} data-panel-content-class={panelContentClassName} - data-show-overlay={showOverlay} - data-need-check-chunks={needCheckChunks} data-modal={modal} > {children} @@ -23,7 +21,7 @@ vi.mock('../drawer', () => ({ }, })) -describe('FullScreenDrawer', () => { +describe('DocumentDetailDrawer', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -31,9 +29,9 @@ describe('FullScreenDrawer', () => { describe('Rendering', () => { it('should render without crashing when open', () => { render( - +
Content
-
, + , ) expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() @@ -41,9 +39,9 @@ describe('FullScreenDrawer', () => { it('should not render when closed', () => { render( - +
Content
-
, + , ) expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() @@ -51,9 +49,9 @@ describe('FullScreenDrawer', () => { it('should render children content', () => { render( - +
Test Content
-
, + , ) expect(screen.getByText('Test Content')).toBeInTheDocument() @@ -63,86 +61,46 @@ describe('FullScreenDrawer', () => { describe('Props', () => { it('should pass fullScreen=true to Drawer with full width class', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-full') + expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-full') + expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-full') }) it('should pass fullScreen=false to Drawer with fixed width class', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]') + expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-[568px]') + expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-[568px]') }) - it('should pass showOverlay prop with default true', () => { + it('should render as non-modal by default', () => { render( - +
Content
-
, - ) - - const drawer = screen.getByTestId('drawer-mock') - expect(drawer.getAttribute('data-show-overlay')).toBe('true') - }) - - it('should pass showOverlay=false when specified', () => { - render( - -
Content
-
, - ) - - const drawer = screen.getByTestId('drawer-mock') - expect(drawer.getAttribute('data-show-overlay')).toBe('false') - }) - - it('should pass needCheckChunks prop with default false', () => { - render( - -
Content
-
, - ) - - const drawer = screen.getByTestId('drawer-mock') - expect(drawer.getAttribute('data-need-check-chunks')).toBe('false') - }) - - it('should pass needCheckChunks=true when specified', () => { - render( - -
Content
-
, - ) - - const drawer = screen.getByTestId('drawer-mock') - expect(drawer.getAttribute('data-need-check-chunks')).toBe('true') - }) - - it('should pass modal prop with default false', () => { - render( - -
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-modal')).toBe('false') }) - it('should pass modal=true when specified', () => { + it('should pass modal when specified', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') @@ -154,9 +112,9 @@ describe('FullScreenDrawer', () => { describe('Styling', () => { it('should apply panel content classes for non-fullScreen mode', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') @@ -167,9 +125,9 @@ describe('FullScreenDrawer', () => { it('should apply panel content classes without border for fullScreen mode', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') @@ -184,24 +142,24 @@ describe('FullScreenDrawer', () => { // Arrange & Act & Assert - should not throw expect(() => { render( - +
Content
-
, + , ) }).not.toThrow() }) it('should maintain structure when rerendered', () => { const { rerender } = render( - +
Content
-
, + , ) rerender( - +
Updated Content
-
, + , ) expect(screen.getByText('Updated Content')).toBeInTheDocument() @@ -209,16 +167,16 @@ describe('FullScreenDrawer', () => { it('should handle toggle between open and closed states', () => { const { rerender } = render( - +
Content
-
, + , ) expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() rerender( - +
Content
-
, + , ) expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() diff --git a/web/app/components/datasets/documents/detail/completed/common/drawer.tsx b/web/app/components/datasets/documents/detail/completed/common/drawer.tsx index 47b381e0ff..86c40a7367 100644 --- a/web/app/components/datasets/documents/detail/completed/common/drawer.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/drawer.tsx @@ -1,143 +1,92 @@ +import type { ComponentProps, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { useKeyPress } from 'ahooks' -import * as React from 'react' -import { useCallback, useEffect, useRef } from 'react' -import { createPortal } from 'react-dom' -import { useSegmentListContext } from '..' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' -type DrawerProps = { +type DrawerSide = 'right' | 'left' | 'bottom' | 'top' +type DrawerSwipeDirection = 'right' | 'left' | 'down' | 'up' +type DrawerOpenChange = NonNullable['onOpenChange']> + +type CompletedDrawerProps = { open: boolean onClose: () => void - side?: 'right' | 'left' | 'bottom' | 'top' - showOverlay?: boolean - modal?: boolean // click outside event can pass through if modal is false - closeOnOutsideClick?: boolean + side?: DrawerSide panelClassName?: string panelContentClassName?: string - needCheckChunks?: boolean + modal?: boolean + children: ReactNode } -const SIDE_POSITION_CLASS = { - right: 'right-0', - left: 'left-0', - bottom: 'bottom-0', - top: 'top-0', -} as const - -function containsTarget(selector: string, target: Node | null): boolean { - const elements = document.querySelectorAll(selector) - return Array.from(elements).some(el => el?.contains(target)) +const SIDE_TO_SWIPE_DIRECTION: Record = { + right: 'right', + left: 'left', + bottom: 'down', + top: 'up', } -function shouldReopenChunkDetail( - isClickOnChunk: boolean, - isClickOnChildChunk: boolean, - segmentModalOpen: boolean, - childChunkModalOpen: boolean, -): boolean { - if (segmentModalOpen && isClickOnChildChunk) - return true - if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk) - return true - return !isClickOnChunk && !isClickOnChildChunk -} +const DRAWER_POPUP_CLASS_NAME = [ + 'pointer-events-auto overflow-visible border-0 bg-transparent shadow-none', + 'data-[swipe-direction=right]:h-screen data-[swipe-direction=right]:max-w-none data-[swipe-direction=right]:rounded-none data-[swipe-direction=right]:border-0', + 'data-[swipe-direction=left]:h-screen data-[swipe-direction=left]:max-w-none data-[swipe-direction=left]:rounded-none data-[swipe-direction=left]:border-0', + 'data-[swipe-direction=down]:max-h-none data-[swipe-direction=down]:rounded-none data-[swipe-direction=down]:border-0', + 'data-[swipe-direction=up]:max-h-none data-[swipe-direction=up]:rounded-none data-[swipe-direction=up]:border-0', +].join(' ') -const Drawer = ({ +export function CompletedDrawer({ open, onClose, side = 'right', - showOverlay = true, - modal = false, - needCheckChunks = false, children, panelClassName, panelContentClassName, -}: React.PropsWithChildren) => { - const panelContentRef = useRef(null) - const currSegment = useSegmentListContext(s => s.currSegment) - const currChildChunk = useSegmentListContext(s => s.currChildChunk) - - useKeyPress('esc', (e) => { - if (!open) + modal = false, +}: CompletedDrawerProps) { + const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => { + if (nextOpen) return - e.preventDefault() + + if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press') + return + onClose() - }, { exactMatch: true, useCapture: true }) - - const shouldCloseDrawer = useCallback((target: Node | null) => { - const panelContent = panelContentRef.current - if (!panelContent || !target) - return false - - if (panelContent.contains(target)) - return false - - if (containsTarget('.image-previewer', target)) - return false - - if (!needCheckChunks) - return true - - const isClickOnChunk = containsTarget('.chunk-card', target) - const isClickOnChildChunk = containsTarget('.child-chunk', target) - return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal) - }, [currSegment.showModal, currChildChunk.showModal, needCheckChunks]) - - const onDownCapture = useCallback((e: PointerEvent) => { - if (!open || modal) - return - const panelContent = panelContentRef.current - if (!panelContent) - return - const target = e.target as Node | null - if (shouldCloseDrawer(target)) - queueMicrotask(onClose) - }, [shouldCloseDrawer, onClose, open, modal]) - - useEffect(() => { - window.addEventListener('pointerdown', onDownCapture, { capture: true }) - return () => - window.removeEventListener('pointerdown', onDownCapture, { capture: true }) - }, [onDownCapture]) - - const isHorizontal = side === 'left' || side === 'right' - - const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none' - - const content = ( -
- {showOverlay && ( - - ) + } if (!open) return null - return createPortal(content, document.body) + return ( + + + {modal && ( + + )} + + + + {children} + + + + + + ) } - -export default Drawer diff --git a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx index 01cc264a76..28a0d89262 100644 --- a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx @@ -1,46 +1,39 @@ +import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { noop } from 'es-toolkit/function' -import * as React from 'react' -import Drawer from './drawer' +import { CompletedDrawer } from './drawer' -type IFullScreenDrawerProps = { - isOpen: boolean +type DocumentDetailDrawerProps = { + open: boolean onClose?: () => void fullScreen: boolean - showOverlay?: boolean - needCheckChunks?: boolean modal?: boolean + children: ReactNode } -const FullScreenDrawer = ({ - isOpen, +export function DocumentDetailDrawer({ + open, onClose = noop, fullScreen, children, - showOverlay = true, - needCheckChunks = false, modal = false, -}: React.PropsWithChildren) => { +}: DocumentDetailDrawerProps) { return ( - {children} - + ) } - -export default FullScreenDrawer diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx index dfcb02215c..4e3c935f0a 100644 --- a/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx @@ -2,16 +2,16 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import DrawerGroup from '../drawer-group' +import { DrawerGroup } from '../drawer-group' vi.mock('../../common/full-screen-drawer', () => ({ - default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => ( - isOpen ?
{children}
: null + DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => ( + open ?
{children}
: null ), })) vi.mock('../../segment-detail', () => ({ - default: () =>
, + SegmentDetail: () =>
, })) vi.mock('../../child-segment-detail', () => ({ @@ -31,8 +31,6 @@ describe('DrawerGroup', () => { currSegment: { segInfo: undefined, showModal: false, isEditMode: false }, onCloseSegmentDetail: vi.fn(), onUpdateSegment: vi.fn(), - isRegenerationModalOpen: false, - setIsRegenerationModalOpen: vi.fn(), showNewSegmentModal: false, onCloseNewSegmentModal: vi.fn(), onSaveNewSegment: vi.fn(), @@ -55,7 +53,7 @@ describe('DrawerGroup', () => { it('should render nothing when all modals are closed', () => { const { container } = render() - expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull() + expect(container.querySelector('[data-testid="document-detail-drawer"]')).toBeNull() }) it('should render segment detail when segment modal is open', () => { @@ -66,6 +64,7 @@ describe('DrawerGroup', () => { />, ) expect(screen.getByTestId('segment-detail')).toBeInTheDocument() + expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false') }) it('should render new segment modal when showNewSegmentModal is true', () => { @@ -73,6 +72,7 @@ describe('DrawerGroup', () => { , ) expect(screen.getByTestId('new-segment')).toBeInTheDocument() + expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true') }) it('should render child segment detail when child chunk modal is open', () => { @@ -83,6 +83,7 @@ describe('DrawerGroup', () => { />, ) expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument() + expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false') }) it('should render new child segment modal when showNewChildSegmentModal is true', () => { @@ -90,6 +91,7 @@ describe('DrawerGroup', () => { , ) expect(screen.getByTestId('new-child-segment')).toBeInTheDocument() + expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true') }) it('should render multiple drawers simultaneously', () => { diff --git a/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx b/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx index 04f993b98c..d79433e0ff 100644 --- a/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx @@ -1,15 +1,13 @@ 'use client' -import type { FC } from 'react' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets' import NewSegment from '@/app/components/datasets/documents/detail/new-segment' import ChildSegmentDetail from '../child-segment-detail' -import FullScreenDrawer from '../common/full-screen-drawer' +import { DocumentDetailDrawer } from '../common/full-screen-drawer' import NewChildSegment from '../new-child-segment' -import SegmentDetail from '../segment-detail' +import { SegmentDetail } from '../segment-detail' type DrawerGroupProps = { - // Segment detail drawer currSegment: { segInfo?: SegmentDetailModel showModal: boolean @@ -25,14 +23,10 @@ type DrawerGroupProps = { summary?: string, needRegenerate?: boolean, ) => Promise - isRegenerationModalOpen: boolean - setIsRegenerationModalOpen: (open: boolean) => void - // New segment drawer showNewSegmentModal: boolean onCloseNewSegmentModal: () => void onSaveNewSegment: () => void viewNewlyAddedChunk: () => void - // Child segment detail drawer currChildChunk: { childChunkInfo?: ChildChunkDetail showModal: boolean @@ -40,52 +34,39 @@ type DrawerGroupProps = { currChunkId: string onCloseChildSegmentDetail: () => void onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise - // New child segment drawer showNewChildSegmentModal: boolean onCloseNewChildChunkModal: () => void onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void viewNewlyAddedChildChunk: () => void - // Common props fullScreen: boolean docForm: ChunkingMode } -const DrawerGroup: FC = ({ - // Segment detail drawer +export function DrawerGroup({ currSegment, onCloseSegmentDetail, onUpdateSegment, - isRegenerationModalOpen, - setIsRegenerationModalOpen, - // New segment drawer showNewSegmentModal, onCloseNewSegmentModal, onSaveNewSegment, viewNewlyAddedChunk, - // Child segment detail drawer currChildChunk, currChunkId, onCloseChildSegmentDetail, onUpdateChildChunk, - // New child segment drawer showNewChildSegmentModal, onCloseNewChildChunkModal, onSaveNewChildChunk, viewNewlyAddedChildChunk, - // Common props fullScreen, docForm, -}) => { +}: DrawerGroupProps) { return ( <> - {/* Edit or view segment detail */} - = ({ isEditMode={currSegment.isEditMode} onUpdate={onUpdateSegment} onCancel={onCloseSegmentDetail} - onModalStateChange={setIsRegenerationModalOpen} /> - + - {/* Create New Segment */} - = ({ onSave={onSaveNewSegment} viewNewlyAddedChunk={viewNewlyAddedChunk} /> - + - {/* Edit or view child segment detail */} - = ({ onUpdate={onUpdateChildChunk} onCancel={onCloseChildSegmentDetail} /> - + - {/* Create New Child Segment */} - = ({ onSave={onSaveNewChildChunk} viewNewlyAddedChildChunk={viewNewlyAddedChildChunk} /> - + ) } - -export default DrawerGroup diff --git a/web/app/components/datasets/documents/detail/completed/components/index.ts b/web/app/components/datasets/documents/detail/completed/components/index.ts deleted file mode 100644 index 67bd6ae643..0000000000 --- a/web/app/components/datasets/documents/detail/completed/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as DrawerGroup } from './drawer-group' -export { default as MenuBar } from './menu-bar' -export { FullDocModeContent, GeneralModeContent } from './segment-list-content' diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts index 57e7ae5d5e..0f887083c2 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts @@ -1,7 +1,9 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useModalState } from '../use-modal-state' +import * as modalStateHooks from '../use-modal-state' + +const renderDatasetModalState = modalStateHooks.useModalState describe('useModalState', () => { const onNewSegmentModalChange = vi.fn() @@ -10,22 +12,21 @@ describe('useModalState', () => { vi.clearAllMocks() }) - const renderUseModalState = () => - renderHook(() => useModalState({ onNewSegmentModalChange })) + const renderModalState = () => + renderHook(() => renderDatasetModalState({ onNewSegmentModalChange })) it('should initialize with all modals closed', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() expect(result.current.currSegment.showModal).toBe(false) expect(result.current.currChildChunk.showModal).toBe(false) expect(result.current.showNewChildSegmentModal).toBe(false) - expect(result.current.isRegenerationModalOpen).toBe(false) expect(result.current.fullScreen).toBe(false) expect(result.current.isCollapsed).toBe(true) }) it('should open segment detail on card click', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel act(() => { @@ -37,8 +38,25 @@ describe('useModalState', () => { expect(result.current.currSegment.isEditMode).toBe(true) }) + it('should close child detail when opening segment detail', () => { + const { result } = renderModalState() + const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail + const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel + + act(() => { + result.current.onClickSlice(childDetail) + }) + act(() => { + result.current.onClickCard(segmentDetail) + }) + + expect(result.current.currSegment.showModal).toBe(true) + expect(result.current.currSegment.segInfo).toBe(segmentDetail) + expect(result.current.currChildChunk.showModal).toBe(false) + }) + it('should close segment detail and reset fullscreen', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel) @@ -55,7 +73,7 @@ describe('useModalState', () => { }) it('should open child segment detail on slice click', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail act(() => { @@ -67,8 +85,25 @@ describe('useModalState', () => { expect(result.current.currChunkId).toBe('seg-1') }) + it('should close segment detail when opening child detail', () => { + const { result } = renderModalState() + const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel + const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail + + act(() => { + result.current.onClickCard(segmentDetail) + }) + act(() => { + result.current.onClickSlice(childDetail) + }) + + expect(result.current.currSegment.showModal).toBe(false) + expect(result.current.currChildChunk.showModal).toBe(true) + expect(result.current.currChildChunk.childChunkInfo).toBe(childDetail) + }) + it('should close child segment detail', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail) @@ -81,7 +116,7 @@ describe('useModalState', () => { }) it('should handle new child chunk modal', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.handleAddNewChildChunk('parent-chunk-1') @@ -98,7 +133,7 @@ describe('useModalState', () => { }) it('should close new segment modal and notify parent', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.onCloseNewSegmentModal() @@ -108,7 +143,7 @@ describe('useModalState', () => { }) it('should toggle full screen', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.toggleFullScreen() @@ -122,7 +157,7 @@ describe('useModalState', () => { }) it('should toggle collapsed', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.toggleCollapsed() @@ -134,13 +169,4 @@ describe('useModalState', () => { }) expect(result.current.isCollapsed).toBe(true) }) - - it('should set regeneration modal state', () => { - const { result } = renderUseModalState() - - act(() => { - result.current.setIsRegenerationModalOpen(true) - }) - expect(result.current.isRegenerationModalOpen).toBe(true) - }) }) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts index 5b8f8d7e53..5616af241d 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts @@ -1,11 +1,12 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets' +import type { SegmentImportStatus } from '@/types/dataset' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import * as React from 'react' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' -import { ProcessStatus } from '../../../segment-add' +import { segmentImportStatus } from '@/types/dataset' import { useSegmentListData } from '../use-segment-list-data' // Type for mutation callbacks @@ -176,7 +177,7 @@ const defaultOptions = { searchValue: '', selectedStatus: 'all' as boolean | 'all', selectedSegmentIds: [] as string[], - importStatus: undefined as ProcessStatus | string | undefined, + importStatus: undefined as SegmentImportStatus | undefined, currentPage: 1, limit: 10, onCloseSegmentDetail: vi.fn(), @@ -689,7 +690,7 @@ describe('useSegmentListData', () => { renderHook(() => useSegmentListData({ ...defaultOptions, - importStatus: ProcessStatus.COMPLETED, + importStatus: segmentImportStatus.completed, clearSelection, }), { wrapper: createWrapper(), diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts index fa314bec25..71e21a5a80 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts @@ -13,29 +13,20 @@ type CurrChildChunkType = { } type UseModalStateReturn = { - // Segment detail modal currSegment: CurrSegmentType onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void onCloseSegmentDetail: () => void - // Child segment detail modal currChildChunk: CurrChildChunkType currChunkId: string onClickSlice: (detail: ChildChunkDetail) => void onCloseChildSegmentDetail: () => void - // New segment modal onCloseNewSegmentModal: () => void - // New child segment modal showNewChildSegmentModal: boolean handleAddNewChildChunk: (parentChunkId: string) => void onCloseNewChildChunkModal: () => void - // Regeneration modal - isRegenerationModalOpen: boolean - setIsRegenerationModalOpen: (open: boolean) => void - // Full screen fullScreen: boolean toggleFullScreen: () => void setFullScreen: (fullScreen: boolean) => void - // Collapsed state isCollapsed: boolean toggleCollapsed: () => void } @@ -47,25 +38,15 @@ type UseModalStateOptions = { export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => { const { onNewSegmentModalChange } = options - // Segment detail modal state const [currSegment, setCurrSegment] = useState({ showModal: false }) - - // Child segment detail modal state const [currChildChunk, setCurrChildChunk] = useState({ showModal: false }) const [currChunkId, setCurrChunkId] = useState('') - - // New child segment modal state const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false) - - // Regeneration modal state - const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false) - - // Display state const [fullScreen, setFullScreen] = useState(false) const [isCollapsed, setIsCollapsed] = useState(true) - // Segment detail handlers const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => { + setCurrChildChunk({ showModal: false }) setCurrSegment({ segInfo: detail, showModal: true, isEditMode }) }, []) @@ -74,8 +55,8 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur setFullScreen(false) }, []) - // Child segment detail handlers const onClickSlice = useCallback((detail: ChildChunkDetail) => { + setCurrSegment({ showModal: false }) setCurrChildChunk({ childChunkInfo: detail, showModal: true }) setCurrChunkId(detail.segment_id) }, []) @@ -85,13 +66,11 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur setFullScreen(false) }, []) - // New segment modal handlers const onCloseNewSegmentModal = useCallback(() => { onNewSegmentModalChange(false) setFullScreen(false) }, [onNewSegmentModalChange]) - // New child segment modal handlers const handleAddNewChildChunk = useCallback((parentChunkId: string) => { setShowNewChildSegmentModal(true) setCurrChunkId(parentChunkId) @@ -102,7 +81,6 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur setFullScreen(false) }, []) - // Display handlers - handles both direct calls and click events const toggleFullScreen = useCallback(() => { setFullScreen(prev => !prev) }, []) @@ -112,29 +90,20 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur }, []) return { - // Segment detail modal currSegment, onClickCard, onCloseSegmentDetail, - // Child segment detail modal currChildChunk, currChunkId, onClickSlice, onCloseChildSegmentDetail, - // New segment modal onCloseNewSegmentModal, - // New child segment modal showNewChildSegmentModal, handleAddNewChildChunk, onCloseNewChildChunkModal, - // Regeneration modal - isRegenerationModalOpen, - setIsRegenerationModalOpen, - // Full screen fullScreen, toggleFullScreen, setFullScreen, - // Collapsed state isCollapsed, toggleCollapsed, } diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts index 1c55d12d15..22bdecccb4 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts @@ -1,5 +1,6 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets' +import type { SegmentImportStatus } from '@/types/dataset' import { toast } from '@langgenius/dify-ui/toast' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -9,16 +10,16 @@ import { ChunkingMode } from '@/models/datasets' import { usePathname } from '@/next/navigation' import { useChunkListAllKey, useChunkListDisabledKey, useChunkListEnabledKey, useDeleteSegment, useDisableSegment, useEnableSegment, useSegmentList, useSegmentListKey, useUpdateSegment } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' +import { segmentImportStatus } from '@/types/dataset' import { formatNumber } from '@/utils/format' import { useDocumentContext } from '../../context' -import { ProcessStatus } from '../../segment-add' const DEFAULT_LIMIT = 10 type UseSegmentListDataOptions = { searchValue: string selectedStatus: boolean | 'all' selectedSegmentIds: string[] - importStatus: ProcessStatus | string | undefined + importStatus: SegmentImportStatus | undefined currentPage: number limit: number onCloseSegmentDetail: () => void @@ -92,7 +93,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme }, [pathname]) // Reset list on import completion useEffect(() => { - if (importStatus === ProcessStatus.COMPLETED) { + if (importStatus === segmentImportStatus.completed) { clearSelection() invalidSegmentList() } diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx index 0251919e26..d38dbb3bfe 100644 --- a/web/app/components/datasets/documents/detail/completed/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' -import type { ProcessStatus } from '../segment-add' import type { SegmentListContextValue } from './segment-list-context' +import type { SegmentImportStatus } from '@/types/dataset' import { useCallback, useMemo, useState } from 'react' import Divider from '@/app/components/base/divider' import Pagination from '@/app/components/base/pagination' @@ -13,7 +13,9 @@ import { import { useInvalid } from '@/service/use-base' import { useDocumentContext } from '../context' import BatchAction from './common/batch-action' -import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components' +import { DrawerGroup } from './components/drawer-group' +import MenuBar from './components/menu-bar' +import { FullDocModeContent, GeneralModeContent } from './components/segment-list-content' import { useChildSegmentData, useModalState, @@ -32,7 +34,7 @@ type ICompletedProps = { embeddingAvailable: boolean showNewSegmentModal: boolean onNewSegmentModalChange: (state: boolean) => void - importStatus: ProcessStatus | string | undefined + importStatus: SegmentImportStatus | undefined archived?: boolean } @@ -225,8 +227,6 @@ const Completed: FC = ({ currSegment={modalState.currSegment} onCloseSegmentDetail={modalState.onCloseSegmentDetail} onUpdateSegment={segmentListDataHook.handleUpdateSegment} - isRegenerationModalOpen={modalState.isRegenerationModalOpen} - setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen} showNewSegmentModal={showNewSegmentModal} onCloseNewSegmentModal={modalState.onCloseNewSegmentModal} onSaveNewSegment={segmentListDataHook.resetList} diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx index 91174c1bf6..fad94819b0 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx @@ -1,4 +1,3 @@ -import type { FC } from 'react' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { SegmentDetailModel } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' @@ -7,7 +6,6 @@ import { RiCollapseDiagonalLine, RiExpandDiagonalLine, } from '@remixicon/react' -import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' @@ -42,20 +40,15 @@ type ISegmentDetailProps = { onCancel: () => void isEditMode?: boolean docForm: ChunkingMode - onModalStateChange?: (isOpen: boolean) => void } -/** - * Show all the contents of the segment - */ -const SegmentDetail: FC = ({ +export function SegmentDetail({ segInfo, onUpdate, onCancel, isEditMode, docForm, - onModalStateChange, -}) => { +}: ISegmentDetailProps) { const { t } = useTranslation() const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '') const [answer, setAnswer] = useState(segInfo?.answer || '') @@ -99,19 +92,16 @@ const SegmentDetail: FC = ({ const handleRegeneration = useCallback(() => { setShowRegenerationModal(true) - onModalStateChange?.(true) - }, [onModalStateChange]) + }, []) const onCancelRegeneration = useCallback(() => { setShowRegenerationModal(false) - onModalStateChange?.(false) - }, [onModalStateChange]) + }, []) const onCloseAfterRegeneration = useCallback(() => { setShowRegenerationModal(false) - onModalStateChange?.(false) - onCancel() // Close the edit drawer - }, [onCancel, onModalStateChange]) + onCancel() + }, [onCancel]) const onConfirmRegeneration = useCallback(() => { onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true) @@ -241,5 +231,3 @@ const SegmentDetail: FC = ({
) } - -export default React.memo(SegmentDetail) diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 8a684a4e44..caae703f6b 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets' +import type { SegmentImportStatus } from '@/types/dataset' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' @@ -17,6 +18,7 @@ import { useRouter, useSearchParams } from '@/next/navigation' import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' +import { segmentImportStatus } from '@/types/dataset' import Operations from '../components/operations' import StatusItem from '../status-item' import BatchModal from './batch-modal' @@ -24,7 +26,7 @@ import Completed from './completed' import { DocumentContext } from './context' import { DocumentTitle } from './document-title' import Embedding from './embedding' -import SegmentAdd, { ProcessStatus } from './segment-add' +import { SegmentAdd } from './segment-add' import style from './style.module.css' type DocumentDetailProps = { @@ -53,20 +55,20 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { const [showMetadata, setShowMetadata] = useState(!isMobile) const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false) const [batchModalVisible, setBatchModalVisible] = useState(false) - const [importStatus, setImportStatus] = useState() + const [importStatus, setImportStatus] = useState() const showNewSegmentModal = () => setNewSegmentModalVisible(true) const showBatchModal = () => setBatchModalVisible(true) const hideBatchModal = () => setBatchModalVisible(false) - const resetProcessStatus = () => setImportStatus('') + const resetImportStatus = () => setImportStatus(undefined) const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress() const checkProcess = async (jobID: string) => { await checkSegmentBatchImportProgress({ jobID }, { onSuccess: (res) => { setImportStatus(res.job_status) - if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING) + if (res.job_status === segmentImportStatus.waiting || res.job_status === segmentImportStatus.processing) setTimeout(() => checkProcess(res.job_id), 2500) - if (res.job_status === ProcessStatus.ERROR) + if (res.job_status === segmentImportStatus.error) toast.error(`${t('list.batchModal.runError', { ns: 'datasetDocuments' })}`) }, onError: (e) => { @@ -222,7 +224,7 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { <> { }) const defaultProps = { - importStatus: undefined as ProcessStatus | string | undefined, - clearProcessStatus: vi.fn(), + importStatus: undefined as SegmentImportStatus | undefined, + clearImportStatus: vi.fn(), showNewSegmentModal: vi.fn(), showBatchModal: vi.fn(), embedding: false, @@ -52,33 +54,33 @@ describe('SegmentAdd', () => { // Import Status displays describe('Import Status Display', () => { it('should show processing indicator when status is WAITING', () => { - render() + render() expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() }) it('should show processing indicator when status is PROCESSING', () => { - render() + render() expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() }) it('should show completed status with ok button', () => { - render() + render() expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() }) it('should show error status with ok button', () => { - render() + render() expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() }) it('should not show add button when importStatus is set', () => { - render() + render() expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument() }) @@ -94,34 +96,34 @@ describe('SegmentAdd', () => { expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1) }) - it('should call clearProcessStatus when ok is clicked on completed status', () => { - const mockClearProcessStatus = vi.fn() + it('should call clearImportStatus when ok is clicked on completed status', () => { + const mockClearImportStatus = vi.fn() render( , ) fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) - expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) + expect(mockClearImportStatus).toHaveBeenCalledTimes(1) }) - it('should call clearProcessStatus when ok is clicked on error status', () => { - const mockClearProcessStatus = vi.fn() + it('should call clearImportStatus when ok is clicked on error status', () => { + const mockClearImportStatus = vi.fn() render( , ) fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) - expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) + expect(mockClearImportStatus).toHaveBeenCalledTimes(1) }) it('should render batch add option in dropdown', async () => { @@ -215,14 +217,14 @@ describe('SegmentAdd', () => { // Progress bar width tests describe('Progress Bar', () => { it('should show 3/12 width progress bar for WAITING status', () => { - const { container } = render() + const { container } = render() const progressBar = container.querySelector('.w-3\\/12') expect(progressBar).toBeInTheDocument() }) it('should show 2/3 width progress bar for PROCESSING status', () => { - const { container } = render() + const { container } = render() const progressBar = container.querySelector('.w-2\\/3') expect(progressBar).toBeInTheDocument() @@ -230,15 +232,6 @@ describe('SegmentAdd', () => { }) describe('Edge Cases', () => { - it('should handle unknown importStatus string', () => { - // Arrange & Act - pass unknown status - const { container } = render() - - // Assert - empty fragment is rendered for unknown status (container exists but has no visible content) - expect(container).toBeInTheDocument() - expect(container.textContent).toBe('') - }) - it('should maintain structure when rerendered', () => { const { rerender } = render() diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index 5ee0a2bcb3..da4a0109c0 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { FC } from 'react' +import type { SegmentImportStatus } from '@/types/dataset' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -7,95 +7,92 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' import { useProviderContext } from '@/context/provider-context' +import { segmentImportStatus } from '@/types/dataset' -type ISegmentAddProps = { - importStatus: ProcessStatus | string | undefined - clearProcessStatus: () => void +type SegmentAddProps = { + importStatus: SegmentImportStatus | undefined + clearImportStatus: () => void showNewSegmentModal: () => void showBatchModal: () => void embedding: boolean } -export enum ProcessStatus { - WAITING = 'waiting', - PROCESSING = 'processing', - COMPLETED = 'completed', - ERROR = 'error', -} - -const SegmentAdd: FC = ({ +export function SegmentAdd({ importStatus, - clearProcessStatus, + clearImportStatus, showNewSegmentModal, showBatchModal, embedding, -}) => { +}: SegmentAddProps) { const { t } = useTranslation() - const [isShowPlanUpgradeModal, { - setTrue: showPlanUpgradeModal, - setFalse: hidePlanUpgradeModal, - }] = useBoolean(false) - const { plan, enableBilling } = useProviderContext() - const { type } = plan - const canAdd = enableBilling ? type !== Plan.sandbox : true const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false) + const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false) const batchMenuAnchorRef = useRef(null) + const { plan, enableBilling } = useProviderContext() + const canAddChunks = !enableBilling || plan.type !== Plan.sandbox - const withNeedUpgradeCheck = useCallback((fn: () => void) => { - return () => { - if (!canAdd) { - showPlanUpgradeModal() - return - } - fn() + const textColor = embedding + ? 'text-components-button-secondary-accent-text-disabled' + : 'text-components-button-secondary-accent-text' + + const handleAddClick = () => { + if (!canAddChunks) { + setIsPlanUpgradeModalOpen(true) + return } - }, [canAdd, showPlanUpgradeModal]) - const textColor = useMemo(() => { - return embedding - ? 'text-components-button-secondary-accent-text-disabled' - : 'text-components-button-secondary-accent-text' - }, [embedding]) + + showNewSegmentModal() + } + + const handleBatchAddClick = () => { + setIsBatchMenuOpen(false) + + if (!canAddChunks) { + setIsPlanUpgradeModalOpen(true) + return + } + + showBatchModal() + } if (importStatus) { return ( <> - {(importStatus === ProcessStatus.WAITING || importStatus === ProcessStatus.PROCESSING) && ( + {(importStatus === segmentImportStatus.waiting || importStatus === segmentImportStatus.processing) && (
-
+
{t('list.batchModal.processing', { ns: 'datasetDocuments' })}
)} - {importStatus === ProcessStatus.COMPLETED && ( + {importStatus === segmentImportStatus.completed && (
{t('list.batchModal.completed', { ns: 'datasetDocuments' })}
- {t('list.batchModal.ok', { ns: 'datasetDocuments' })} + {t('list.batchModal.ok', { ns: 'datasetDocuments' })}
)} - {importStatus === ProcessStatus.ERROR && ( + {importStatus === segmentImportStatus.error && (
{t('list.batchModal.error', { ns: 'datasetDocuments' })}
- {t('list.batchModal.ok', { ns: 'datasetDocuments' })} + {t('list.batchModal.ok', { ns: 'datasetDocuments' })}
@@ -116,7 +113,7 @@ const SegmentAdd: FC = ({ type="button" className={`inline-flex items-center rounded-l-lg border-r border-r-divider-subtle px-2.5 py-2 hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`} - onClick={withNeedUpgradeCheck(showNewSegmentModal)} + onClick={handleAddClick} disabled={embedding} > @@ -142,25 +139,20 @@ const SegmentAdd: FC = ({ placement="bottom-start" sideOffset={4} positionerProps={{ anchor: batchMenuAnchorRef }} - popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-0 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]" + popupClassName="w-[var(--anchor-width)]" > -
- { - setIsBatchMenuOpen(false) - withNeedUpgradeCheck(showBatchModal)() - }} - > - {t('list.action.batchAdd', { ns: 'datasetDocuments' })} - -
+ + {t('list.action.batchAdd', { ns: 'datasetDocuments' })} + - {isShowPlanUpgradeModal && ( + {isPlanUpgradeModalOpen && ( setIsPlanUpgradeModalOpen(false)} title={t('upgrade.addChunks.title', { ns: 'billing' })!} description={t('upgrade.addChunks.description', { ns: 'billing' })!} /> @@ -169,4 +161,3 @@ const SegmentAdd: FC = ({ ) } -export default React.memo(SegmentAdd) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx index ad99f7ce8c..0cc374d113 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -55,10 +55,6 @@ vi.mock('../../../readme-panel/entrance', () => ({ ReadmeEntrance: () =>
, })) -vi.mock('../../../readme-panel/store', () => ({ - ReadmeShowType: { modal: 'modal' }, -})) - vi.mock('@/app/components/base/encrypted-bottom', () => ({ EncryptedBottom: () =>
, })) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx index 58bbd441ce..7509090be3 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx @@ -41,10 +41,6 @@ vi.mock('../../../readme-panel/entrance', () => ({ ReadmeEntrance: () =>
, })) -vi.mock('../../../readme-panel/store', () => ({ - ReadmeShowType: { modal: 'modal' }, -})) - vi.mock('@/app/components/base/form/form-scenarios/auth', () => { const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref } & Record) => { mockAuthFormProps = props diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx index e01886ccde..3a7e495a77 100644 --- a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -19,7 +19,6 @@ import AuthForm from '@/app/components/base/form/form-scenarios/auth' import { FormTypeEnum } from '@/app/components/base/form/types' import Loading from '@/app/components/base/loading' import { ReadmeEntrance } from '../../readme-panel/entrance' -import { ReadmeShowType } from '../../readme-panel/store' import { useAddPluginCredentialHook, useGetPluginCredentialSchemaHook, @@ -159,7 +158,7 @@ const ApiKeyModal = ({
{pluginPayload.detail && ( - + )} { isLoading && ( diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx index 50718d50db..a3bd35a865 100644 --- a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -19,7 +19,6 @@ import { import { useTranslation } from 'react-i18next' import AuthForm from '@/app/components/base/form/form-scenarios/auth' import { ReadmeEntrance } from '../../readme-panel/entrance' -import { ReadmeShowType } from '../../readme-panel/store' import { useDeletePluginOAuthCustomClientHook, useInvalidPluginOAuthClientSchemaHook, @@ -157,7 +156,7 @@ const OAuthClientSettings = ({
{pluginPayload.detail && ( - + )}
{pluginDetail && ( - + )} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx index 774eaa9fe9..a7b5c9f2c0 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx @@ -12,7 +12,6 @@ import { BaseForm } from '@/app/components/base/form/components/base' import { FormTypeEnum } from '@/app/components/base/form/types' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { useUpdateTriggerSubscription } from '@/service/use-triggers' -import { ReadmeShowType } from '../../../readme-panel/store' import { usePluginStore } from '../../store' import { useSubscriptionList } from '../use-subscription-list' @@ -159,7 +158,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props)
{pluginDetail && ( - + )}
{pluginDetail && ( - + )} ({ - cn: (...args: unknown[]) => args.filter(Boolean).join(' '), -})) - -const mockSetCurrentPluginDetail = vi.fn() - -vi.mock('../store', () => ({ - ReadmeShowType: { drawer: 'drawer', side: 'side', modal: 'modal' }, - useReadmePanelStore: () => ({ - setCurrentPluginDetail: mockSetCurrentPluginDetail, - }), -})) - -vi.mock('../constants', () => ({ - BUILTIN_TOOLS_ARRAY: ['google_search', 'bing_search'], -})) +import { beforeEach, describe, expect, it } from 'vitest' +import { ReadmeEntrance } from '../entrance' +import { useReadmePanelStore } from '../store' describe('ReadmeEntrance', () => { - let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance'] - - beforeEach(async () => { - vi.clearAllMocks() - const mod = await import('../entrance') - ReadmeEntrance = mod.ReadmeEntrance + beforeEach(() => { + useReadmePanelStore.setState({ currentPanel: undefined }) }) it('should render readme button for non-builtin plugin with unique identifier', () => { @@ -35,18 +15,31 @@ describe('ReadmeEntrance', () => { expect(screen.getByRole('button')).toBeInTheDocument() }) - it('should call setCurrentPluginDetail on button click', () => { + it('should open drawer presentation by default', () => { const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never render() const button = screen.getByRole('button') fireEvent.click(button) - expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer') + expect(useReadmePanelStore.getState().currentPanel).toEqual({ + detail: pluginDetail, + presentation: 'drawer', + triggerId: button.id, + }) + }) + + it('should open dialog presentation when requested', () => { + const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never + render() + + fireEvent.click(screen.getByRole('button')) + + expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog') }) it('should return null for builtin tools', () => { - const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never + const pluginDetail = { id: 'code', name: 'Code', plugin_unique_identifier: 'org/code' } as never const { container } = render() expect(container.innerHTML).toBe('') diff --git a/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx index d52a22cb61..433ac011c5 100644 --- a/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx +++ b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx @@ -1,29 +1,29 @@ +import type { ReactElement } from 'react' import type { PluginDetail } from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum, PluginSource } from '../../types' import { ReadmeEntrance } from '../entrance' import ReadmePanel from '../index' -import { ReadmeShowType, useReadmePanelStore } from '../store' +import { useReadmePanelStore } from '../store' -// ================================ -// Mock external dependencies only -// ================================ +( + globalThis as typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean + } +).BASE_UI_ANIMATIONS_DISABLED = true -// Mock usePluginReadme hook const mockUsePluginReadme = vi.fn() vi.mock('@/service/use-plugins', () => ({ usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params), })) -// Mock useLanguage hook let mockLanguage = 'en-US' vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => mockLanguage, })) -// Mock DetailHeader component (complex component with many dependencies) vi.mock('../../plugin-detail-panel/detail-header', () => ({ default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => (
@@ -32,10 +32,6 @@ vi.mock('../../plugin-detail-panel/detail-header', () => ({ ), })) -// ================================ -// Test Data Factories -// ================================ - const createMockPluginDetail = (overrides: Partial = {}): PluginDetail => ({ id: 'test-plugin-id', created_at: '2024-01-01T00:00:00Z', @@ -93,10 +89,6 @@ const createMockPluginDetail = (overrides: Partial = {}): PluginDe ...overrides, }) -// ================================ -// Test Utilities -// ================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { @@ -105,7 +97,7 @@ const createQueryClient = () => new QueryClient({ }, }) -const renderWithQueryClient = (ui: React.ReactElement) => { +const renderWithQueryClient = (ui: ReactElement) => { const queryClient = createQueryClient() return render( @@ -114,15 +106,23 @@ const renderWithQueryClient = (ui: React.ReactElement) => { ) } -// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts -// Store (useReadmePanelStore) tests moved to store.spec.ts -// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx +const openReadmePanel = ( + detail = createMockPluginDetail(), + presentation: 'drawer' | 'dialog' = 'drawer', +) => { + useReadmePanelStore.getState().openReadmePanel({ + detail, + presentation, + triggerId: 'readme-trigger', + }) + return detail +} -// ================================ -// ReadmePanel Component Tests -// ================================ describe('ReadmePanel', () => { beforeEach(() => { + vi.clearAllMocks() + mockLanguage = 'en-US' + useReadmePanelStore.setState({ currentPanel: undefined }) mockUsePluginReadme.mockReturnValue({ data: null, isLoading: false, @@ -130,487 +130,114 @@ describe('ReadmePanel', () => { }) }) - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should return null when no plugin detail is set', () => { - const { container } = renderWithQueryClient() + it('should return null when no readme panel is open', () => { + const { container } = renderWithQueryClient() - expect(container.firstChild).toBeNull() + expect(container.firstChild).toBeNull() + }) + + it('should render drawer presentation with plugin header content', () => { + openReadmePanel() + + renderWithQueryClient() + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument() + expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true') + expect(screen.getByRole('dialog')).toHaveClass('data-[swipe-direction=left]:w-150') + }) + + it('should render dialog presentation when requested', () => { + openReadmePanel(createMockPluginDetail(), 'dialog') + + renderWithQueryClient() + + expect(screen.getByRole('dialog')).toHaveClass('max-w-200') + }) + + it('should close the active panel when close button is clicked', () => { + openReadmePanel() + + renderWithQueryClient() + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) + + expect(useReadmePanelStore.getState().currentPanel).toBeUndefined() + }) + + it('should render loading, error, empty, and readme states from the readme query', () => { + openReadmePanel() + mockUsePluginReadme.mockReturnValue({ + data: null, + isLoading: true, + error: null, }) + const { rerender } = renderWithQueryClient() + expect(screen.getByRole('status')).toBeInTheDocument() - it('should render portal content when plugin detail is set', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument() + mockUsePluginReadme.mockReturnValue({ + data: null, + isLoading: false, + error: new Error('Failed to fetch'), }) + rerender() + expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument() - it('should render DetailHeader component', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByTestId('detail-header')).toBeInTheDocument() - expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true') + mockUsePluginReadme.mockReturnValue({ + data: { readme: '' }, + isLoading: false, + error: null, }) + rerender() + expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument() - it('should render close button', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) + mockUsePluginReadme.mockReturnValue({ + data: { readme: '# Test Readme Content' }, + isLoading: false, + error: null, + }) + rerender() + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) - renderWithQueryClient() + it('should call usePluginReadme with the plugin identifier and selected language', () => { + openReadmePanel(createMockPluginDetail({ + plugin_unique_identifier: 'custom-plugin@2.0.0', + })) - // ActionButton wraps the close icon - expect(screen.getByRole('button')).toBeInTheDocument() + renderWithQueryClient() + + expect(mockUsePluginReadme).toHaveBeenCalledWith({ + plugin_unique_identifier: 'custom-plugin@2.0.0', + language: 'en-US', }) }) - // ================================ - // Loading State Tests - // ================================ - describe('Loading State', () => { - it('should show loading indicator when isLoading is true', () => { - mockUsePluginReadme.mockReturnValue({ - data: null, - isLoading: true, - error: null, - }) + it('should pass undefined language for zh-Hans locale', () => { + mockLanguage = 'zh-Hans' + openReadmePanel(createMockPluginDetail({ + plugin_unique_identifier: 'zh-plugin@1.0.0', + })) - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) + renderWithQueryClient() - renderWithQueryClient() - - // Loading component should be rendered with role="status" - expect(screen.getByRole('status')).toBeInTheDocument() + expect(mockUsePluginReadme).toHaveBeenCalledWith({ + plugin_unique_identifier: 'zh-plugin@1.0.0', + language: undefined, }) }) - // ================================ - // Error State Tests - // ================================ - describe('Error State', () => { - it('should show error message when error occurs', () => { - mockUsePluginReadme.mockReturnValue({ - data: null, - isLoading: false, - error: new Error('Failed to fetch'), - }) + it('should open correctly from ReadmeEntrance through the global host', () => { + const detail = createMockPluginDetail() - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) + renderWithQueryClient( + <> + + + , + ) - renderWithQueryClient() + fireEvent.click(screen.getByRole('button', { name: /plugin\.readmeInfo\.needHelpCheckReadme/ })) - expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument() - }) - }) - - // ================================ - // No Readme Available State Tests - // ================================ - describe('No Readme Available', () => { - it('should show no readme message when readme is empty', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument() - }) - - it('should show no readme message when data is null', () => { - mockUsePluginReadme.mockReturnValue({ - data: null, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument() - }) - }) - - // ================================ - // Markdown Content Tests - // ================================ - describe('Markdown Content', () => { - it('should render markdown container when readme is available', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Test Readme Content' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Markdown component container should be rendered - // Note: The Markdown component uses dynamic import, so content may load asynchronously - const markdownContainer = document.querySelector('.markdown-body') - expect(markdownContainer).toBeInTheDocument() - }) - - it('should not show error or no-readme message when readme is available', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Test Readme Content' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Should not show error or no-readme message - expect(screen.queryByText('plugin.readmeInfo.failedToFetch')).not.toBeInTheDocument() - expect(screen.queryByText('plugin.readmeInfo.noReadmeAvailable')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Portal Rendering Tests (Drawer Mode) - // ================================ - describe('Portal Rendering - Drawer Mode', () => { - it('should render drawer styled container in drawer mode', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Test' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Drawer mode has specific max-width - const drawerContainer = document.querySelector('.max-w-\\[600px\\]') - expect(drawerContainer).toBeInTheDocument() - }) - - it('should have correct drawer positioning classes', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Check for drawer-specific classes - const backdrop = document.querySelector('.justify-start') - expect(backdrop).toBeInTheDocument() - }) - }) - - // ================================ - // Portal Rendering Tests (Modal Mode) - // ================================ - describe('Portal Rendering - Modal Mode', () => { - it('should render modal styled container in modal mode', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Test' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - - renderWithQueryClient() - - // Modal mode has different max-width - const modalContainer = document.querySelector('.max-w-\\[800px\\]') - expect(modalContainer).toBeInTheDocument() - }) - - it('should have correct modal positioning classes', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - - renderWithQueryClient() - - // Check for modal-specific classes - const backdrop = document.querySelector('.items-center.justify-center') - expect(backdrop).toBeInTheDocument() - }) - }) - - // ================================ - // User Interactions / Event Handlers - // ================================ - describe('User Interactions', () => { - it('should close panel when close button is clicked', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - fireEvent.click(screen.getByRole('button')) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - - it('should close panel when backdrop is clicked', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Click on the backdrop (outer div) - const backdrop = document.querySelector('.fixed.inset-0') - fireEvent.click(backdrop!) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - - it('should not close panel when content area is clicked', async () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Click on the content container (should stop propagation) - const contentContainer = document.querySelector('.pointer-events-auto') - fireEvent.click(contentContainer!) - - await waitFor(() => { - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeDefined() - }) - }) - - it('should not close panel when content area is clicked in modal mode', async () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - - renderWithQueryClient() - - // Click on the content container in modal mode (should stop propagation) - const contentContainer = document.querySelector('.pointer-events-auto') - fireEvent.click(contentContainer!) - - await waitFor(() => { - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeDefined() - }) - }) - }) - - // ================================ - // API Call Tests - // ================================ - describe('API Calls', () => { - it('should call usePluginReadme with correct parameters', () => { - const mockDetail = createMockPluginDetail({ - plugin_unique_identifier: 'custom-plugin@2.0.0', - }) - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(mockUsePluginReadme).toHaveBeenCalledWith({ - plugin_unique_identifier: 'custom-plugin@2.0.0', - language: 'en-US', - }) - }) - - it('should pass undefined language for zh-Hans locale', () => { - // Set language to zh-Hans - mockLanguage = 'zh-Hans' - - const mockDetail = createMockPluginDetail({ - plugin_unique_identifier: 'zh-plugin@1.0.0', - }) - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // The component should pass undefined for language when zh-Hans - expect(mockUsePluginReadme).toHaveBeenCalledWith({ - plugin_unique_identifier: 'zh-plugin@1.0.0', - language: undefined, - }) - - // Reset language - mockLanguage = 'en-US' - }) - - it('should handle empty plugin_unique_identifier', () => { - mockUsePluginReadme.mockReturnValue({ - data: null, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail({ - plugin_unique_identifier: '', - }) - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(mockUsePluginReadme).toHaveBeenCalledWith({ - plugin_unique_identifier: '', - language: 'en-US', - }) - }) - }) - - // ================================ - // Edge Cases - // ================================ - describe('Edge Cases', () => { - it('should handle detail with missing declaration', () => { - const mockDetail = createMockPluginDetail() - // Simulate missing fields - delete (mockDetail as Partial).declaration - - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // This should not throw - expect(() => setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)).not.toThrow() - }) - - it('should handle rapid open/close operations', async () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // Rapidly toggle the panel - act(() => { - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - setCurrentPluginDetail() - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - }) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail?.showType).toBe(ReadmeShowType.modal) - }) - - it('should handle switching between drawer and modal modes', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // Start with drawer - act(() => { - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - }) - - let state = useReadmePanelStore.getState() - expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.drawer) - - // Switch to modal - act(() => { - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - }) - - state = useReadmePanelStore.getState() - expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal) - }) - - it('should handle undefined detail gracefully', () => { - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // Set to undefined explicitly - act(() => { - setCurrentPluginDetail(undefined, ReadmeShowType.drawer) - }) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - }) - - // ================================ - // Integration Tests - // ================================ - describe('Integration', () => { - it('should work correctly when opened from ReadmeEntrance', () => { - const mockDetail = createMockPluginDetail() - - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Integration Test' }, - isLoading: false, - error: null, - }) - - // Render both components - const { rerender } = renderWithQueryClient( - <> - - - , - ) - - // Initially panel should not show content - expect(screen.queryByTestId('detail-header')).not.toBeInTheDocument() - - // Click the entrance button - fireEvent.click(screen.getByRole('button')) - - // Re-render to pick up store changes - rerender( - - - - , - ) - - // Panel should now show content - expect(screen.getByTestId('detail-header')).toBeInTheDocument() - // Markdown content renders in a container (dynamic import may not render content synchronously) - expect(document.querySelector('.markdown-body')).toBeInTheDocument() - }) - - it('should display correct plugin information in header', () => { - const mockDetail = createMockPluginDetail({ - name: 'my-awesome-plugin', - }) - - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument() - }) + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/readme-panel/__tests__/store.spec.ts b/web/app/components/plugins/readme-panel/__tests__/store.spec.ts index a349659f42..f8f1ae035e 100644 --- a/web/app/components/plugins/readme-panel/__tests__/store.spec.ts +++ b/web/app/components/plugins/readme-panel/__tests__/store.spec.ts @@ -1,54 +1,52 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { beforeEach, describe, expect, it } from 'vitest' -import { ReadmeShowType, useReadmePanelStore } from '../store' +import { useReadmePanelStore } from '../store' describe('readme-panel/store', () => { beforeEach(() => { - useReadmePanelStore.setState({ currentPluginDetail: undefined }) + useReadmePanelStore.setState({ currentPanel: undefined }) }) - it('initializes with undefined currentPluginDetail', () => { + it('initializes without an active panel', () => { const state = useReadmePanelStore.getState() - expect(state.currentPluginDetail).toBeUndefined() + expect(state.currentPanel).toBeUndefined() }) - it('sets current plugin detail with drawer showType by default', () => { - const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail - useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail) + it('opens drawer presentation by default', () => { + const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().openReadmePanel({ detail, triggerId: 'readme-trigger' }) - const state = useReadmePanelStore.getState() - expect(state.currentPluginDetail).toEqual({ - detail: mockDetail, - showType: ReadmeShowType.drawer, + expect(useReadmePanelStore.getState().currentPanel).toEqual({ + detail, + presentation: 'drawer', + triggerId: 'readme-trigger', }) }) - it('sets current plugin detail with modal showType', () => { - const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail - useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) + it('opens dialog presentation when requested', () => { + const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().openReadmePanel({ detail, presentation: 'dialog' }) - const state = useReadmePanelStore.getState() - expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal) + expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog') }) - it('clears current plugin detail when called with undefined', () => { - const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail - useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail) - expect(useReadmePanelStore.getState().currentPluginDetail).toBeDefined() + it('closes the active panel', () => { + const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().openReadmePanel({ detail }) + expect(useReadmePanelStore.getState().currentPanel).toBeDefined() - useReadmePanelStore.getState().setCurrentPluginDetail(undefined) - expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined() + useReadmePanelStore.getState().closeReadmePanel() + expect(useReadmePanelStore.getState().currentPanel).toBeUndefined() }) - it('replaces previous detail with new one', () => { + it('replaces the active panel with the latest request', () => { const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail - useReadmePanelStore.getState().setCurrentPluginDetail(detail1) - expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1') + useReadmePanelStore.getState().openReadmePanel({ detail: detail1 }) + useReadmePanelStore.getState().openReadmePanel({ detail: detail2, presentation: 'dialog' }) - useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal) - expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2') - expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal) + expect(useReadmePanelStore.getState().currentPanel?.detail.id).toBe('plugin-2') + expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog') }) }) diff --git a/web/app/components/plugins/readme-panel/content.tsx b/web/app/components/plugins/readme-panel/content.tsx new file mode 100644 index 0000000000..1fde49b701 --- /dev/null +++ b/web/app/components/plugins/readme-panel/content.tsx @@ -0,0 +1,81 @@ +'use client' + +import type { ReactNode } from 'react' +import type { PluginDetail } from '../types' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { Markdown } from '@/app/components/base/markdown' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { usePluginReadme } from '@/service/use-plugins' +import DetailHeader from '../plugin-detail-panel/detail-header' + +type ReadmePanelContentProps = { + detail: PluginDetail + title: ReactNode + closeButton: ReactNode +} + +export function ReadmePanelContent({ + detail, + title, + closeButton, +}: ReadmePanelContentProps) { + const { t } = useTranslation() + const language = useLanguage() + const pluginUniqueIdentifier = detail.plugin_unique_identifier || '' + + const { data: readmeData, isLoading, error } = usePluginReadme({ + plugin_unique_identifier: pluginUniqueIdentifier, + language: language === 'zh-Hans' ? undefined : language, + }) + + let readmeContent: ReactNode + if (isLoading) { + readmeContent = ( +
+ +
+ ) + } + else if (error) { + readmeContent = ( +
+

{t('readmeInfo.failedToFetch', { ns: 'plugin' })}

+
+ ) + } + else if (readmeData?.readme) { + readmeContent = ( + + ) + } + else { + readmeContent = ( +
+

{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}

+
+ ) + } + + return ( +
+
+
+
+
+ {closeButton} +
+ +
+ +
+ {readmeContent} +
+
+ ) +} diff --git a/web/app/components/plugins/readme-panel/dialog.tsx b/web/app/components/plugins/readme-panel/dialog.tsx new file mode 100644 index 0000000000..36695196b3 --- /dev/null +++ b/web/app/components/plugins/readme-panel/dialog.tsx @@ -0,0 +1,52 @@ +'use client' + +import type { PluginDetail } from '../types' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { useTranslation } from 'react-i18next' +import { ReadmePanelContent } from './content' + +type ReadmeDialogProps = { + detail: PluginDetail + open: boolean + onOpenChange: (open: boolean) => void + triggerId?: string +} + +export function ReadmeDialog({ + detail, + open, + onOpenChange, + triggerId, +}: ReadmeDialogProps) { + const { t } = useTranslation() + + return ( + + + + {t('readmeInfo.title', { ns: 'plugin' })} + + )} + closeButton={( + + )} + /> + + + ) +} diff --git a/web/app/components/plugins/readme-panel/drawer.tsx b/web/app/components/plugins/readme-panel/drawer.tsx new file mode 100644 index 0000000000..97ce5185fd --- /dev/null +++ b/web/app/components/plugins/readme-panel/drawer.tsx @@ -0,0 +1,62 @@ +'use client' + +import type { PluginDetail } from '../types' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' +import { useTranslation } from 'react-i18next' +import { ReadmePanelContent } from './content' + +type ReadmeDrawerProps = { + detail: PluginDetail + open: boolean + onOpenChange: (open: boolean) => void + triggerId?: string +} + +export function ReadmeDrawer({ + detail, + open, + onOpenChange, + triggerId, +}: ReadmeDrawerProps) { + const { t } = useTranslation() + + return ( + + + + + + + + {t('readmeInfo.title', { ns: 'plugin' })} + + )} + closeButton={( + + )} + /> + + + + + + ) +} diff --git a/web/app/components/plugins/readme-panel/entrance.tsx b/web/app/components/plugins/readme-panel/entrance.tsx index 2d8188874d..067420f5f7 100644 --- a/web/app/components/plugins/readme-panel/entrance.tsx +++ b/web/app/components/plugins/readme-panel/entrance.tsx @@ -1,34 +1,40 @@ import type { PluginDetail } from '../types' +import type { ReadmePanelPresentation } from './store' import { cn } from '@langgenius/dify-ui/cn' -import { RiBookReadLine } from '@remixicon/react' -import * as React from 'react' +import { useId } from 'react' import { useTranslation } from 'react-i18next' import { BUILTIN_TOOLS_ARRAY } from './constants' -import { ReadmeShowType, useReadmePanelStore } from './store' +import { useReadmePanelStore } from './store' export const ReadmeEntrance = ({ pluginDetail, - showType = ReadmeShowType.drawer, + presentation = 'drawer', className, showShortTip = false, }: { pluginDetail: PluginDetail - showType?: ReadmeShowType + presentation?: ReadmePanelPresentation className?: string showShortTip?: boolean }) => { const { t } = useTranslation() - const { setCurrentPluginDetail } = useReadmePanelStore() + const triggerId = useId() + const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel) const handleReadmeClick = () => { - if (pluginDetail) - setCurrentPluginDetail(pluginDetail, showType) + if (pluginDetail) { + openReadmePanel({ + detail: pluginDetail, + presentation, + triggerId, + }) + } } if (!pluginDetail || !pluginDetail?.plugin_unique_identifier || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id)) return null return ( -
+
{!showShortTip && (
@@ -36,11 +42,13 @@ export const ReadmeEntrance = ({ )} ) } @@ -119,6 +135,12 @@ describe('LabelSelector', () => { expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument() }) + it('should render the trigger as a native button', () => { + render() + + expect(screen.getByRole('button', { name: 'tools.createTool.toolInput.labelPlaceholder' })).toHaveAttribute('type', 'button') + }) + it('should display selected labels as comma-separated list', () => { render() diff --git a/web/app/components/tools/labels/selector.tsx b/web/app/components/tools/labels/selector.tsx index b4dff0c0f2..40b890667b 100644 --- a/web/app/components/tools/labels/selector.tsx +++ b/web/app/components/tools/labels/selector.tsx @@ -6,7 +6,6 @@ import { PopoverContent, PopoverTrigger, } from '@langgenius/dify-ui/popover' -import { RiArrowDownSLine } from '@remixicon/react' import { useDebounceFn } from 'ahooks' import { noop } from 'es-toolkit/function' import { useMemo, useState } from 'react' @@ -60,22 +59,19 @@ const LabelSelector: FC = ({
-
0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}> - {!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })} - {!!value.length && selectedLabels} -
-
- -
-
+ className={cn( + 'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 text-left hover:bg-components-input-bg-hover', + open && 'bg-components-input-bg-hover hover:bg-components-input-bg-hover', )} - /> + > +
0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-4.5 text-text-secondary', !value.length && 'text-text-quaternary!')}> + {!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })} + {!!value.length && selectedLabels} +
+
+ +
+ ({ })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => ( -
+ WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => ( +
@@ -581,7 +581,7 @@ describe('ProviderDetail', () => { }) }) - it('saves workflow tool via workflow modal', async () => { + it('saves workflow tool via workflow drawer', async () => { render( { expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument() }) fireEvent.click(screen.getByText('tools.createTool.editAction')) - expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument() + expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument() await act(async () => { fireEvent.click(screen.getByTestId('wf-save')) }) @@ -627,7 +627,7 @@ describe('ProviderDetail', () => { }) }) - describe('Modal Close Actions', () => { + describe('Overlay Close Actions', () => { it('closes ConfigCredential when cancel is clicked', async () => { render( { expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument() }) - it('closes WorkflowToolModal via onHide', async () => { + it('closes WorkflowToolDrawer via onHide', async () => { render( { expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument() }) fireEvent.click(screen.getByText('tools.createTool.editAction')) - expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument() + expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument() fireEvent.click(screen.getByTestId('wf-close')) - expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument() + expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index eee3f423bb..9080ee2c7d 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -1,6 +1,6 @@ 'use client' import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types' -import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool' +import type { WorkflowToolDrawerPayload } from '@/app/components/tools/workflow-tool' import { AlertDialog, AlertDialogActions, @@ -31,7 +31,7 @@ import OrgInfo from '@/app/components/plugins/card/base/org-info' import Title from '@/app/components/plugins/card/base/title' import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal' import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' -import WorkflowToolModal from '@/app/components/tools/workflow-tool' +import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' import { useAppContext } from '@/context/app-context' import { useLocale } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' @@ -140,7 +140,7 @@ const ProviderDetail = ({ setIsShowEditCustomCollectionModal(false) } // workflow provider - const [isShowEditWorkflowToolModal, setIsShowEditWorkflowToolModal] = useState(false) + const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) const getWorkflowToolProvider = useCallback(async () => { setIsDetailLoading(true) const res = await fetchWorkflowToolDetail(collection.id) @@ -164,7 +164,7 @@ const ProviderDetail = ({ await deleteWorkflowTool(collection.id) onRefreshData() toast.success(t('api.actionSuccess', { ns: 'common' })) - setIsShowEditWorkflowToolModal(false) + setWorkflowToolDrawerOpen(false) } const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{ workflow_app_id: string @@ -175,7 +175,7 @@ const ProviderDetail = ({ onRefreshData() getWorkflowToolProvider() toast.success(t('api.actionSuccess', { ns: 'common' })) - setIsShowEditWorkflowToolModal(false) + setWorkflowToolDrawerOpen(false) } const onClickCustomToolDelete = () => { setDeleteAction('customTool') @@ -287,7 +287,7 @@ const ProviderDetail = ({ - {body} -
- ) - }, -})) - // Mock EmojiPickerInner - simplified for testing vi.mock('@/app/components/base/emoji-picker/Inner', () => ({ default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => ( @@ -120,22 +103,6 @@ const createMockEmoji = (overrides = {}) => ({ ...overrides, }) -const createMockInputVar = (overrides: Partial = {}): InputVar => ({ - variable: 'test_var', - label: 'Test Variable', - type: InputVarType.textInput, - required: true, - max_length: 100, - options: [], - ...overrides, -} as InputVar) - -const createMockVariable = (overrides: Partial = {}): Variable => ({ - variable: 'output_var', - value_type: 'string', - ...overrides, -} as Variable) - const createMockWorkflowToolDetail = (overrides: Partial = {}): WorkflowToolProviderResponse => ({ workflow_app_id: 'workflow-app-123', workflow_tool_id: 'workflow-tool-456', @@ -179,19 +146,14 @@ const createMockWorkflowToolDetail = (overrides: Partial ({ disabled: false, published: false, - detailNeedUpdate: false, - workflowAppId: 'workflow-app-123', - icon: createMockEmoji(), - name: 'Test Workflow', - description: 'Test workflow description', - inputs: [createMockInputVar()], - outputs: [createMockVariable()], - handlePublish: vi.fn().mockResolvedValue(undefined), - onRefreshData: vi.fn(), + isLoading: false, + outdated: false, + isCurrentWorkspaceManager: true, + onConfigure: vi.fn(), ...overrides, }) -const createDefaultModalPayload = (overrides: Partial = {}): WorkflowToolModalPayload => ({ +const createDefaultDrawerPayload = (overrides: Partial = {}): WorkflowToolDrawerPayload => ({ icon: createMockEmoji(), label: 'Test Tool', name: 'test_tool', @@ -297,8 +259,7 @@ describe('WorkflowToolConfigureButton', () => { it('should render loading state when published and fetching details', () => { // Arrange - mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true }) - const props = createDefaultConfigureButtonProps({ published: true }) + const props = createDefaultConfigureButtonProps({ published: true, isLoading: true }) // Act render() @@ -324,8 +285,7 @@ describe('WorkflowToolConfigureButton', () => { it('should render different UI for non-workspace manager', () => { // Arrange - mockIsCurrentWorkspaceManager.mockReturnValue(false) - const props = createDefaultConfigureButtonProps() + const props = createDefaultConfigureButtonProps({ isCurrentWorkspaceManager: false }) // Act render() @@ -346,53 +306,46 @@ describe('WorkflowToolConfigureButton', () => { expect(() => render()).not.toThrow() }) - it('should handle undefined inputs and outputs', () => { + it('should render without disabled reason', () => { // Arrange - const props = createDefaultConfigureButtonProps({ - inputs: undefined, - outputs: undefined, - }) + const props = createDefaultConfigureButtonProps({ disabledReason: undefined }) // Act & Assert expect(() => render()).not.toThrow() }) - it('should handle empty inputs and outputs arrays', () => { + it('should handle configured callback props', () => { // Arrange - const props = createDefaultConfigureButtonProps({ - inputs: [], - outputs: [], - }) + const props = createDefaultConfigureButtonProps({ onConfigure: vi.fn() }) // Act & Assert expect(() => render()).not.toThrow() }) }) - // Modal behavior tests - describe('Modal Behavior', () => { - it('should toggle modal visibility', async () => { + // Drawer behavior tests + describe('Drawer Behavior', () => { + it('should request configuration from the unpublished entry point', async () => { // Arrange const user = userEvent.setup() - const props = createDefaultConfigureButtonProps() + const onConfigure = vi.fn() + const props = createDefaultConfigureButtonProps({ onConfigure }) // Act render() - // Click to open modal + // Click to request opening the drawer const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') await user.click(triggerArea!) - // Assert - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + expect(onConfigure).toHaveBeenCalledTimes(1) }) - it('should not open modal when disabled', async () => { + it('should not request configuration when disabled', async () => { // Arrange const user = userEvent.setup() - const props = createDefaultConfigureButtonProps({ disabled: true }) + const onConfigure = vi.fn() + const props = createDefaultConfigureButtonProps({ disabled: true, onConfigure }) // Act render() @@ -400,45 +353,14 @@ describe('WorkflowToolConfigureButton', () => { const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') await user.click(triggerArea!) - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() + expect(onConfigure).not.toHaveBeenCalled() }) - it('should not open modal when published (use configure button instead)', async () => { + it('should request configuration from the published configure button only', async () => { // Arrange const user = userEvent.setup() - const props = createDefaultConfigureButtonProps({ published: true }) + const onConfigure = vi.fn() + const props = createDefaultConfigureButtonProps({ published: true, onConfigure }) // Act render() @@ -447,51 +369,16 @@ describe('WorkflowToolConfigureButton', () => { expect(screen.getByText('workflow.common.configure'))!.toBeInTheDocument() }) - // Click the main area (should not open modal) + // Click the main area (should not request opening the drawer) const mainArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') await user.click(mainArea!) - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() + expect(onConfigure).not.toHaveBeenCalled() // Click configure button await user.click(screen.getByText('workflow.common.configure')) - // Assert - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + expect(onConfigure).toHaveBeenCalledTimes(1) }) }) @@ -541,12 +428,11 @@ describe('WorkflowToolConfigureButton', () => { expect(screen.getByText('workflow.common.workflowAsTool'))!.toBeInTheDocument() }) - it('should handle paragraph type input conversion', async () => { + it('should keep the configure entry independent from workflow parameter shape', async () => { // Arrange const user = userEvent.setup() - const props = createDefaultConfigureButtonProps({ - inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })], - }) + const onConfigure = vi.fn() + const props = createDefaultConfigureButtonProps({ onConfigure }) // Act render() @@ -554,10 +440,7 @@ describe('WorkflowToolConfigureButton', () => { const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') await user.click(triggerArea!) - // Assert - should render without error - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + expect(onConfigure).toHaveBeenCalledTimes(1) }) }) @@ -579,8 +462,7 @@ describe('WorkflowToolConfigureButton', () => { it('should disable configure button when not workspace manager', async () => { // Arrange - mockIsCurrentWorkspaceManager.mockReturnValue(false) - const props = createDefaultConfigureButtonProps({ published: true }) + const props = createDefaultConfigureButtonProps({ published: true, isCurrentWorkspaceManager: false }) // Act render() @@ -595,9 +477,9 @@ describe('WorkflowToolConfigureButton', () => { }) // ============================================================================ -// WorkflowToolAsModal Tests +// WorkflowToolDrawer Tests // ============================================================================ -describe('WorkflowToolAsModal', () => { +describe('WorkflowToolDrawer', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -608,12 +490,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -624,12 +506,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -640,12 +522,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -656,12 +538,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -672,12 +554,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -688,12 +570,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -704,12 +586,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -722,12 +604,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -738,12 +620,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -754,13 +636,13 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onRemove: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -771,13 +653,13 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), onRemove: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -819,7 +701,7 @@ describe('WorkflowToolAsModal', () => { describe('Props', () => { it('should initialize state from payload', () => { // Arrange - const payload = createDefaultModalPayload({ + const payload = createDefaultDrawerPayload({ label: 'Custom Label', name: 'custom_name', description: 'Custom description', @@ -831,7 +713,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() // Assert // Assert @@ -842,7 +724,7 @@ describe('WorkflowToolAsModal', () => { it('should pass labels to label selector', () => { // Arrange - const payload = createDefaultModalPayload({ labels: ['tag1', 'tag2'] }) + const payload = createDefaultDrawerPayload({ labels: ['tag1', 'tag2'] }) const props = { isAdd: true, payload, @@ -850,7 +732,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() // Assert // Assert @@ -865,12 +747,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ label: '' }), + payload: createDefaultDrawerPayload({ label: '' }), onHide: vi.fn(), } // Act - render() + render() const labelInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder') await user.type(labelInput, 'New Label') @@ -884,12 +766,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ name: '' }), + payload: createDefaultDrawerPayload({ name: '' }), onHide: vi.fn(), } // Act - render() + render() const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') await user.type(nameInput, 'new_name') @@ -903,12 +785,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ description: '' }), + payload: createDefaultDrawerPayload({ description: '' }), onHide: vi.fn(), } // Act - render() + render() const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') await user.type(descInput, 'New description') @@ -922,12 +804,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() const iconButton = screen.getByTestId('app-icon') await user.click(iconButton) @@ -941,12 +823,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Open emoji picker const iconButton = screen.getByTestId('app-icon') @@ -967,12 +849,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() const iconButton = screen.getByTestId('app-icon') await user.click(iconButton) @@ -1021,12 +903,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ labels: ['initial'] }), + payload: createDefaultDrawerPayload({ labels: ['initial'] }), onHide: vi.fn(), } // Act - render() + render() await user.click(screen.getByTestId('add-label')) // Assert @@ -1039,12 +921,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ privacy_policy: '' }), + payload: createDefaultDrawerPayload({ privacy_policy: '' }), onHide: vi.fn(), } // Act - render() + render() const privacyInput = screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder') await user.type(privacyInput, 'https://example.com/privacy') @@ -1062,12 +944,12 @@ describe('WorkflowToolAsModal', () => { const onHide = vi.fn() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide, } // Act - render() + render() await user.click(screen.getByText('common.operation.cancel')) // Assert @@ -1080,12 +962,12 @@ describe('WorkflowToolAsModal', () => { const onHide = vi.fn() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide, } // Act - render() + render() await user.click(screen.getByTestId('drawer-close')) // Assert @@ -1098,13 +980,13 @@ describe('WorkflowToolAsModal', () => { const onRemove = vi.fn() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onRemove, } // Act - render() + render() await user.click(screen.getByText('common.operation.delete')) // Assert @@ -1117,13 +999,13 @@ describe('WorkflowToolAsModal', () => { const onCreate = vi.fn() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), onCreate, } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Assert @@ -1138,13 +1020,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onSave: vi.fn(), } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Assert @@ -1158,13 +1040,13 @@ describe('WorkflowToolAsModal', () => { const onSave = vi.fn() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onSave, } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) await user.click(screen.getByText('common.operation.confirm')) @@ -1179,7 +1061,7 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ + payload: createDefaultDrawerPayload({ parameters: [{ name: 'param1', description: '', // Start with empty description @@ -1192,7 +1074,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() const descInput = screen.getByPlaceholderText('tools.createTool.toolInput.descriptionPlaceholder') await user.type(descInput, 'New parameter description') @@ -1209,13 +1091,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ label: '' }), + payload: createDefaultDrawerPayload({ label: '' }), onHide: vi.fn(), onCreate: vi.fn(), } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Assert @@ -1230,13 +1112,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ label: 'Test', name: '' }), + payload: createDefaultDrawerPayload({ label: 'Test', name: '' }), onHide: vi.fn(), onCreate: vi.fn(), } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Assert @@ -1251,12 +1133,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ name: '' }), + payload: createDefaultDrawerPayload({ name: '' }), onHide: vi.fn(), } // Act - render() + render() const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') await user.type(nameInput, 'invalid name with spaces') @@ -1270,12 +1152,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ name: '' }), + payload: createDefaultDrawerPayload({ name: '' }), onHide: vi.fn(), } // Act - render() + render() const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') await user.type(nameInput, 'valid_name_123') @@ -1321,31 +1203,31 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload({ parameters: [] }), + payload: createDefaultDrawerPayload({ parameters: [] }), onHide: vi.fn(), } // Act & Assert - expect(() => render()).not.toThrow() + expect(() => render()).not.toThrow() }) it('should handle empty output parameters', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload({ outputParameters: [] }), + payload: createDefaultDrawerPayload({ outputParameters: [] }), onHide: vi.fn(), } // Act & Assert - expect(() => render()).not.toThrow() + expect(() => render()).not.toThrow() }) it('should handle parameter with __image name specially', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload({ + payload: createDefaultDrawerPayload({ parameters: [{ name: '__image', description: 'Image parameter', @@ -1358,7 +1240,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() // Assert - __image should show method as text, not selector // Assert - __image should show method as text, not selector @@ -1369,7 +1251,7 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload({ + payload: createDefaultDrawerPayload({ outputParameters: [{ name: 'text', // Collides with reserved description: 'Custom text output', @@ -1380,7 +1262,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() // Assert - should show both reserved and custom with warning icon const textElements = screen.getAllByText('text') @@ -1392,13 +1274,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), // onSave is undefined } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Show confirm modal @@ -1415,13 +1297,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), // onCreate is undefined } // Act & Assert - should not crash - render() + render() await user.click(screen.getByText('common.operation.save')) }) @@ -1430,13 +1312,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onSave: vi.fn(), } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) await waitFor(() => { @@ -1690,25 +1572,22 @@ describe('Integration Tests', () => { })) }) - // Complete workflow: open modal -> fill form -> save + // Complete workflow: open drawer -> fill form -> save describe('Complete Workflow', () => { it('should complete full create workflow', async () => { // Arrange const user = userEvent.setup() - mockCreateWorkflowToolProvider.mockResolvedValue({}) - const onRefreshData = vi.fn() - const props = createDefaultConfigureButtonProps({ onRefreshData }) + const onCreate = vi.fn() // Act - render() - - // Open modal - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + render( + , + ) // Fill form const labelInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder') @@ -1716,6 +1595,7 @@ describe('Integration Tests', () => { await user.type(labelInput, 'My Custom Tool') const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') + await user.clear(nameInput) await user.type(nameInput, 'my_custom_tool') const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') @@ -1727,7 +1607,7 @@ describe('Integration Tests', () => { // Assert await waitFor(() => { - expect(mockCreateWorkflowToolProvider).toHaveBeenCalledWith( + expect(onCreate).toHaveBeenCalledWith( expect.objectContaining({ name: 'my_custom_tool', label: 'My Custom Tool', @@ -1735,36 +1615,22 @@ describe('Integration Tests', () => { }), ) }) - - await waitFor(() => { - expect(onRefreshData).toHaveBeenCalled() - }) }) it('should complete full update workflow', async () => { // Arrange const user = userEvent.setup() - const handlePublish = vi.fn().mockResolvedValue(undefined) - mockSaveWorkflowToolProvider.mockResolvedValue({}) - const props = createDefaultConfigureButtonProps({ - published: true, - handlePublish, - }) + const onSave = vi.fn() // Act - render() - - // Wait for detail to load - await waitFor(() => { - expect(screen.getByText('workflow.common.configure'))!.toBeInTheDocument() - }) - - // Open modal - await user.click(screen.getByText('workflow.common.configure')) - - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + render( + , + ) // Modify description const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') @@ -1782,8 +1648,10 @@ describe('Integration Tests', () => { // Assert await waitFor(() => { - expect(handlePublish).toHaveBeenCalled() - expect(mockSaveWorkflowToolProvider).toHaveBeenCalled() + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + workflow_tool_id: 'workflow-tool-1', + description: 'Updated description', + })) }) }) }) @@ -1792,11 +1660,9 @@ describe('Integration Tests', () => { describe('Callback Stability', () => { it('should maintain callback references across rerenders', async () => { // Arrange - const handlePublish = vi.fn().mockResolvedValue(undefined) - const onRefreshData = vi.fn() + const onConfigure = vi.fn() const props = createDefaultConfigureButtonProps({ - handlePublish, - onRefreshData, + onConfigure, }) // Act diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx index 9f5532f1f7..8c35232d35 100644 --- a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -1,22 +1,9 @@ -import type { WorkflowToolModalPayload } from '../index' +import type { ReactNode } from 'react' +import type { WorkflowToolDrawerPayload } from '../index' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import WorkflowToolAsModal from '../index' - -vi.mock('@/app/components/base/drawer-plus', () => ({ - default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => ( - isShow - ? ( -
- {title} - - {body} -
- ) - : null - ), -})) +import { WorkflowToolDrawer } from '../index' vi.mock('@/app/components/base/emoji-picker/Inner', () => ({ default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => ( @@ -46,8 +33,8 @@ vi.mock('@/app/components/base/tooltip', () => ({ children, popupContent, }: { - children?: React.ReactNode - popupContent?: React.ReactNode + children?: ReactNode + popupContent?: ReactNode }) => (
{children} @@ -86,7 +73,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) -const createPayload = (overrides: Partial = {}): WorkflowToolModalPayload => ({ +const createPayload = (overrides: Partial = {}): WorkflowToolDrawerPayload => ({ icon: { content: '🔧', background: '#ffffff' }, label: 'My Tool', name: 'my_tool', @@ -105,7 +92,7 @@ const createPayload = (overrides: Partial = {}): Workf ...overrides, }) -describe('WorkflowToolAsModal', () => { +describe('WorkflowToolDrawer', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -115,7 +102,7 @@ describe('WorkflowToolAsModal', () => { const onCreate = vi.fn() render( - { const onCreate = vi.fn() render( - { const onSave = vi.fn() render( - { it('should show duplicate reserved output warnings', () => { render( - Promise - onRefreshData?: () => void + isLoading: boolean + outdated: boolean + isCurrentWorkspaceManager: boolean + onConfigure: () => void disabledReason?: string } const WorkflowToolConfigureButton = ({ disabled, published, - detailNeedUpdate, - workflowAppId, - icon, - name, - description, - inputs, - outputs, - handlePublish, - onRefreshData, + isLoading, + outdated, + isCurrentWorkspaceManager, + onConfigure, disabledReason, }: Props) => { const { t } = useTranslation() - const { - showModal, - isLoading, - outdated, - payload, - isCurrentWorkspaceManager, - openModal, - closeModal, - handleCreate, - handleUpdate, - navigateToTools, - } = useConfigureButton({ - published, - detailNeedUpdate, - workflowAppId, - icon, - name, - description, - inputs, - outputs, - handlePublish, - onRefreshData, - }) + const router = useRouter() return ( <> @@ -80,9 +43,12 @@ const WorkflowToolConfigureButton = ({ ? (
!disabled && !published && openModal()} + onClick={() => { + if (!disabled && !published) + onConfigure() + }} > - +
- +
{t('common.configure', { ns: 'workflow' })} @@ -129,11 +95,11 @@ const WorkflowToolConfigureButton = ({
{outdated && ( @@ -146,15 +112,6 @@ const WorkflowToolConfigureButton = ({
)} {published && isLoading &&
} - {showModal && ( - - )} ) } diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts index 8bc3db95da..fd800fe5b0 100644 --- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts +++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts @@ -4,11 +4,6 @@ import { act, renderHook } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import { isParametersOutdated, useConfigureButton } from '../use-configure-button' -const mockPush = vi.fn() -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ push: mockPush }), -})) - const mockIsCurrentWorkspaceManager = vi.fn(() => true) vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ @@ -98,6 +93,7 @@ const createMockDetail = (overrides: Partial = {}) }) const createDefaultOptions = (overrides = {}) => ({ + enabled: true, published: false, detailNeedUpdate: false, workflowAppId: 'app-123', @@ -213,9 +209,9 @@ describe('useConfigureButton', () => { }) describe('Initialization', () => { - it('should return showModal as false by default', () => { + it('should return workflow tool state without owning drawer visibility', () => { const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) - expect(result.current.showModal).toBe(false) + expect(result.current.payload).toMatchObject({ workflow_app_id: 'app-123' }) }) it('should forward isCurrentWorkspaceManager from context', () => { @@ -239,6 +235,11 @@ describe('useConfigureButton', () => { renderHook(() => useConfigureButton(createDefaultOptions({ published: false }))) expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false) }) + + it('should call query hook with enabled=false when controller is disabled', () => { + renderHook(() => useConfigureButton(createDefaultOptions({ enabled: false, published: true }))) + expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false) + }) }) // Computed values @@ -348,46 +349,13 @@ describe('useConfigureButton', () => { }) }) - // Modal controls - describe('Modal Controls', () => { - it('should open modal via openModal', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) - act(() => { - result.current.openModal() - }) - expect(result.current.showModal).toBe(true) - }) - - it('should close modal via closeModal', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) - act(() => { - result.current.openModal() - }) - act(() => { - result.current.closeModal() - }) - expect(result.current.showModal).toBe(false) - }) - - it('should navigate to tools page', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) - act(() => { - result.current.navigateToTools() - }) - expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow') - }) - }) - // Mutation handlers describe('handleCreate', () => { - it('should create provider, invalidate caches, refresh, and close modal', async () => { + it('should create provider, invalidate caches, refresh, and notify configured', async () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) const onRefreshData = vi.fn() - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData }))) - - act(() => { - result.current.openModal() - }) + const onConfigured = vi.fn() + const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured }))) await act(async () => { await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string }) @@ -398,7 +366,7 @@ describe('useConfigureButton', () => { expect(onRefreshData).toHaveBeenCalled() expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) }) - expect(result.current.showModal).toBe(false) + expect(onConfigured).toHaveBeenCalled() }) it('should show error toast on failure', async () => { @@ -414,20 +382,18 @@ describe('useConfigureButton', () => { }) describe('handleUpdate', () => { - it('should publish, save, invalidate caches, and close modal', async () => { + it('should publish, save, invalidate caches, and notify configured', async () => { mockSaveWorkflowToolProvider.mockResolvedValue({}) const handlePublish = vi.fn().mockResolvedValue(undefined) const onRefreshData = vi.fn() + const onConfigured = vi.fn() const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true, handlePublish, onRefreshData, + onConfigured, }))) - act(() => { - result.current.openModal() - }) - await act(async () => { await result.current.handleUpdate(createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>) }) @@ -437,7 +403,7 @@ describe('useConfigureButton', () => { expect(onRefreshData).toHaveBeenCalled() expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') - expect(result.current.showModal).toBe(false) + expect(onConfigured).toHaveBeenCalled() }) it('should show error toast when publish fails', async () => { @@ -491,6 +457,16 @@ describe('useConfigureButton', () => { expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled() }) + + it('should not invalidate detail while disabled', () => { + renderHook(() => useConfigureButton(createDefaultOptions({ + enabled: false, + published: true, + detailNeedUpdate: true, + }))) + + expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled() + }) }) // Edge cases diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts index 33965aa5ee..f4b6881f98 100644 --- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -2,10 +2,9 @@ import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderPa import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useAppContext } from '@/context/app-context' -import { useRouter } from '@/next/navigation' import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools' import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools' @@ -89,6 +88,7 @@ function buildExistingOutputParameters( // endregion type UseConfigureButtonOptions = { + enabled: boolean published: boolean detailNeedUpdate: boolean workflowAppId: string @@ -99,10 +99,12 @@ type UseConfigureButtonOptions = { outputs?: Variable[] handlePublish: (params?: PublishWorkflowParams) => Promise onRefreshData?: () => void + onConfigured?: () => void } export function useConfigureButton(options: UseConfigureButtonOptions) { const { + enabled, published, detailNeedUpdate, workflowAppId, @@ -113,16 +115,14 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { outputs, handlePublish, onRefreshData, + onConfigured, } = options const { t } = useTranslation() - const router = useRouter() const { isCurrentWorkspaceManager } = useAppContext() - const [showModal, setShowModal] = useState(false) - // Data fetching via React Query - const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, published) + const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, enabled && published) // Invalidation functions (store in ref for stable effect dependency) const invalidateDetail = useInvalidateWorkflowToolDetailByAppID() @@ -133,9 +133,9 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { // Refetch when detailNeedUpdate becomes true useEffect(() => { - if (detailNeedUpdate) + if (enabled && detailNeedUpdate) invalidateDetailRef.current(workflowAppId) - }, [detailNeedUpdate, workflowAppId]) + }, [detailNeedUpdate, enabled, workflowAppId]) // Computed values const outdated = useMemo( @@ -173,14 +173,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { } }, [detail, published, workflowAppId, icon, name, description, inputs, outputs]) - // Modal controls (stable callbacks) - const openModal = useCallback(() => setShowModal(true), []) - const closeModal = useCallback(() => setShowModal(false), []) - const navigateToTools = useCallback( - () => router.push('/tools?category=workflow'), - [router], - ) - // Mutation handlers (not memoized — only used in conditionally-rendered modal) const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => { try { @@ -189,7 +181,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData?.() invalidateDetail(workflowAppId) toast.success(t('api.actionSuccess', { ns: 'common' })) - setShowModal(false) + onConfigured?.() } catch (e) { toast.error((e as Error).message) @@ -206,7 +198,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData?.() invalidateAllWorkflowTools() invalidateDetail(workflowAppId) - setShowModal(false) + onConfigured?.() } catch (e) { toast.error((e as Error).message) @@ -214,15 +206,11 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { } return { - showModal, isLoading, outdated, payload, isCurrentWorkspaceManager, - openModal, - closeModal, handleCreate, handleUpdate, - navigateToTools, } } diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index 6f8258f185..c582980a49 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -1,9 +1,19 @@ 'use client' -import type { FC } from 'react' +import type { DrawerRootProps } from '@langgenius/dify-ui/drawer' import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { produce } from 'immer' @@ -26,7 +36,7 @@ import { isWorkflowToolNameValid, } from './helpers' -export type WorkflowToolModalPayload = { +export type WorkflowToolDrawerPayload = { icon: Emoji label: string name: string @@ -42,9 +52,9 @@ export type WorkflowToolModalPayload = { workflow_app_id?: string } -type Props = { +export type WorkflowToolDrawerProps = { isAdd?: boolean - payload: WorkflowToolModalPayload + payload: WorkflowToolDrawerPayload onHide: () => void onRemove?: () => void onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void @@ -54,8 +64,9 @@ type Props = { }>) => void } -type WorkflowToolDrawerProps = { +type WorkflowToolDrawerFrameProps = { title: string + closeLabel: string onHide: () => void children: React.ReactNode } @@ -77,39 +88,45 @@ const InfoTooltip = ({ children }: { children: React.ReactNode }) => { ) } -const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => { +const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: WorkflowToolDrawerFrameProps) => { + const handleOpenChange = React.useCallback>((open) => { + if (!open) + onHide() + }, [onHide]) + return ( - - -
-
-
- - {title} - - -
-
-
- {children} -
-
-
-
+ + + + + + +
+
+ + {title} + + +
+
+
+ {children} +
+
+
+
+
+
) } @@ -158,15 +175,14 @@ const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerP ) } -// Add and Edit -const WorkflowToolAsModal: FC = ({ +export function WorkflowToolDrawer({ isAdd, payload, onHide, onRemove, onSave, onCreate, -}) => { +}: WorkflowToolDrawerProps) { const { t } = useTranslation() const [showEmojiPicker, setShowEmojiPicker] = useState(false) @@ -200,7 +216,7 @@ const WorkflowToolAsModal: FC = ({ setLabels(value) } const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy) - const [showModal, setShowModal] = useState(false) + const [confirmModalOpen, setConfirmModalOpen] = useState(false) const onConfirm = () => { let errorMessage = '' @@ -243,9 +259,10 @@ const WorkflowToolAsModal: FC = ({ return ( <> -
@@ -427,7 +444,7 @@ const WorkflowToolAsModal: FC = ({ if (isAdd) onConfirm() else - setShowModal(true) + setConfirmModalOpen(true) }} > {t('operation.save', { ns: 'common' })} @@ -435,7 +452,7 @@ const WorkflowToolAsModal: FC = ({
- + {showEmojiPicker && ( { @@ -447,10 +464,10 @@ const WorkflowToolAsModal: FC = ({ }} /> )} - {showModal && ( + {confirmModalOpen && ( setShowModal(false)} + show={confirmModalOpen} + onClose={() => setConfirmModalOpen(false)} onConfirm={onConfirm} /> )} @@ -458,4 +475,3 @@ const WorkflowToolAsModal: FC = ({ ) } -export default React.memo(WorkflowToolAsModal) diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 41e47967b2..39dd8e4ccb 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -124,7 +124,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({ -
diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index 4457d9cddf..b849159867 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -10,12 +10,15 @@ This document tracks the Dify-web migration away from legacy overlay APIs. - `@/app/components/base/tooltip` - `@/app/components/base/modal` - `@/app/components/base/dialog` + - `@/app/components/base/drawer` + - `@/app/components/base/drawer-plus` - Replacement primitives: - `@langgenius/dify-ui/tooltip` - `@langgenius/dify-ui/dropdown-menu` - `@langgenius/dify-ui/context-menu` - `@langgenius/dify-ui/popover` - `@langgenius/dify-ui/dialog` + - `@langgenius/dify-ui/drawer` - `@langgenius/dify-ui/alert-dialog` - `@langgenius/dify-ui/autocomplete` - `@langgenius/dify-ui/combobox` @@ -49,12 +52,12 @@ All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index val During the migration period, legacy and new overlays coexist. Legacy overlays portal to `document.body` with explicit z-index values: -| Layer | z-index | Components | -| --------------------- | ------------ | -------------------------------------------------------------------------------- | -| Legacy Drawer | `z-30` | `base/drawer` | -| Legacy Modal | `z-60` | `base/modal` (default) | -| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) | -| Toast | `z-1003` | `@langgenius/dify-ui/toast` | +| Layer | z-index | Components | +| --------------------- | ------------ | ---------------------------------------------------------------------------------------- | +| Legacy Drawer | `z-30` | `base/drawer`, `base/drawer-plus` | +| Legacy Modal | `z-60` | `base/modal` (default) | +| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Drawer, Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) | +| Toast | `z-1003` | `@langgenius/dify-ui/toast` | `z-1002` sits above all common legacy overlays, so new primitives always render on top without needing per-call-site z-index hacks. Among themselves, diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index a55213ab49..f74c5c9115 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -66,6 +66,15 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ ], message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.', }, + { + group: [ + '**/base/drawer', + '**/base/drawer/index', + '**/base/drawer-plus', + '**/base/drawer-plus/index', + ], + message: 'Deprecated: use @langgenius/dify-ui/drawer instead. See issue #32767.', + }, ] export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = { diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 27850e62ad..b9edad48f0 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -5,6 +5,7 @@ import type { MetadataItemWithValue } from '@/app/components/datasets/metadata/t import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { Tag } from '@/contract/console/tags' import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app' +import type { SegmentImportStatus } from '@/types/dataset' import type { I18nKeysByPrefix } from '@/types/i18n' import { ExternalKnowledgeBase, General, ParentChild, Qa } from '@/app/components/base/icons/src/public/knowledge/dataset-card' @@ -783,7 +784,7 @@ export type UpdateDocumentBatchParams = { export type BatchImportResponse = { job_id: string - job_status: string + job_status: SegmentImportStatus } export const DOC_FORM_ICON_WITH_BG: Record> = { diff --git a/web/types/dataset.ts b/web/types/dataset.ts new file mode 100644 index 0000000000..167a740098 --- /dev/null +++ b/web/types/dataset.ts @@ -0,0 +1,8 @@ +export const segmentImportStatus = { + waiting: 'waiting', + processing: 'processing', + completed: 'completed', + error: 'error', +} as const + +export type SegmentImportStatus = typeof segmentImportStatus[keyof typeof segmentImportStatus] From af754f497a25124dd5b68042faa23c970b54d746 Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 8 May 2026 17:49:43 +0800 Subject: [PATCH 5/6] chore: add query generator before lauch webapp (#35416) Co-authored-by: yyh --- .../components/authenticated-layout.tsx | 4 +- .../app-info/app-info-detail-panel.tsx | 2 +- .../app-publisher/__tests__/index.spec.tsx | 81 ++++ .../app-publisher/__tests__/sections.spec.tsx | 59 ++- .../__tests__/suggested-action.spec.tsx | 43 ++ .../components/app/app-publisher/index.tsx | 70 +++ .../components/app/app-publisher/sections.tsx | 23 + .../app/app-publisher/suggested-action.tsx | 78 +++- .../__tests__/form-fields.spec.tsx | 72 ++- .../__tests__/index-logic.spec.tsx | 4 +- .../config-modal/__tests__/utils.spec.ts | 20 + .../config-var/config-modal/form-fields.tsx | 45 +- .../config-var/config-modal/utils.ts | 35 +- .../__tests__/app-card-sections.spec.tsx | 163 ++++++- .../overview/__tests__/app-card-utils.spec.ts | 223 +++++++++- .../app/overview/__tests__/app-card.spec.tsx | 182 +++++++- .../workflow-hidden-input-fields.spec.tsx | 214 +++++++++ .../app/overview/app-card-sections.tsx | 167 ++++++- .../components/app/overview/app-card-utils.ts | 173 +++++++- web/app/components/app/overview/app-card.tsx | 78 +++- .../embedded/__tests__/index.spec.tsx | 137 +++++- .../app/overview/embedded/index.tsx | 411 ++++++++++++------ .../overview/workflow-hidden-input-fields.tsx | 116 +++++ .../base/chat/__tests__/utils.spec.ts | 51 ++- web/app/components/base/chat/utils.ts | 20 +- .../use-text-generation-app-state.spec.ts | 244 ++++++++++- .../hooks/use-text-generation-app-state.ts | 52 ++- web/app/components/workflow/types.ts | 2 +- web/i18n/en-US/app-debug.json | 2 + web/i18n/en-US/app-overview.json | 4 + web/i18n/ja-JP/app-debug.json | 2 + web/i18n/ja-JP/app-overview.json | 4 + web/i18n/zh-Hans/app-debug.json | 2 + web/i18n/zh-Hans/app-overview.json | 4 + web/models/debug.ts | 2 +- 35 files changed, 2564 insertions(+), 225 deletions(-) create mode 100644 web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx create mode 100644 web/app/components/app/overview/workflow-hidden-input-fields.tsx diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index a7b65f33fe..3ee5d52603 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -39,7 +39,9 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { const getSigninUrl = useCallback(() => { const params = new URLSearchParams(searchParams) params.delete('message') - params.set('redirect_url', pathname) + const query = params.toString() + const fullPath = query ? `${pathname}?${query}` : pathname + params.set('redirect_url', fullPath) return `/webapp-signin?${params.toString()}` }, [searchParams, pathname]) diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx index 9afa0063dc..3dabb2a91e 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx @@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 1fad833933..0dfb4347e4 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -20,6 +20,7 @@ const mockOpenAsyncWindow = vi.fn() const mockFetchInstalledAppList = vi.fn() const mockFetchAppDetailDirect = vi.fn() const mockToastError = vi.fn() +const mockWindowOpen = vi.fn() const mockInvalidateAppWorkflow = vi.fn() const sectionProps = vi.hoisted(() => ({ @@ -37,6 +38,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) vi.mock('ahooks', async () => { @@ -167,6 +169,12 @@ vi.mock('../sections', () => ({
+ {props.handleOpenRunConfig && ( + <> + + + + )}
) @@ -200,6 +208,10 @@ describe('AppPublisher', () => { mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise) => { await resolver() }) + Object.defineProperty(window, 'open', { + writable: true, + value: mockWindowOpen, + }) }) it('should open the publish popover and refetch access permission data', async () => { @@ -256,6 +268,75 @@ describe('AppPublisher', () => { expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument() }) + it('should collect hidden inputs before opening published run links from config actions', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-run-config')) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/token-1?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open batch run config links with the configured hidden inputs', async () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-batch-run-config')) + + fireEvent.change(screen.getByLabelText('Batch Secret'), { + target: { value: 'batch-value' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`, + '_blank', + ) + }) + }) + it('should keep workflow tool drawer mounted after closing the publish popover', () => { mockAppDetail = { ...mockAppDetail, diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 714cd3b7c3..453779504e 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -18,8 +18,32 @@ vi.mock('../publish-with-multiple-model', () => ({ })) vi.mock('../suggested-action', () => ({ - default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => ( - + default: ({ + children, + onClick, + link, + disabled, + actionButton, + }: { + children: ReactNode + onClick?: () => void + link?: string + disabled?: boolean + actionButton?: { ariaLabel: string, onClick: () => void } + }) => ( +
+ + {actionButton && ( + + )} +
), })) @@ -170,9 +194,25 @@ describe('app-publisher sections', () => { expect(render().container).toBeEmptyDOMElement() }) + it('should hide access control content when enabled is false', () => { + render( + , + ) + + expect(screen.queryByText('publishApp.title')).not.toBeInTheDocument() + expect(screen.queryByText('accessControlDialog.accessItems.anyone')).not.toBeInTheDocument() + }) + it('should render workflow actions, batch run links, and workflow tool configuration', () => { const handleOpenInExplore = vi.fn() const handleEmbed = vi.fn() + const handleOpenRunConfig = vi.fn() const { rerender } = render( { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} + handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} missingStartNode={false} + published={false} publishedAt={Date.now()} + showBatchRunConfig + showRunConfig toolPublished workflowToolAvailable={false} workflowToolIsLoading={false} @@ -205,6 +250,10 @@ describe('app-publisher sections', () => { ) expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch') + fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[0]!) + expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app') + fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[1]!) + expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch') fireEvent.click(screen.getByText('common.openInExplore')) expect(handleOpenInExplore).toHaveBeenCalled() expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument() @@ -222,9 +271,12 @@ describe('app-publisher sections', () => { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} + handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} missingStartNode + published={false} publishedAt={Date.now()} toolPublished={false} workflowToolAvailable @@ -246,9 +298,12 @@ describe('app-publisher sections', () => { disabledFunctionButton={false} handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} + handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode missingStartNode={false} + published={false} publishedAt={undefined} toolPublished={false} workflowToolAvailable diff --git a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx index ea199dfb78..2ca9e77abf 100644 --- a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx @@ -46,4 +46,47 @@ describe('SuggestedAction', () => { expect(handleClick).toHaveBeenCalledTimes(1) }) + + it('should render and trigger the trailing action button when configured', () => { + const handleActionClick = vi.fn() + + render( + config, + onClick: handleActionClick, + }} + > + Configurable action + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + + expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute('href', 'https://example.com/docs') + expect(handleActionClick).toHaveBeenCalledTimes(1) + }) + + it('should block action button clicks when disabled', () => { + const handleActionClick = vi.fn() + + render( + config, + onClick: handleActionClick, + }} + > + Disabled with action + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + expect(handleActionClick).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index a066233107..f5b2c80ae8 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,4 +1,6 @@ +import type { FormEvent } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' @@ -8,6 +10,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { + memo, use, useCallback, @@ -16,6 +19,13 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections' +import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, + isWorkflowLaunchInputSupported, + +} from '@/app/components/app/overview/app-card-utils' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' @@ -111,6 +121,9 @@ const AppPublisher = ({ const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) + const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) + const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('') + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) const workflowStore = use(WorkflowContext) @@ -122,6 +135,22 @@ const AppPublisher = ({ const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) + const hiddenLaunchVariables = useMemo( + () => (inputs ?? []).filter(input => input.hide === true), + [inputs], + ) + const supportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), + [hiddenLaunchVariables], + ) + const unsupportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + [hiddenLaunchVariables], + ) + const initialWorkflowLaunchValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), + [supportedWorkflowLaunchVariables], + ) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) @@ -231,6 +260,31 @@ const AppPublisher = ({ } }, [appDetail, setAppDetail]) + const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => { + setWorkflowLaunchValues(initialWorkflowLaunchValues) + setWorkflowLaunchTargetUrl(targetUrl) + setWorkflowLaunchDialogOpen(true) + }, [initialWorkflowLaunchValues]) + + const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues(prev => ({ + ...prev, + [variable]: value, + })) + }, []) + + const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent) => { + event.preventDefault() + + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: workflowLaunchTargetUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) + + window.open(targetUrl, '_blank') + setWorkflowLaunchDialogOpen(false) + }, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues]) const handlePublishToMarketplace = useCallback(async () => { if (!appDetail?.id || publishingToMarketplace) return @@ -377,10 +431,15 @@ const AppPublisher = ({ handleOpenChange(false) handleOpenInExplore() }} + handleOpenRunConfig={handleOpenWorkflowLaunchDialog} + handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} missingStartNode={missingStartNode} + published={published} publishedAt={publishedAt} + showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)} + showRunConfig={hiddenLaunchVariables.length > 0} toolPublished={toolPublished} workflowToolAvailable={workflowToolAvailable} workflowToolIsLoading={workflowTool.isLoading} @@ -410,8 +469,19 @@ const AppPublisher = ({ onClose={() => setEmbeddingModalOpen(false)} appBaseUrl={appBaseURL} accessToken={accessToken} + hiddenInputs={hiddenLaunchVariables} /> {showAppAccessControl && { setShowAppAccessControl(false) }} />} + {workflowToolDrawerOpen && ( void handleOpenInExplore: () => void + handleOpenRunConfig?: (url: string) => void + handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise + published: boolean + showBatchRunConfig?: boolean + showRunConfig?: boolean workflowToolIsLoading: boolean workflowToolOutdated: boolean workflowToolIsCurrentWorkspaceManager: boolean @@ -253,10 +259,13 @@ export const PublisherActionsSection = ({ disabledFunctionTooltip, handleEmbed, handleOpenInExplore, + handleOpenRunConfig, hasHumanInputNode = false, hasTriggerNode = false, missingStartNode = false, publishedAt, + showBatchRunConfig = false, + showRunConfig = false, toolPublished, workflowToolAvailable = true, workflowToolIsLoading, @@ -280,6 +289,13 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={appURL} icon={} + actionButton={showRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(appURL), + } + : undefined} > {t('common.runApp', { ns: 'workflow' })} @@ -292,6 +308,13 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} icon={} + actionButton={showBatchRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`), + } + : undefined} > {t('common.batchRunApp', { ns: 'workflow' })} diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx index db13364eb9..c1cec6f819 100644 --- a/web/app/components/app/app-publisher/suggested-action.tsx +++ b/web/app/components/app/app-publisher/suggested-action.tsx @@ -1,33 +1,93 @@ -import type { HTMLProps, PropsWithChildren } from 'react' +import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightUpLine } from '@remixicon/react' +type SuggestedActionButton = { + ariaLabel: string + icon: React.ReactNode + onClick: (event: ReactMouseEvent) => void +} + type SuggestedActionProps = PropsWithChildren & { icon?: React.ReactNode link?: string disabled?: boolean + actionButton?: SuggestedActionButton }> -const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => { - const handleClick = (e: React.MouseEvent) => { - if (disabled) +const SuggestedAction = ({ + icon, + link, + disabled, + children, + className, + onClick, + actionButton, + ...props +}: SuggestedActionProps) => { + const handleClick = (event: ReactMouseEvent) => { + if (disabled) { + event.preventDefault() return - onClick?.(e) + } + + onClick?.(event) } - return ( + + const handleActionClick = (event: ReactMouseEvent) => { + if (disabled) { + event.preventDefault() + return + } + + actionButton?.onClick(event) + } + + const mainAction = ( -
{icon}
+
{icon}
{children}
- +
) + + if (!actionButton) + return mainAction + + return ( +
+ {mainAction} + +
+ ) } export default SuggestedAction diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 7a63df3350..cdb1d17833 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -4,6 +4,29 @@ import { fireEvent, render, screen } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import ConfigModalFormFields from '../form-fields' +vi.mock('react-i18next', async () => { + const React = await import('react') + return { + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const ns = options?.ns as string | undefined + return ns ? `${ns}.${key}` : key + }, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record }) => ( + + {i18nKey} + {components?.docLink} + + ), + } +}) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path?: string) => `https://docs.example.com${path || ''}`, +})) + vi.mock('@/app/components/base/file-uploader', () => ({ FileUploaderInAttachmentWrapper: ({ onChange, @@ -74,6 +97,12 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { } }) +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, +})) + vi.mock('../field', () => ({ default: ({ children, title }: { children: ReactNode, title: string }) => (
@@ -176,7 +205,18 @@ describe('ConfigModalFormFields', () => { expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta') }) - it('should wire file, json schema, and visibility controls', () => { + it('should wire file, json schema, and visibility controls', async () => { + const textInputProps = createBaseProps() + const textInputView = render() + expect(screen.getByText('variableConfig.hidden')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'variableConfig.hiddenDescription' })) + expect(await screen.findByText('variableConfig.hiddenDescription')).toBeInTheDocument() + const docLink = await screen.findByRole('link') + expect(docLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields') + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + textInputView.unmount() + const singleFileProps = createBaseProps() singleFileProps.tempPayload = { ...singleFileProps.tempPayload, @@ -185,18 +225,20 @@ describe('ConfigModalFormFields', () => { allowed_file_extensions: [], allowed_file_upload_methods: ['remote_url'], } - render() + const singleFileView = render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() + expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument() fireEvent.click(screen.getByText('single-file-setting')) fireEvent.click(screen.getByText('upload-file')) fireEvent.click(screen.getAllByText('unchecked')[0]!) - fireEvent.click(screen.getAllByText('unchecked')[1]!) expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 }) expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({ fileId: 'file-1', })) expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true) - expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true) + expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled() + singleFileView.unmount() const multiFileProps = createBaseProps() multiFileProps.tempPayload = { @@ -207,8 +249,9 @@ describe('ConfigModalFormFields', () => { allowed_file_upload_methods: ['remote_url'], } render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() fireEvent.click(screen.getByText('multi-file-setting')) - fireEvent.click(screen.getAllByText('upload-file')[1]!) + fireEvent.click(screen.getAllByText('upload-file')[0]!) expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 }) expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([ expect.objectContaining({ fileId: 'file-1' }), @@ -367,4 +410,23 @@ describe('ConfigModalFormFields', () => { expect(screen.getByRole('spinbutton')).toHaveValue(null) }) + + it('should disable hide checkbox when required is true and disable required when hide is true', () => { + const requiredProps = createBaseProps() + requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false } + const { unmount } = render() + + const buttons = screen.getAllByRole('button') + const hideButton = buttons.find(btn => btn.textContent === 'unchecked' && btn !== buttons[0]) + expect(hideButton).toBeDefined() + unmount() + + const hideProps = createBaseProps() + hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true } + render() + + const allButtons = screen.getAllByRole('button') + const checkedHideButton = allButtons.find(btn => btn.textContent === 'checked') + expect(checkedHideButton).toBeDefined() + }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index e6cb56f490..d32bcec755 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -25,6 +25,7 @@ vi.mock('../form-fields', () => ({ return (
{String(props.tempPayload.type)}
+
{String(props.tempPayload.hide)}
{String(props.tempPayload.label ?? '')}
{String(props.tempPayload.json_schema ?? '')}
{String(props.tempPayload.default ?? '')}
@@ -115,7 +116,7 @@ describe('ConfigModal logic', () => { }) it('should derive payload fields from mocked form-field callbacks', async () => { - renderConfigModal() + renderConfigModal(createPayload({ hide: true })) fireEvent.click(screen.getByTestId('valid-key-blur')) await waitFor(() => { @@ -138,6 +139,7 @@ describe('ConfigModal logic', () => { fireEvent.click(screen.getByTestId('type-change')) await waitFor(() => { expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile) + expect(screen.getByTestId('payload-hide')).toHaveTextContent('false') }) fireEvent.click(screen.getByTestId('file-payload-change')) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts index 1c00e1c5b2..2317868004 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts @@ -49,11 +49,13 @@ describe('config-modal utils', () => { const payload = createInputVar({ type: InputVarType.textInput, default: 'hello', + hide: true, }) const nextPayload = createPayloadForType(payload, InputVarType.multiFiles) expect(nextPayload.type).toBe(InputVarType.multiFiles) + expect(nextPayload.hide).toBe(false) expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length) expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types) expect(nextPayload.default).toBe('hello') @@ -249,6 +251,24 @@ describe('config-modal utils', () => { }) }) + it('should force file inputs to stay visible when saving', () => { + const result = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.singleFile, + hide: true, + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: [], + }), + payload: createInputVar(), + checkVariableName: () => true, + t, + }) + + expect(result.payloadToSave).toEqual(expect.objectContaining({ + hide: false, + })) + }) + it('should stop validation when the variable name checker rejects the payload', () => { const result = validateConfigModalPayload({ tempPayload: createInputVar({ diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index 748108e19a..4bd938c3f6 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -13,14 +13,17 @@ import { SelectValue, } from '@langgenius/dify-ui/select' import * as React from 'react' +import { Trans } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' import { TransferMethod } from '@/types/app' import ConfigSelect from '../config-select' import ConfigString from '../config-string' @@ -68,6 +71,9 @@ const ConfigModalFormFields: FC = ({ t, }) => { const { type, label, variable } = tempPayload + const isFileInput = [InputVarType.singleFile, InputVarType.multiFiles].includes(type) + const docLink = useDocLink() + const hiddenDescriptionAriaLabel = t('variableConfig.hiddenDescription', { ns: 'appDebug' }).replace(/<[^>]+>/g, '') return (
@@ -105,7 +111,7 @@ const ConfigModalFormFields: FC = ({ {type === InputVarType.textInput && ( onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -126,7 +132,7 @@ const ConfigModalFormFields: FC = ({ onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -186,7 +192,7 @@ const ConfigModalFormFields: FC = ({ )} - {[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && ( + {isFileInput && ( <> = ({ )}
- onPayloadChange('required')(!tempPayload.required)} /> + onPayloadChange('required')(!tempPayload.required)} /> {t('variableConfig.required', { ns: 'appDebug' })}
-
- onPayloadChange('hide')(!tempPayload.hide)} /> - {t('variableConfig.hide', { ns: 'appDebug' })} -
+ {!isFileInput && ( +
+ onPayloadChange('hide')(!tempPayload.hide)} /> +
+ {t('variableConfig.hidden', { ns: 'appDebug' })} + + + ), + }} + /> + +
+
+ )}
) } diff --git a/web/app/components/app/configuration/config-var/config-modal/utils.ts b/web/app/components/app/configuration/config-var/config-modal/utils.ts index fdc0ac3501..e24e4b6593 100644 --- a/web/app/components/app/configuration/config-var/config-modal/utils.ts +++ b/web/app/components/app/configuration/config-var/config-modal/utils.ts @@ -88,7 +88,9 @@ export const createPayloadForType = (payload: InputVar, type: InputVarType) => { draft.default = undefined if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { - (Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array).forEach((key) => { + draft.hide = false + const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array + fileUploadSettingKeys.forEach((key) => { if (key !== 'max_length') draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never }) @@ -158,38 +160,41 @@ export const validateConfigModalPayload = ({ checkVariableName, t, }: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => { + const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type) + ? { ...tempPayload, hide: false } + : tempPayload const jsonSchemaValue = tempPayload.json_schema const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue) const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue - const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty - ? { ...tempPayload, json_schema: undefined } - : tempPayload + const payloadToSave = normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty + ? { ...normalizedTempPayload, json_schema: undefined } + : normalizedTempPayload - const moreInfo = tempPayload.variable === payload?.variable + const moreInfo = normalizedTempPayload.variable === payload?.variable ? undefined : { type: ChangeType.changeVarName, - payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable }, + payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable }, } - if (!checkVariableName(tempPayload.variable)) + if (!checkVariableName(normalizedTempPayload.variable)) return {} - if (!tempPayload.label) { + if (!normalizedTempPayload.label) { return { errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }), } } - if (tempPayload.type === InputVarType.select) { - if (!tempPayload.options?.length) { + if (normalizedTempPayload.type === InputVarType.select) { + if (!normalizedTempPayload.options?.length) { return { errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }), } } const duplicated = new Set() - const hasRepeatedItem = tempPayload.options.some((option) => { + const hasRepeatedItem = normalizedTempPayload.options.some((option) => { if (duplicated.has(option)) return true @@ -204,8 +209,8 @@ export const validateConfigModalPayload = ({ } } - if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) { - if (!tempPayload.allowed_file_types?.length) { + if ([InputVarType.singleFile, InputVarType.multiFiles].includes(normalizedTempPayload.type)) { + if (!normalizedTempPayload.allowed_file_types?.length) { return { errorMessage: t('errorMsg.fieldRequired', { ns: 'workflow', @@ -214,7 +219,7 @@ export const validateConfigModalPayload = ({ } } - if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) { + if (normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !normalizedTempPayload.allowed_file_extensions?.length) { return { errorMessage: t('errorMsg.fieldRequired', { ns: 'workflow', @@ -224,7 +229,7 @@ export const validateConfigModalPayload = ({ } } - if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { + if (normalizedTempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { try { const schema = JSON.parse(normalizedJsonSchema) if (schema?.type !== 'object') { diff --git a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx index 9820e15ad8..d3f83d5d9c 100644 --- a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx @@ -1,8 +1,38 @@ +import type { FormEvent } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, render, screen, within } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' -import { AppCardAccessControlSection, AppCardOperations, AppCardUrlSection, createAppCardOperations } from '../app-card-sections' +import { AppCardAccessControlSection, AppCardDialogs, AppCardOperations, AppCardUrlSection, createAppCardOperations, WorkflowLaunchDialog } from '../app-card-sections' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, +})) + +vi.mock('../settings', () => ({ + default: () =>
, +})) + +vi.mock('../embedded', () => ({ + default: () =>
, +})) + +vi.mock('../customize', () => ({ + default: () =>
, +})) + +vi.mock('../../app-access-control', () => ({ + default: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => ( +
+ + +
+ ), +})) describe('app-card-sections', () => { const t = (key: string) => key @@ -52,6 +82,7 @@ describe('app-card-sections', () => { it('should render operation buttons and execute enabled actions', () => { const onLaunch = vi.fn() + const onLaunchConfig = vi.fn() const operations = createAppCardOperations({ operationKeys: ['launch', 'embedded'], t: t as never, @@ -68,12 +99,19 @@ describe('app-card-sections', () => { , ) fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) + fireEvent.click(screen.getByRole('button', { name: /operation\.config/i })) expect(onLaunch).toHaveBeenCalledTimes(1) + expect(onLaunchConfig).toHaveBeenCalledTimes(1) expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument() }) @@ -127,4 +165,127 @@ describe('app-card-sections', () => { fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i })) expect(onRegenerate).toHaveBeenCalledTimes(1) }) + + it('should disable all operations when triggerModeDisabled is true', () => { + const operations = createAppCardOperations({ + operationKeys: ['launch', 'settings'], + t: t as never, + runningStatus: true, + triggerModeDisabled: true, + onLaunch: vi.fn(), + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + expect(operations[0]!.disabled).toBe(true) + expect(operations[1]!.disabled).toBe(true) + }) + + it('should render WorkflowLaunchDialog and submit values', () => { + const onOpenChange = vi.fn() + const onValueChange = vi.fn() + const onSubmit = vi.fn((event: FormEvent) => { + event.preventDefault() + }) + + render( + , + ) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + fireEvent.submit(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!) + expect(onSubmit).toHaveBeenCalled() + }) + + it('should return null for WorkflowLaunchDialog when no variables are provided', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render AppCardDialogs with all modals for web apps', () => { + const appInfo = { + id: 'app-1', + mode: AppModeEnum.CHAT, + enable_site: true, + enable_api: false, + site: { app_base_url: 'https://example.com', access_token: 'token-1' }, + api_base_url: 'https://api.example.com', + } as never + + render( + , + ) + + expect(screen.getByTestId('settings-modal')).toBeInTheDocument() + expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() + expect(screen.getByTestId('customize-modal')).toBeInTheDocument() + expect(screen.getByTestId('access-control')).toBeInTheDocument() + }) + + it('should return null for AppCardDialogs when not an app', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) }) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts index fbfcdaf955..0a6d7f7dd7 100644 --- a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -1,9 +1,22 @@ import type { AppDetailResponse } from '@/models/app' -import { BlockEnum } from '@/app/components/workflow/types' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils' +import { + buildWorkflowLaunchUrl, + compressAndEncodeBase64, + createWorkflowLaunchInitialValues, + getAppCardDisplayState, + getAppCardOperationKeys, + getAppHiddenLaunchVariables, + getEmbeddedIframeSnippet, + getEmbeddedScriptSnippet, + getWorkflowHiddenStartVariables, + hasWorkflowStartNode, + isAppAccessConfigured, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' describe('app-card-utils', () => { const baseAppInfo = { @@ -33,6 +46,108 @@ describe('app-card-utils', () => { })).toBe(false) }) + it('should return hidden workflow start variables and their initial launch values', () => { + const hiddenVariables = getWorkflowHiddenStartVariables({ + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'visible', + label: 'Visible', + type: InputVarType.textInput, + hide: false, + required: false, + }, + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + default: 'prefilled', + required: false, + }, + { + variable: 'enabled', + label: 'Enabled', + type: InputVarType.checkbox, + hide: true, + default: true, + required: false, + }, + ], + }, + }], + }, + }) + + expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled']) + expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({ + secret: 'prefilled', + enabled: true, + }) + }) + + it('should return hidden advanced-chat launch variables from the workflow start node first', () => { + const hiddenVariables = getAppHiddenLaunchVariables({ + appInfo: { + ...baseAppInfo, + mode: AppModeEnum.ADVANCED_CHAT, + model_config: { + user_input_form: [ + { + 'text-input': { + label: 'Visible', + variable: 'visible', + required: true, + max_length: 48, + default: '', + hide: false, + }, + }, + { + checkbox: { + label: 'Hidden Toggle', + variable: 'hidden_toggle', + required: false, + default: true, + hide: true, + }, + }, + ], + }, + } as AppDetailResponse, + currentWorkflow: { + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'start_secret', + label: 'Start Secret', + type: InputVarType.textInput, + hide: true, + default: 'from-start', + required: false, + }, + ], + }, + }], + }, + }, + }) + + expect(hiddenVariables).toEqual([ + expect.objectContaining({ + variable: 'start_secret', + type: InputVarType.textInput, + default: 'from-start', + }), + ]) + }) + it('should build the display state for a published web app', () => { const state = getAppCardDisplayState({ appInfo: baseAppInfo, @@ -104,4 +219,108 @@ describe('app-card-utils', () => { isCurrentWorkspaceEditor: false, })).toEqual(['launch', 'embedded', 'customize']) }) + + it('should build a workflow launch URL with serialized parameters', async () => { + const url = await buildWorkflowLaunchUrl({ + accessibleUrl: 'https://example.com/app/workflow/token-1', + variables: [ + { variable: 'name', label: 'Name', type: InputVarType.textInput, hide: true, required: false }, + { variable: 'enabled', label: 'Enabled', type: InputVarType.checkbox, hide: true, required: false }, + ], + values: { name: 'Alice', enabled: true }, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get('name')).toBe('Alice') + expect(parsed.searchParams.get('enabled')).toBe('true') + }) + + it('should serialize checkbox false and empty string values in launch URL', async () => { + const url = await buildWorkflowLaunchUrl({ + accessibleUrl: 'https://example.com/app/workflow/token-1', + variables: [ + { variable: 'flag', label: 'Flag', type: InputVarType.checkbox, hide: true, required: false }, + { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + ], + values: { flag: false, empty: '' }, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get('flag')).toBe('false') + expect(parsed.searchParams.get('empty')).toBe('') + }) + + it('should generate an iframe snippet with the provided URL', () => { + const snippet = getEmbeddedIframeSnippet('https://example.com/chatbot/token-1') + expect(snippet).toContain('src="https://example.com/chatbot/token-1"') + expect(snippet).toContain('frameborder="0"') + expect(snippet).toContain('allow="microphone"') + }) + + it('should generate an embedded script snippet with inputs', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'abc123', + primaryColor: '#FF0000', + isTestEnv: true, + inputValues: { name: 'Alice', count: '5' }, + }) + + expect(snippet).toContain('token: \'abc123\'') + expect(snippet).toContain('isDev: true') + expect(snippet).toContain('name: "Alice"') + expect(snippet).toContain('count: "5"') + expect(snippet).toContain('background-color: #FF0000') + }) + + it('should generate an embedded script snippet with empty inputs comment', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'abc123', + primaryColor: '#1C64F2', + inputValues: {}, + }) + + expect(snippet).toContain('// You can define the inputs from the Start node here') + expect(snippet).not.toContain('isDev: true') + }) + + it('should compress and encode base64 using CompressionStream when available', async () => { + const result = await compressAndEncodeBase64('hello') + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('should fallback to plain base64 when CompressionStream is unavailable', async () => { + const original = globalThis.CompressionStream + // @ts-expect-error remove for test + delete globalThis.CompressionStream + + const result = await compressAndEncodeBase64('hello') + expect(result).toBe(btoa('hello')) + + globalThis.CompressionStream = original + }) + + it('should identify supported workflow launch input types', () => { + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.textInput, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.paragraph, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.select, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.number, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.checkbox, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.json, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.jsonObject, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.url, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.files, hide: true, required: false })).toBe(false) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.singleFile, hide: true, required: false })).toBe(false) + }) + + it('should coerce numeric defaults to string in createWorkflowLaunchInitialValues', () => { + const result = createWorkflowLaunchInitialValues([ + { variable: 'count', label: 'Count', type: InputVarType.number, hide: true, required: false, default: 42 }, + { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + ]) + + expect(result).toEqual({ count: '42', empty: '' }) + }) }) diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 2f730ad278..a6bacce887 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' @@ -17,7 +18,7 @@ const mockSetAppDetail = vi.fn() const mockOnChangeStatus = vi.fn() const mockOnGenerateCode = vi.fn() -let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null +let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array> } }> } } | null = null let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] } let mockAppDetail: AppDetailResponse | undefined @@ -25,6 +26,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) vi.mock('@/context/app-context', () => ({ @@ -164,6 +166,182 @@ describe('AppCard', () => { expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank') }) + it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open the chat web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Chat Secret'), { + target: { value: 'chat-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`, + '_blank', + ) + }) + }) + it('should show the access-control not-set badge when specific access has no subjects', () => { render( { }) it('should report refresh failures from access control updates', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed')) render( diff --git a/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx new file mode 100644 index 0000000000..309df540a6 --- /dev/null +++ b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx @@ -0,0 +1,214 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' + +describe('WorkflowHiddenInputFields', () => { + const onValueChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render a text input with label and placeholder', () => { + render( + , + ) + + const input = screen.getByLabelText('Full Name') + expect(input).toHaveValue('Alice') + + fireEvent.change(input, { target: { value: 'Bob' } }) + expect(onValueChange).toHaveBeenCalledWith('name', 'Bob') + }) + + it('should render a number input for number-typed variables', () => { + render( + , + ) + + const input = screen.getByLabelText('Count') + expect(input).toHaveAttribute('type', 'number') + + fireEvent.change(input, { target: { value: '10' } }) + expect(onValueChange).toHaveBeenCalledWith('count', '10') + }) + + it('should render a checkbox input without a separate label element above', () => { + render( + , + ) + + const checkbox = screen.getByRole('checkbox') + expect(checkbox).toBeChecked() + expect(screen.getByText('Enable Feature')).toBeInTheDocument() + + fireEvent.click(checkbox) + expect(onValueChange).toHaveBeenCalledWith('enabled', false) + }) + + it('should render a select dropdown for select-typed variables', () => { + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Color' })).toBeInTheDocument() + }) + + it('should render a textarea for paragraph-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Description') + expect(textarea).toHaveValue('Hello world') + + fireEvent.change(textarea, { target: { value: 'Updated' } }) + expect(onValueChange).toHaveBeenCalledWith('description', 'Updated') + }) + + it('should render a textarea for json-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Config JSON') + expect(textarea).toHaveValue('{"key": "value"}') + }) + + it('should render a textarea for jsonObject-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Schema') + expect(textarea).toHaveValue('{}') + }) + + it('should use the variable key as label when label is not a string', () => { + render( + , + ) + + expect(screen.getByText('my_var')).toBeInTheDocument() + }) + + it('should use the custom fieldIdPrefix for element ids', () => { + const { container } = render( + , + ) + + expect(container.querySelector('#custom-prefix-token')).toBeInTheDocument() + }) + + it('should render empty string for non-string fieldValue in text inputs', () => { + render( + , + ) + + const input = screen.getByLabelText('Flag') + expect(input).toHaveValue('') + }) +}) diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index 8fef355f34..8db5193f2d 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -1,7 +1,11 @@ /* eslint-disable react-refresh/only-export-components */ import type { TFunction } from 'i18next' -import type { ComponentType, ReactNode } from 'react' -import type { OverviewOperationKey } from './app-card-utils' +import type { ComponentType, FormEvent, ReactNode } from 'react' +import type { + OverviewOperationKey, + WorkflowHiddenStartVariable, + WorkflowLaunchInputValue, +} from './app-card-utils' import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -15,12 +19,19 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' -import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' +import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' +import { Trans } from 'react-i18next' import CopyFeedback from '@/app/components/base/copy-feedback' import Divider from '@/app/components/base/divider' import ShareQRCode from '@/app/components/base/qrcode' @@ -31,6 +42,7 @@ import CustomizeModal from './customize' import EmbeddedModal from './embedded' import SettingsModal from './settings' import style from './style.module.css' +import WorkflowHiddenInputFields from './workflow-hidden-input-fields' type AppInfo = AppDetailResponse & Partial @@ -50,6 +62,12 @@ type AppCardOperation = { onClick: () => void } +type LaunchConfigAction = { + label: string + disabled: boolean + onClick: () => void +} + const OPERATION_ICON_MAP: Record = { launch: RiExternalLinkLine, embedded: RiWindowLine, @@ -96,6 +114,65 @@ const MaybeTooltip = ({ ) } +export const WorkflowLaunchDialog = ({ + t, + open, + hiddenVariables, + unsupportedVariables, + values, + onOpenChange, + onValueChange, + onSubmit, +}: { + t: TFunction + open: boolean + hiddenVariables: WorkflowHiddenStartVariable[] + unsupportedVariables: WorkflowHiddenStartVariable[] + values: Record + onOpenChange: (open: boolean) => void + onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void + onSubmit: (event: FormEvent) => void +}) => { + if (!hiddenVariables.length && !unsupportedVariables.length) + return null + + return ( + + +
+ + {t('overview.appInfo.workflowLaunchHiddenInputs.title', { ns: 'appOverview' })} + + + }} + /> + +
+
+
+ +
+
+ + +
+
+
+
+ ) +} + export const createAppCardOperations = ({ operationKeys, t, @@ -251,20 +328,15 @@ export const AppCardAccessControlSection = ({ export const AppCardOperations = ({ t, operations, + launchConfigAction, }: { t: TFunction operations: AppCardOperation[] + launchConfigAction?: LaunchConfigAction }) => ( <> - {operations.map(({ key, label, Icon, disabled, onClick }) => ( -
- - ))} + ) + + if (key === 'launch' && launchConfigAction) { + return ( + + + + ) + } + + return ( + + ) + })} ) @@ -295,6 +431,7 @@ export const AppCardDialogs = ({ onCloseAccessControl, onSaveSiteConfig, onConfirmAccessControl, + hiddenInputs, }: { isApp: boolean appInfo: AppInfo @@ -310,6 +447,7 @@ export const AppCardDialogs = ({ onCloseAccessControl: () => void onSaveSiteConfig?: (params: ConfigParams) => Promise onConfirmAccessControl: () => Promise + hiddenInputs?: WorkflowHiddenStartVariable[] }) => { if (!isApp) return null @@ -329,6 +467,7 @@ export const AppCardDialogs = ({ onClose={onCloseEmbedded} appBaseUrl={appInfo.site?.app_base_url} accessToken={appInfo.site?.access_token} + hiddenInputs={hiddenInputs} /> type AppInfo = AppDetailResponse & Partial @@ -16,6 +23,7 @@ type WorkflowLike = { nodes?: Array<{ data?: { type?: string + variables?: InputVar[] } }> } @@ -42,10 +50,173 @@ const getCardAppMode = (mode: AppModeEnum) => { return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode } +const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set([ + InputVarType.textInput, + InputVarType.paragraph, + InputVarType.select, + InputVarType.number, + InputVarType.checkbox, + InputVarType.json, + InputVarType.jsonObject, + InputVarType.url, +]) + +const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => { + if (variable.type === InputVarType.checkbox) { + if (typeof variable.default === 'boolean') + return variable.default + + return String(variable.default).toLowerCase() === 'true' + } + + if (typeof variable.default === 'number') + return String(variable.default) + + return String(variable.default ?? '') +} + export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => { return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false } +export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => { + const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start) + return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true) +} + +export const getAppHiddenLaunchVariables = ({ + appInfo, + currentWorkflow, +}: { + appInfo: AppInfo + currentWorkflow: WorkflowLike +}) => { + if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode)) + return getWorkflowHiddenStartVariables(currentWorkflow) +} + +export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => { + return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type) +} + +export const createWorkflowLaunchInitialValues = (variables: WorkflowHiddenStartVariable[]) => { + return variables.reduce>((acc, variable) => { + acc[variable.variable] = coerceWorkflowLaunchDefaultValue(variable) + return acc + }, {}) +} + +export const buildWorkflowLaunchUrl = async ({ + accessibleUrl, + variables, + values, +}: { + accessibleUrl: string + variables: WorkflowHiddenStartVariable[] + values: Record +}) => { + const targetUrl = new URL(accessibleUrl, window.location.origin) + variables.forEach((variable) => { + const rawValue = values[variable.variable] + const serializedValue = variable.type === InputVarType.checkbox + ? String(Boolean(rawValue)) + : String(rawValue ?? '') + + targetUrl.searchParams.set(variable.variable, serializedValue) + }) + + return targetUrl.toString() +} + +export const getEmbeddedIframeSnippet = (iframeUrl: string) => + `` + +const getScriptInputsContent = (values: Record) => { + const entries = Object.entries(values) + + if (!entries.length) { + return `{ + // You can define the inputs from the Start node here + // key is the variable name + // e.g. + // name: "NAME" + }` + } + + return `{ +${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\n')} + }` +} + +export const getEmbeddedScriptSnippet = ({ + url, + token, + primaryColor, + isTestEnv, + inputValues, +}: { + url: string + token: string + primaryColor: string + isTestEnv?: boolean + inputValues: Record +}) => + ` + +` + +export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}` + +export const compressAndEncodeBase64 = async (input: string) => { + const uint8Array = new TextEncoder().encode(input) + if (typeof CompressionStream === 'undefined') + return btoa(String.fromCharCode(...uint8Array)) + + const compressedStream = new Response( + new Blob([uint8Array]) + .stream() + .pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer() + const compressedUint8Array = new Uint8Array(await compressedStream) + return btoa(String.fromCharCode(...compressedUint8Array)) +} + export const getAppCardDisplayState = ({ appInfo, cardType, diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index b7ec4a2d81..9b1fc3a032 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -1,4 +1,5 @@ 'use client' +import type { WorkflowLaunchInputValue } from './app-card-utils' import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -28,11 +29,16 @@ import { AppCardOperations, AppCardUrlSection, createAppCardOperations, + WorkflowLaunchDialog, } from './app-card-sections' import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, getAppCardDisplayState, getAppCardOperationKeys, + getAppHiddenLaunchVariables, isAppAccessConfigured, + isWorkflowLaunchInputSupported, } from './app-card-utils' export type IAppCardProps = { @@ -63,7 +69,8 @@ function AppCard({ const router = useRouter() const pathname = usePathname() const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() - const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '') + const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT + const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '') const docLink = useDocLink() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) @@ -73,6 +80,8 @@ function AppCard({ const [genLoading, setGenLoading] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showAccessControl, setShowAccessControl] = useState(false) + const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false) + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appAccessSubjects } = useAppWhiteListSubjects( @@ -98,6 +107,25 @@ function AppCard({ () => isAppAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail], ) + const hiddenLaunchVariables = useMemo( + () => getAppHiddenLaunchVariables({ + appInfo, + currentWorkflow, + }) || [], + [appInfo, currentWorkflow], + ) + const supportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), + [hiddenLaunchVariables], + ) + const unsupportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + [hiddenLaunchVariables], + ) + const initialWorkflowLaunchValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), + [supportedWorkflowLaunchVariables], + ) const onGenCode = async () => { if (!onGenerateCode) @@ -139,6 +167,31 @@ function AppCard({ window.open(cardState.accessibleUrl, '_blank') }, [cardState.accessibleUrl]) + const handleOpenWorkflowLaunchDialog = useCallback(() => { + setWorkflowLaunchValues(initialWorkflowLaunchValues) + setShowWorkflowLaunchDialog(true) + }, [initialWorkflowLaunchValues]) + + const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues(prev => ({ + ...prev, + [variable]: value, + })) + }, []) + + const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent) => { + event.preventDefault() + + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: cardState.accessibleUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) + + window.open(targetUrl, '_blank') + setShowWorkflowLaunchDialog(false) + }, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues]) + const handleOpenCustomize = useCallback(() => { setShowCustomizeModal(true) }, []) @@ -304,7 +357,17 @@ function AppCard({ {!cardState.isMinimalState && (
{!isApp && } - + 0 + ? { + label: t('operation.config', { ns: 'common' }), + disabled: triggerModeDisabled || !cardState.runningStatus, + onClick: handleOpenWorkflowLaunchDialog, + } + : undefined} + />
)}
@@ -323,6 +386,17 @@ function AppCard({ onCloseAccessControl={() => setShowAccessControl(false)} onSaveSiteConfig={onSaveSiteConfig} onConfirmAccessControl={handleAccessControlUpdate} + hiddenInputs={hiddenLaunchVariables} + /> +
) diff --git a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx index 0a843c26fd..a6e391cb0e 100644 --- a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx @@ -1,10 +1,11 @@ import type { SiteInfo } from '@/models/share' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import copy from 'copy-to-clipboard' import * as React from 'react' - import { act } from 'react' -import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { InputVarType } from '@/app/components/workflow/types' import Embedded from '../index' vi.mock('../style.module.css', () => ({ @@ -46,6 +47,7 @@ vi.mock('@/context/app-context', () => ({ })) const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) const mockedCopy = vi.mocked(copy) +const originalCompressionStream = globalThis.CompressionStream const siteInfo: SiteInfo = { title: 'test site', @@ -70,6 +72,22 @@ const getCopyButton = () => { } describe('Embedded', () => { + beforeAll(() => { + class MockCompressionStream { + readable: ReadableStream + writable: WritableStream + + constructor() { + const transformStream = new TransformStream() + this.readable = transformStream.readable + this.writable = transformStream.writable + } + } + + // @ts-expect-error test polyfill + globalThis.CompressionStream = MockCompressionStream + }) + afterEach(() => { vi.clearAllMocks() mockWindowOpen.mockClear() @@ -77,6 +95,7 @@ describe('Embedded', () => { afterAll(() => { mockWindowOpen.mockRestore() + globalThis.CompressionStream = originalCompressionStream }) it('builds theme and copies iframe snippet', async () => { @@ -84,14 +103,20 @@ describe('Embedded', () => { render() }) + await waitFor(() => { + expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument() + }) + const actionButton = getCopyButton() const innerDiv = actionButton.querySelector('div') - act(() => { + await act(async () => { fireEvent.click(innerDiv ?? actionButton) }) expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted) - expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + }) }) it('opens chrome plugin store link when chrome option selected', async () => { @@ -116,4 +141,106 @@ describe('Embedded', () => { 'noopener,noreferrer', ) }) + + it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => { + render( + , + ) + + expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!) + }) + + await waitFor(() => { + expect(screen.getByLabelText('Secret')).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + }) + + expect(document.querySelector('pre')?.textContent ?? '').toContain('/chatbot/token') + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('/chatbot/token?secret=dG9wLXNlY3JldA%3D%3D') + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[1]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('secret: "top-secret"') + }) + }) + + it('copies script content when scripts option is selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[1]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('token: \'token\'') + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + await act(async () => { + fireEvent.click(innerDiv ?? actionButton) + }) + + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('token: \'token\'')) + }) + }) + + it('copies chrome plugin URL (without prefix) when chromePlugin option is selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[2]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('ChatBot URL:') + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + await act(async () => { + fireEvent.click(innerDiv ?? actionButton) + }) + + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + expect(mockedCopy).not.toHaveBeenCalledWith(expect.stringContaining('ChatBot URL:')) + }) + }) }) diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx index 12203178f1..112848760b 100644 --- a/web/app/components/app/overview/embedded/index.tsx +++ b/web/app/components/app/overview/embedded/index.tsx @@ -1,88 +1,46 @@ +import type { MutableRefObject } from 'react' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { + RiArrowDownSLine, + RiArrowRightSLine, +} from '@remixicon/react' import copy from 'copy-to-clipboard' -import * as React from 'react' -import { useState } from 'react' +import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' -import { IS_CE_EDITION } from '@/config' +import { InputVarType } from '@/app/components/workflow/types' import { useAppContext } from '@/context/app-context' import { basePath } from '@/utils/var' +import { + compressAndEncodeBase64, + createWorkflowLaunchInitialValues, + getChromePluginContent, + getEmbeddedIframeSnippet, + getEmbeddedScriptSnippet, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' import style from './style.module.css' type Props = { siteInfo?: SiteInfo isShow: boolean onClose: () => void - accessToken: string - appBaseUrl: string + accessToken?: string + appBaseUrl?: string + hiddenInputs?: WorkflowHiddenStartVariable[] className?: string } -const OPTION_MAP = { - iframe: { - getContent: (url: string, token: string) => - ``, - }, - scripts: { - getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) => - ` - -`, - }, - chromePlugin: { - getContent: (url: string, token: string) => `ChatBot URL: ${url}${basePath}/chatbot/${token}`, - }, -} +const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const const prefixEmbedded = 'overview.appInfo.embedded' -type Option = keyof typeof OPTION_MAP - -const OPTIONS: Option[] = ['iframe', 'scripts', 'chromePlugin'] +type Option = typeof OPTION_KEYS[number] const optionIconClassName: Record = { iframe: style.iframeIcon!, @@ -90,38 +48,274 @@ const optionIconClassName: Record = { chromePlugin: style.chromePluginIcon!, } -const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => { +const getSerializedHiddenInputValue = ( + variable: WorkflowHiddenStartVariable, + values: Record, +) => { + const rawValue = values[variable.variable] + if (variable.type === InputVarType.checkbox) + return String(Boolean(rawValue)) + + return String(rawValue ?? '') +} + +const buildEmbeddedIframeUrl = async ({ + appBaseUrl, + accessToken, + variables, + values, +}: { + appBaseUrl: string + accessToken: string + variables: WorkflowHiddenStartVariable[] + values: Record +}) => { + const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin) + + await Promise.all(variables.map(async (variable) => { + iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values))) + })) + + return iframeUrl.toString() +} + +const AsyncEmbeddedOptionContent = ({ + option, + iframeUrlPromise, + latestResolvedIframeUrlRef, +}: { + option: Option + iframeUrlPromise: Promise + latestResolvedIframeUrlRef: MutableRefObject +}) => { + const iframeUrl = use(iframeUrlPromise) + latestResolvedIframeUrlRef.current = iframeUrl + + if (option === 'chromePlugin') + return getChromePluginContent(iframeUrl) + + return getEmbeddedIframeSnippet(iframeUrl) +} + +const EmbeddedContent = ({ + siteInfo, + appBaseUrl, + accessToken, + hiddenInputs, +}: Required> & Pick) => { const { t } = useTranslation() + const supportedHiddenInputs = useMemo( + () => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported), + [hiddenInputs], + ) + const initialHiddenInputValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedHiddenInputs), + [supportedHiddenInputs], + ) const [option, setOption] = useState