From c5ac191a7953d59cc772c0c9e3daa32b58e08ffa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 13:11:09 +0900 Subject: [PATCH 01/53] chore(deps): bump gitpython from 3.1.49 to 3.1.50 in /api (#35958) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index c3db4b514c..10487f6bac 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -2660,14 +2660,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.49" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/63/210aaa302d6a0a78daa67c5c15bbac2cad361722841278b0209b6da20855/gitpython-3.1.49.tar.gz", hash = "sha256:42f9399c9eb33fc581014bedd76049dfbaf6375aa2a5754575966387280315e1", size = 219367, upload-time = "2026-04-29T00:31:20.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/6f/b842bfa6f21d6f87c57f9abf7194225e55279d96d869775e19e9f7236fc5/gitpython-3.1.49-py3-none-any.whl", hash = "sha256:024b0422d7f84d15cd794844e029ffebd4c5d42a7eb9b936b458697ef550a02c", size = 212190, upload-time = "2026-04-29T00:31:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] [[package]] From 5ebeb34feb56fba200c90a99ba0c3083e7bdac29 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 12:35:29 +0800 Subject: [PATCH 02/53] fix(web): forward csp nonce to theme script (#35960) --- web/app/layout.tsx | 4 ++++ web/proxy.ts | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 1ec9217296..8bb2069aaf 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -5,9 +5,11 @@ import { Provider as JotaiProvider } from 'jotai/react' import { ThemeProvider } from 'next-themes' import { NuqsAdapter } from 'nuqs/adapters/next/app' import AmplitudeProvider from '@/app/components/base/amplitude' +import { IS_PROD } from '@/config' import { TanstackQueryInitializer } from '@/context/query-client' import { getDatasetMap } from '@/env' import { getLocaleOnServer } from '@/i18n-config/server' +import { headers } from '@/next/headers' import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder' import CreateAppAttributionBootstrap from './components/create-app-attribution-bootstrap' import { AgentationLoader } from './components/devtools/agentation-loader' @@ -32,6 +34,7 @@ const LocaleLayout = async ({ }) => { const locale = await getLocaleOnServer() const datasetMap = getDatasetMap() + const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? undefined : undefined return ( @@ -64,6 +67,7 @@ const LocaleLayout = async ({ defaultTheme="system" enableSystem disableTransitionOnChange + nonce={nonce} > diff --git a/web/proxy.ts b/web/proxy.ts index 983713fd0e..d735c9f568 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -18,15 +18,16 @@ const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string) export function proxy(request: NextRequest) { const { pathname } = request.nextUrl const requestHeaders = new Headers(request.headers) - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }) const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production' - if (!isWhiteListEnabled) + if (!isWhiteListEnabled) { + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }) return wrapResponseWithXFrameOptions(response, pathname) + } const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}` const nonce = Buffer.from(crypto.randomUUID()).toString('base64') @@ -60,6 +61,12 @@ export function proxy(request: NextRequest) { contentSecurityPolicyHeaderValue, ) + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }) + response.headers.set( 'Content-Security-Policy', contentSecurityPolicyHeaderValue, From d5ad6aedc0054162a95c4a7ac02050c48d74ea90 Mon Sep 17 00:00:00 2001 From: chariri Date: Sat, 9 May 2026 13:52:45 +0900 Subject: [PATCH 03/53] fix(swagger): add util to convert BaseModel to schema for query params (#35959) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/common/schema.py | 117 ++++++++++++++++++ .../console/explore/recommended_app.py | 6 +- api/openapi/markdown/console-swagger.md | 4 +- .../controllers/common/test_schema.py | 85 +++++++++++-- 4 files changed, 196 insertions(+), 16 deletions(-) diff --git a/api/controllers/common/schema.py b/api/controllers/common/schema.py index 0c5e23c29c..57070f1c80 100644 --- a/api/controllers/common/schema.py +++ b/api/controllers/common/schema.py @@ -6,7 +6,9 @@ These helpers keep that translation centralized so models registered through `register_schema_models` emit resolvable Swagger 2.0 references. """ +from collections.abc import Mapping from enum import StrEnum +from typing import Any, NotRequired, TypedDict from flask_restx import Namespace from pydantic import BaseModel, TypeAdapter @@ -14,6 +16,26 @@ from pydantic import BaseModel, TypeAdapter DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" +QueryParamDoc = TypedDict( + "QueryParamDoc", + { + "in": NotRequired[str], + "type": NotRequired[str], + "items": NotRequired[dict[str, object]], + "required": NotRequired[bool], + "description": NotRequired[str], + "enum": NotRequired[list[object]], + "default": NotRequired[object], + "minimum": NotRequired[int | float], + "maximum": NotRequired[int | float], + "minLength": NotRequired[int], + "maxLength": NotRequired[int], + "minItems": NotRequired[int], + "maxItems": NotRequired[int], + }, +) + + def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None: """Register a JSON schema and promote any nested Pydantic `$defs`.""" @@ -69,9 +91,104 @@ def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None: ) +def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]: + """Build Flask-RESTX query parameter docs from a flat Pydantic model. + + `Namespace.expect()` treats Pydantic schema models as request bodies, so GET + endpoints should keep runtime validation on the Pydantic model and feed this + derived mapping to `Namespace.doc(params=...)` for Swagger documentation. + """ + + schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) + properties = schema.get("properties", {}) + if not isinstance(properties, Mapping): + return {} + + required = schema.get("required", []) + required_names = set(required) if isinstance(required, list) else set() + + params: dict[str, QueryParamDoc] = {} + for name, property_schema in properties.items(): + if not isinstance(name, str) or not isinstance(property_schema, Mapping): + continue + + params[name] = _query_param_from_property(property_schema, required=name in required_names) + + return params + + +def _query_param_from_property(property_schema: Mapping[str, Any], *, required: bool) -> QueryParamDoc: + param_schema = _nullable_property_schema(property_schema) + param_doc: QueryParamDoc = {"in": "query", "required": required} + + description = param_schema.get("description") + if isinstance(description, str): + param_doc["description"] = description + + schema_type = param_schema.get("type") + if isinstance(schema_type, str) and schema_type in {"array", "boolean", "integer", "number", "string"}: + param_doc["type"] = schema_type + if schema_type == "array": + items = param_schema.get("items") + if isinstance(items, Mapping): + item_type = items.get("type") + if isinstance(item_type, str): + param_doc["items"] = {"type": item_type} + + enum = param_schema.get("enum") + if isinstance(enum, list): + param_doc["enum"] = enum + + default = param_schema.get("default") + if default is not None: + param_doc["default"] = default + + minimum = param_schema.get("minimum") + if isinstance(minimum, int | float): + param_doc["minimum"] = minimum + + maximum = param_schema.get("maximum") + if isinstance(maximum, int | float): + param_doc["maximum"] = maximum + + min_length = param_schema.get("minLength") + if isinstance(min_length, int): + param_doc["minLength"] = min_length + + max_length = param_schema.get("maxLength") + if isinstance(max_length, int): + param_doc["maxLength"] = max_length + + min_items = param_schema.get("minItems") + if isinstance(min_items, int): + param_doc["minItems"] = min_items + + max_items = param_schema.get("maxItems") + if isinstance(max_items, int): + param_doc["maxItems"] = max_items + + return param_doc + + +def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str, Any]: + any_of = property_schema.get("anyOf") + if not isinstance(any_of, list): + return property_schema + + non_null_candidates = [ + candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null" + ] + + if len(non_null_candidates) == 1: + return {**property_schema, **non_null_candidates[0]} + + return property_schema + + __all__ = [ "DEFAULT_REF_TEMPLATE_SWAGGER_2_0", "get_or_create_model", + "query_params_from_model", "register_enum_models", "register_schema_model", "register_schema_models", diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index fa65c8daf1..572f9773a1 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -5,7 +5,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field, computed_field, field_validator from constants.languages import languages -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.wraps import account_initialization_required from fields.base import ResponseModel @@ -15,7 +15,7 @@ from services.recommended_app_service import RecommendedAppService class RecommendedAppsQuery(BaseModel): - language: str | None = Field(default=None) + language: str | None = Field(default=None, description="Language code for recommended app localization") class RecommendedAppInfoResponse(ResponseModel): @@ -74,7 +74,7 @@ register_schema_models( @console_ns.route("/explore/apps") class RecommendedAppListApi(Resource): - @console_ns.expect(console_ns.models[RecommendedAppsQuery.__name__]) + @console_ns.doc(params=query_params_from_model(RecommendedAppsQuery)) @console_ns.response(200, "Success", console_ns.models[RecommendedAppListResponse.__name__]) @login_required @account_initialization_required diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index a69cecd83c..f4897e93c5 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -5507,7 +5507,7 @@ Delete an API key for a dataset | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [RecommendedAppsQuery](#recommendedappsquery) | +| language | query | Language code for recommended app localization | No | string | ##### Responses @@ -13289,7 +13289,7 @@ Default value types for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| language | | | No | +| language | | Language code for recommended app localization | No | #### RelatedAppList diff --git a/api/tests/unit_tests/controllers/common/test_schema.py b/api/tests/unit_tests/controllers/common/test_schema.py index 6cf36e3bce..575f8c839c 100644 --- a/api/tests/unit_tests/controllers/common/test_schema.py +++ b/api/tests/unit_tests/controllers/common/test_schema.py @@ -1,10 +1,11 @@ import sys from enum import StrEnum +from typing import Literal from unittest.mock import MagicMock, patch import pytest from flask_restx import Namespace -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field class UserModel(BaseModel): @@ -25,6 +26,27 @@ class ParentModel(BaseModel): child: ChildModel +class StatusEnum(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + +class PriorityEnum(StrEnum): + HIGH = "high" + LOW = "low" + + +class QueryModel(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + page: int = Field(default=1, ge=1, le=100, description="Page number") + keyword: str | None = Field(default=None, min_length=1, max_length=50, description="Search keyword") + status: Literal["active", "inactive"] | None = Field(default=None, description="Status filter") + app_id: str = Field(..., alias="appId", description="Application ID") + tag_ids: list[str] = Field(default_factory=list, min_length=1, max_length=3, description="Tag IDs") + ambiguous: int | str | None = Field(default=None, description="Ambiguous query parameter") + + @pytest.fixture(autouse=True) def mock_console_ns(): """Mock the console_ns to avoid circular imports during test collection.""" @@ -124,16 +146,6 @@ def test_register_schema_models_calls_register_schema_model(monkeypatch: pytest. ] -class StatusEnum(StrEnum): - ACTIVE = "active" - INACTIVE = "inactive" - - -class PriorityEnum(StrEnum): - HIGH = "high" - LOW = "low" - - def test_get_or_create_model_returns_existing_model(mock_console_ns): from controllers.common.schema import get_or_create_model @@ -211,3 +223,54 @@ def test_register_enum_models_uses_correct_ref_template(): # Verify the schema contains enum values assert "enum" in schema or "anyOf" in schema + + +def test_query_params_from_model_builds_flask_restx_doc_params(): + from controllers.common.schema import query_params_from_model + + params = query_params_from_model(QueryModel) + + assert params["page"] == { + "in": "query", + "required": False, + "description": "Page number", + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 100, + } + assert params["keyword"] == { + "in": "query", + "required": False, + "description": "Search keyword", + "type": "string", + "minLength": 1, + "maxLength": 50, + } + assert params["status"] == { + "in": "query", + "required": False, + "description": "Status filter", + "type": "string", + "enum": ["active", "inactive"], + } + assert params["appId"] == { + "in": "query", + "required": True, + "description": "Application ID", + "type": "string", + } + assert params["tag_ids"] == { + "in": "query", + "required": False, + "description": "Tag IDs", + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 3, + } + assert params["ambiguous"] == { + "in": "query", + "required": False, + "description": "Ambiguous query parameter", + } From 2bb1f0906b67dc573aa3c2d4abb0fac62100c309 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 13:26:21 +0800 Subject: [PATCH 04/53] refactor(web): migrate legacy tooltip callers (#35961) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 174 +-------- packages/dify-ui/README.md | 7 + .../billing/cloud-plan-payment-flow.test.tsx | 22 +- .../specific-groups-or-members.tsx | 15 +- .../app/configuration/config-var/index.tsx | 12 +- .../config/agent/agent-setting/item-panel.tsx | 13 +- .../app/log/__tests__/list.spec.tsx | 4 - .../app/overview/__tests__/app-card.spec.tsx | 11 +- .../components/app/workflow-log/detail.tsx | 32 +- .../apps/__tests__/app-card.spec.tsx | 5 - .../__tests__/progress-tooltip.spec.tsx | 8 +- .../chat/citation/__tests__/tooltip.spec.tsx | 16 +- .../chat/chat/citation/progress-tooltip.tsx | 18 +- .../base/chat/chat/citation/tooltip.tsx | 29 +- web/app/components/base/copy-icon/index.tsx | 29 +- .../annotation-reply/config-param.tsx | 10 +- .../__tests__/param-config-content.spec.tsx | 3 +- .../text-to-speech/param-config-content.tsx | 21 +- web/app/components/base/file-thumb/index.tsx | 68 ++-- .../form/components/__tests__/label.spec.tsx | 4 +- .../components/base/form/components/label.tsx | 12 +- .../components/base/input-with-copy/index.tsx | 32 +- .../components/base/tooltip/TooltipManager.ts | 27 -- .../tooltip/__tests__/TooltipManager.spec.ts | 129 ------- .../base/tooltip/__tests__/content.spec.tsx | 49 --- .../base/tooltip/__tests__/index.spec.tsx | 333 ------------------ web/app/components/base/tooltip/content.tsx | 22 -- .../components/base/tooltip/index.stories.tsx | 60 ---- web/app/components/base/tooltip/index.tsx | 231 ------------ .../cloud-plan-item/__tests__/index.spec.tsx | 2 +- .../list/__tests__/index.spec.tsx | 7 +- .../list/item/__tests__/index.spec.tsx | 7 +- .../list/item/__tests__/tooltip.spec.tsx | 7 +- .../cloud-plan-item/list/item/tooltip.tsx | 21 +- .../documents/components/operations.tsx | 42 ++- .../file-list/list/__tests__/item.spec.tsx | 6 - .../online-drive/file-list/list/item.tsx | 70 ++-- .../processing/embedding-process/index.tsx | 21 +- .../detail/completed/display-toggle.tsx | 41 ++- .../detail/completed/segment-card/index.tsx | 66 ++-- .../metadata/edit-metadata-batch/modal.tsx | 16 +- .../dataset-metadata-drawer.tsx | 6 +- ...itch-credential-in-load-balancing.spec.tsx | 8 +- .../model-auth/config-provider.tsx | 12 +- .../switch-credential-in-load-balancing.tsx | 12 +- .../__tests__/status-indicators.spec.tsx | 18 +- .../status-indicators.tsx | 50 ++- .../__tests__/popup-item.spec.tsx | 4 - .../provider-added-card/model-list-item.tsx | 16 +- .../subscription-list/subscription-card.tsx | 35 +- .../__tests__/reasoning-config-form.spec.tsx | 8 +- .../components/reasoning-config-form.tsx | 55 +-- .../tool-selector/components/tool-item.tsx | 38 +- .../components/plugins/plugin-item/index.tsx | 20 +- .../components/panel/input-field/index.tsx | 10 +- .../config-credentials.tsx | 32 +- .../workflow-tool/__tests__/index.spec.tsx | 16 - .../block-selector/__tests__/tabs.spec.tsx | 21 +- .../workflow/block-selector/tabs.tsx | 31 +- .../mcp-tool-not-support-tooltip.tsx | 21 +- .../components/switch-plugin-version.tsx | 157 +++++---- .../workflow/nodes/iteration-start/index.tsx | 22 +- .../search-method-option.tsx | 13 +- .../metadata/metadata-filter/index.tsx | 12 +- .../workflow/nodes/loop-start/index.tsx | 22 +- .../nodes/parameter-extractor/panel.tsx | 18 +- .../__tests__/integration.spec.tsx | 4 +- .../__tests__/node.spec.tsx | 23 +- .../components/advanced-setting.tsx | 18 +- .../nodes/question-classifier/node.tsx | 23 +- .../components/trigger-form/item.tsx | 18 +- .../trigger-webhook/__tests__/panel.spec.tsx | 4 - .../workflow/nodes/trigger-webhook/panel.tsx | 58 +-- .../panel/env-panel/variable-modal.tsx | 18 +- web/app/components/workflow/run/node.tsx | 15 +- .../workflow/variable-inspect/listening.tsx | 48 +-- .../components/nodes/base.tsx | 22 +- .../nodes/iteration-start/index.tsx | 12 +- .../components/nodes/loop-start/index.tsx | 12 +- web/docs/overlay-migration.md | 6 +- web/eslint.constants.mjs | 7 - 81 files changed, 841 insertions(+), 1806 deletions(-) delete mode 100644 web/app/components/base/tooltip/TooltipManager.ts delete mode 100644 web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts delete mode 100644 web/app/components/base/tooltip/__tests__/content.spec.tsx delete mode 100644 web/app/components/base/tooltip/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/tooltip/content.tsx delete mode 100644 web/app/components/base/tooltip/index.stories.tsx delete mode 100644 web/app/components/base/tooltip/index.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cb41ef5f83..2326e92d2f 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -272,11 +272,6 @@ "count": 1 } }, - "web/app/components/app/app-access-control/specific-groups-or-members.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/app-publisher/features-wrapper.tsx": { "ts/no-explicit-any": { "count": 4 @@ -323,11 +318,6 @@ "count": 4 } }, - "web/app/components/app/configuration/config-var/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config-var/select-var-type.tsx": { "ts/no-explicit-any": { "count": 1 @@ -341,11 +331,6 @@ "count": 1 } }, - "web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config/agent/agent-tools/index.tsx": { "ts/no-explicit-any": { "count": 9 @@ -593,11 +578,6 @@ "count": 2 } }, - "web/app/components/app/workflow-log/detail.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/workflow-log/filter.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -967,11 +947,6 @@ "count": 1 } }, - "web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx": { "ts/no-explicit-any": { "count": 3 @@ -1005,11 +980,6 @@ "count": 2 } }, - "web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/features/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -2096,11 +2066,6 @@ "count": 4 } }, - "web/app/components/datasets/documents/components/operations.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/components/rename-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -2116,11 +2081,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx": { "react/set-state-in-effect": { "count": 5 @@ -2166,11 +2126,6 @@ "count": 2 } }, - "web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/steps/index.ts": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -2196,11 +2151,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/display-toggle.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 5 @@ -2217,11 +2167,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/segment-card/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/context.ts": { "ts/no-explicit-any": { "count": 1 @@ -2310,7 +2255,7 @@ }, "web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 } }, "web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": { @@ -2338,7 +2283,7 @@ }, "web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": { "no-restricted-imports": { - "count": 3 + "count": 2 } }, "web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": { @@ -2571,11 +2516,6 @@ "count": 4 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/model-provider-page/model-auth/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 6 @@ -2602,9 +2542,6 @@ } }, "web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 3 } @@ -2630,9 +2567,6 @@ } }, "web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -2647,11 +2581,6 @@ "count": 2 } }, - "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx": { "ts/no-explicit-any": { "count": 5 @@ -2900,11 +2829,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/subscription-list/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -2915,11 +2839,6 @@ "count": 7 } }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -2934,9 +2853,6 @@ } }, "web/app/components/plugins/plugin-item/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -3028,11 +2944,6 @@ "count": 1 } }, - "web/app/components/rag-pipeline/components/panel/input-field/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3193,7 +3104,7 @@ }, "web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 } }, "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { @@ -3380,11 +3291,6 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/tabs.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3651,11 +3557,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/memory-config.tsx": { "unicorn/prefer-number-properties": { "count": 1 @@ -3691,11 +3592,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts": { "ts/no-explicit-any": { "count": 8 @@ -4050,11 +3946,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/iteration-start/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/iteration/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -4075,11 +3966,6 @@ "count": 4 } }, - "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts": { "ts/no-explicit-any": { "count": 2 @@ -4113,11 +3999,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-retrieval/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -4240,11 +4121,6 @@ "count": 7 } }, - "web/app/components/workflow/nodes/loop-start/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx": { "ts/no-explicit-any": { "count": 1 @@ -4306,11 +4182,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/parameter-extractor/panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/parameter-extractor/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -4329,11 +4200,6 @@ "count": 9 } }, - "web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/question-classifier/components/class-item.tsx": { "react/set-state-in-effect": { "count": 1 @@ -4352,11 +4218,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/question-classifier/node.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/question-classifier/use-config.ts": { "react/set-state-in-effect": { "count": 2 @@ -4464,9 +4325,6 @@ } }, "web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -4522,11 +4380,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/trigger-webhook/panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/utils.ts": { "ts/no-explicit-any": { "count": 1 @@ -4637,9 +4490,6 @@ } }, "web/app/components/workflow/panel/env-panel/variable-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 4 }, @@ -4870,9 +4720,6 @@ } }, "web/app/components/workflow/variable-inspect/listening.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -4911,26 +4758,11 @@ "count": 5 } }, - "web/app/components/workflow/workflow-preview/components/nodes/base.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/workflow-preview/components/nodes/constants.ts": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx": { "erasable-syntax-only/enums": { "count": 1 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 2915fe5db7..c78faede89 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -99,6 +99,13 @@ See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for t - 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. +### Tooltip, infotip, and popover semantics + +- Use `Tooltip` only for short, non-interactive visual labels. The trigger must already have visible text or an `aria-label`; the tooltip is not the accessible name and must not contain links, buttons, forms, or structured prose. +- Use `Popover` for explanatory content, long text, rich layout, or anything users may need to reach on touch or with assistive technology. In `web/`, the `Infotip` wrapper is the preferred pattern for a `?` help glyph backed by `Popover`. +- Pick a `placement` and let the primitive own spacing. Avoid per-call-site offsets unless the component API explicitly needs a measured layout exception. +- When passing a Base UI trigger `render` prop, render a real ` - + + + + + )} + /> + + {t('runDetail.testWithParams', { ns: 'appLog' })} + + )} diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index c841617474..d61ca306ae 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -296,11 +296,6 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => { } }) -// Tooltip uses portals - minimal mock preserving popup content as title attribute -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children), -})) - // AppCardTags has tag API dependencies - mock for isolated testing vi.mock('@/features/tag-management/components/app-card-tags', () => ({ AppCardTags: ({ tags }: { tags?: { id: string, name: string }[] }) => { diff --git a/web/app/components/base/chat/chat/citation/__tests__/progress-tooltip.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/progress-tooltip.spec.tsx index a47123aafd..f53e7d15c3 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/progress-tooltip.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/progress-tooltip.spec.tsx @@ -55,7 +55,7 @@ describe('ProgressTooltip', () => { await user.hover(screen.getByTestId('progress-trigger-content')) - expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument() + expect(await screen.findByTestId('progress-tooltip-popup')).toBeInTheDocument() }) it('should hide the tooltip popup on mouse leave', async () => { @@ -74,7 +74,7 @@ describe('ProgressTooltip', () => { await user.hover(screen.getByTestId('progress-trigger-content')) - expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent(/hitScore/i) + expect(await screen.findByTestId('progress-tooltip-popup')).toHaveTextContent(/hitScore/i) }) it('should show the data value inside the tooltip popup', async () => { @@ -83,7 +83,7 @@ describe('ProgressTooltip', () => { await user.hover(screen.getByTestId('progress-trigger-content')) - expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent('0.8') + expect(await screen.findByTestId('progress-tooltip-popup')).toHaveTextContent('0.8') }) }) @@ -126,7 +126,7 @@ describe('ProgressTooltip', () => { await user.unhover(screen.getByTestId('progress-trigger-content')) await user.hover(screen.getByTestId('progress-trigger-content')) - expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument() + expect(await screen.findByTestId('progress-tooltip-popup')).toBeInTheDocument() }) it('should keep tooltip closed without any interaction', () => { diff --git a/web/app/components/base/chat/chat/citation/__tests__/tooltip.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/tooltip.spec.tsx index 45ac4b4fb4..58a3c5c654 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/tooltip.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/tooltip.spec.tsx @@ -41,7 +41,7 @@ describe('Tooltip', () => { await user.hover(screen.getByTestId('tooltip-trigger-content')) - expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Word Count') + expect(await screen.findByTestId('tooltip-popup')).toHaveTextContent('Word Count') }) it('should render the data value inside the tooltip popup', async () => { @@ -50,7 +50,7 @@ describe('Tooltip', () => { await user.hover(screen.getByTestId('tooltip-trigger-content')) - expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('99') + expect(await screen.findByTestId('tooltip-popup')).toHaveTextContent('99') }) it('should render a string data value inside the tooltip popup', async () => { @@ -59,7 +59,7 @@ describe('Tooltip', () => { await user.hover(screen.getByTestId('tooltip-trigger-content')) - expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('abc1234') + expect(await screen.findByTestId('tooltip-popup')).toHaveTextContent('abc1234') }) it('should render both text and data together inside the tooltip popup', async () => { @@ -68,7 +68,7 @@ describe('Tooltip', () => { await user.hover(screen.getByTestId('tooltip-trigger-content')) - const popup = screen.getByTestId('tooltip-popup') + const popup = await screen.findByTestId('tooltip-popup') expect(popup).toHaveTextContent('Characters') expect(popup).toHaveTextContent('55') }) @@ -90,10 +90,10 @@ describe('Tooltip', () => { const user = userEvent.setup() const { rerender } = render(} />) await user.hover(screen.getByTestId('tooltip-trigger-content')) - expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Original') + expect(await screen.findByTestId('tooltip-popup')).toHaveTextContent('Original') rerender(} />) - expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Updated') + expect(await screen.findByTestId('tooltip-popup')).toHaveTextContent('Updated') }) }) @@ -104,7 +104,7 @@ describe('Tooltip', () => { await user.hover(screen.getByTestId('tooltip-trigger-content')) - expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument() + expect(await screen.findByTestId('tooltip-popup')).toBeInTheDocument() }) it('should hide the tooltip popup on mouse leave', async () => { @@ -125,7 +125,7 @@ describe('Tooltip', () => { await user.unhover(screen.getByTestId('tooltip-trigger-content')) await user.hover(screen.getByTestId('tooltip-trigger-content')) - expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument() + expect(await screen.findByTestId('tooltip-popup')).toBeInTheDocument() }) }) diff --git a/web/app/components/base/chat/chat/citation/progress-tooltip.tsx b/web/app/components/base/chat/chat/citation/progress-tooltip.tsx index 75211b706e..be9a4b2661 100644 --- a/web/app/components/base/chat/chat/citation/progress-tooltip.tsx +++ b/web/app/components/base/chat/chat/citation/progress-tooltip.tsx @@ -4,7 +4,6 @@ import { TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' -import { useState } from 'react' import { useTranslation } from 'react-i18next' type ProgressTooltipProps = { @@ -15,22 +14,12 @@ const ProgressTooltip: FC = ({ data, }) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) return ( - + setOpen(true)} - onMouseLeave={() => setOpen(false)} - /> - )} + data-testid="progress-trigger-content" + className="flex grow items-center border-0 bg-transparent p-0 text-left" >
= ({ {t('chat.citation.hitScore', { ns: 'common' })} diff --git a/web/app/components/base/chat/chat/citation/tooltip.tsx b/web/app/components/base/chat/chat/citation/tooltip.tsx index e1d76a9383..f3460abd22 100644 --- a/web/app/components/base/chat/chat/citation/tooltip.tsx +++ b/web/app/components/base/chat/chat/citation/tooltip.tsx @@ -1,39 +1,27 @@ import type { FC } from 'react' import { - Tooltip as DifyTooltip, + Tooltip, TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' import * as React from 'react' -import { useState } from 'react' -type TooltipProps = { +type CitationTooltipProps = { data: number | string text: string icon: React.ReactNode } -const Tooltip: FC = ({ +const CitationTooltip: FC = ({ data, text, icon, }) => { - const [open, setOpen] = useState(false) - return ( - + setOpen(true)} - onMouseLeave={() => setOpen(false)} - /> - )} + data-testid="tooltip-trigger-content" + className="mr-6 flex items-center border-0 bg-transparent p-0 text-left" > {icon} {data} @@ -41,15 +29,14 @@ const Tooltip: FC = ({ {text} {' '} {data} - + ) } -export default Tooltip +export default CitationTooltip diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx index b0b4635a39..a770430580 100644 --- a/web/app/components/base/copy-icon/index.tsx +++ b/web/app/components/base/copy-icon/index.tsx @@ -1,8 +1,8 @@ 'use client' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useClipboard } from '@/hooks/use-clipboard' -import Tooltip from '../tooltip' type Props = { content: string @@ -25,14 +25,25 @@ const CopyIcon = ({ content }: Props) => { const safeTooltipText = tooltipText || '' return ( - -
- {!copied - ? () - : ()} -
+ + + {!copied + ? () + : ()} + + )} + /> + + {safeTooltipText} + ) } diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx index 0335587af0..16cbefe87a 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import * as React from 'react' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' export const Item: FC<{ title: string, tooltip: string, children: React.JSX.Element }> = ({ title, @@ -12,11 +12,9 @@ export const Item: FC<{ title: string, tooltip: string, children: React.JSX.Elem
{title}
- {tooltip}
- } - /> + + {tooltip} +
{children}
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx index 754bde98a6..b4d5beefa6 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx @@ -110,8 +110,7 @@ describe('ParamConfigContent', () => { const languageLabel = screen.getByText(/voice\.voiceSettings\.language/) expect(languageLabel)!.toBeInTheDocument() - const tooltip = languageLabel.parentElement as HTMLElement - expect(tooltip.querySelector('svg'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: /voice\.voiceSettings\.resolutionTooltip/ }))!.toBeInTheDocument() }) it('should display language listbox button', () => { diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index 199cbecccb..f7c3b738a9 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next' import { replace } from 'string-ts' import AudioBtn from '@/app/components/base/audio-btn' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import { languages } from '@/i18n-config/language' import { usePathname } from '@/next/navigation' import { useAppVoices } from '@/service/use-apps' @@ -89,17 +89,16 @@ const VoiceParamConfig = ({
{t('voice.voiceSettings.language', { ns: 'appDebug' })} - - {t('voice.voiceSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( -
- {item} -
- ))} + + {t('voice.voiceSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( +
+ {item}
- )} - /> + ))} +
) => { + const handleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() onClick?.(file) }, [onClick, file]) return ( - -
+ + { + isImage + ? ( + + ) + : ( + + ) + } + )} - onClick={handleClick} - > - { - isImage - ? ( - - ) - : ( - - ) - } -
+ /> + + {name} +
) } diff --git a/web/app/components/base/form/components/__tests__/label.spec.tsx b/web/app/components/base/form/components/__tests__/label.spec.tsx index a3f564dafe..99471e5171 100644 --- a/web/app/components/base/form/components/__tests__/label.spec.tsx +++ b/web/app/components/base/form/components/__tests__/label.spec.tsx @@ -41,8 +41,8 @@ describe('Label', () => { const tooltipText = 'Test Tooltip' render(
- } - triggerClassName="ml-0.5 w-4 h-4" - triggerTestId={`${htmlFor}-tooltip`} - /> + + {tooltip} + )}
) diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx index 2da2e547c2..38a7ceed9c 100644 --- a/web/app/components/base/input-with-copy/index.tsx +++ b/web/app/components/base/input-with-copy/index.tsx @@ -1,11 +1,11 @@ 'use client' import type { InputProps } from '../input' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useClipboard } from '@/hooks/use-clipboard' import ActionButton from '../action-button' -import Tooltip from '../tooltip' type InputWithCopyProps = { showCopyButton?: boolean @@ -64,18 +64,24 @@ const InputWithCopy = React.forwardRef(( onMouseLeave={reset} data-testid="copy-button-wrapper" > - - - {copied - ? () - : ()} - + + + {copied + ? () + : ()} + + )} + /> + + {safeTooltipText} + )} diff --git a/web/app/components/base/tooltip/TooltipManager.ts b/web/app/components/base/tooltip/TooltipManager.ts deleted file mode 100644 index b0138af4b3..0000000000 --- a/web/app/components/base/tooltip/TooltipManager.ts +++ /dev/null @@ -1,27 +0,0 @@ -class TooltipManager { - private activeCloser: (() => void) | null = null - - register(closeFn: () => void) { - if (this.activeCloser) - this.activeCloser() - this.activeCloser = closeFn - } - - clear(closeFn: () => void) { - if (this.activeCloser === closeFn) - this.activeCloser = null - } - - /** - * Closes the currently active tooltip by calling its closer function - * and clearing the reference to it - */ - closeActiveTooltip() { - if (this.activeCloser) { - this.activeCloser() - this.activeCloser = null - } - } -} - -export const tooltipManager = new TooltipManager() diff --git a/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts b/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts deleted file mode 100644 index 406c48259a..0000000000 --- a/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { tooltipManager } from '../TooltipManager' - -describe('TooltipManager', () => { - // Test the singleton instance directly - let manager: typeof tooltipManager - - beforeEach(() => { - // Get fresh reference to the singleton - manager = tooltipManager - // Clean up any active tooltip by calling closeActiveTooltip - // This ensures each test starts with a clean state - manager.closeActiveTooltip() - }) - - describe('register', () => { - it('should register a close function', () => { - const closeFn = vi.fn() - manager.register(closeFn) - expect(closeFn).not.toHaveBeenCalled() - }) - - it('should call the existing close function when registering a new one', () => { - const firstCloseFn = vi.fn() - const secondCloseFn = vi.fn() - - manager.register(firstCloseFn) - manager.register(secondCloseFn) - - expect(firstCloseFn).toHaveBeenCalledTimes(1) - expect(secondCloseFn).not.toHaveBeenCalled() - }) - - it('should replace the active closer with the new one', () => { - const firstCloseFn = vi.fn() - const secondCloseFn = vi.fn() - - // Register first function - manager.register(firstCloseFn) - - // Register second function - this should call firstCloseFn and replace it - manager.register(secondCloseFn) - - // Verify firstCloseFn was called during register (replacement behavior) - expect(firstCloseFn).toHaveBeenCalledTimes(1) - - // Now close the active tooltip - this should call secondCloseFn - manager.closeActiveTooltip() - - // Verify secondCloseFn was called, not firstCloseFn - expect(secondCloseFn).toHaveBeenCalledTimes(1) - }) - }) - - describe('clear', () => { - it('should not clear if the close function does not match', () => { - const closeFn = vi.fn() - const otherCloseFn = vi.fn() - - manager.register(closeFn) - manager.clear(otherCloseFn) - - manager.closeActiveTooltip() - expect(closeFn).toHaveBeenCalledTimes(1) - }) - - it('should clear the close function if it matches', () => { - const closeFn = vi.fn() - - manager.register(closeFn) - manager.clear(closeFn) - - manager.closeActiveTooltip() - expect(closeFn).not.toHaveBeenCalled() - }) - - it('should not call the close function when clearing', () => { - const closeFn = vi.fn() - - manager.register(closeFn) - manager.clear(closeFn) - - expect(closeFn).not.toHaveBeenCalled() - }) - }) - - describe('closeActiveTooltip', () => { - it('should do nothing when no active closer is registered', () => { - expect(() => manager.closeActiveTooltip()).not.toThrow() - }) - - it('should call the active closer function', () => { - const closeFn = vi.fn() - manager.register(closeFn) - - manager.closeActiveTooltip() - - expect(closeFn).toHaveBeenCalledTimes(1) - }) - - it('should clear the active closer after calling it', () => { - const closeFn = vi.fn() - manager.register(closeFn) - - manager.closeActiveTooltip() - manager.closeActiveTooltip() - - expect(closeFn).toHaveBeenCalledTimes(1) - }) - - it('should handle multiple register and close cycles', () => { - const closeFn1 = vi.fn() - const closeFn2 = vi.fn() - const closeFn3 = vi.fn() - - manager.register(closeFn1) - manager.closeActiveTooltip() - - manager.register(closeFn2) - manager.closeActiveTooltip() - - manager.register(closeFn3) - manager.closeActiveTooltip() - - expect(closeFn1).toHaveBeenCalledTimes(1) - expect(closeFn2).toHaveBeenCalledTimes(1) - expect(closeFn3).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/web/app/components/base/tooltip/__tests__/content.spec.tsx b/web/app/components/base/tooltip/__tests__/content.spec.tsx deleted file mode 100644 index fa5d86756e..0000000000 --- a/web/app/components/base/tooltip/__tests__/content.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' -import { ToolTipContent } from '../content' - -describe('ToolTipContent', () => { - it('should render children correctly', () => { - render( - - Tooltip body text - , - ) - expect(screen.getByTestId('tooltip-content')).toBeInTheDocument() - expect(screen.getByTestId('tooltip-content-body')).toHaveTextContent('Tooltip body text') - expect(screen.queryByTestId('tooltip-content-title')).not.toBeInTheDocument() - expect(screen.queryByTestId('tooltip-content-action')).not.toBeInTheDocument() - }) - - it('should render title when provided', () => { - render( - - Tooltip body text - , - ) - expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title') - }) - - it('should render action when provided', () => { - render( - Action Text}> - Tooltip body text - , - ) - expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text') - }) - - it('should handle action click', async () => { - const user = userEvent.setup() - const handleActionClick = vi.fn() - render( - Action Text}> - Tooltip body text - , - ) - - await user.click(screen.getByText('Action Text')) - expect(handleActionClick).toHaveBeenCalledTimes(1) - }) -}) diff --git a/web/app/components/base/tooltip/__tests__/index.spec.tsx b/web/app/components/base/tooltip/__tests__/index.spec.tsx deleted file mode 100644 index 39f8f1b503..0000000000 --- a/web/app/components/base/tooltip/__tests__/index.spec.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import Tooltip from '../index' -import { tooltipManager } from '../TooltipManager' - -afterEach(() => { - cleanup() - vi.clearAllTimers() - vi.useRealTimers() -}) - -describe('Tooltip', () => { - describe('Rendering', () => { - it('should render default tooltip with question icon', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - expect(trigger).not.toBeNull() - expect(trigger?.querySelector('svg')).not.toBeNull() // question icon - }) - - it('should render with custom children', () => { - const { getByText } = render( - - - , - ) - expect(getByText('Hover me').textContent).toBe('Hover me') - }) - - it('should render correctly when asChild is false', () => { - const { container } = render( - - Trigger - , - ) - const trigger = container.querySelector('.custom-parent-trigger') - expect(trigger).not.toBeNull() - }) - - it('should render with a fallback question icon when children are null', () => { - const { container } = render( - - {null} - , - ) - const trigger = container.querySelector('.custom-fallback-trigger') - expect(trigger).not.toBeNull() - expect(trigger?.querySelector('svg')).not.toBeNull() - }) - }) - - describe('Disabled state', () => { - it('should not show tooltip when disabled', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - act(() => { - fireEvent.mouseEnter(trigger!) - }) - expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() - }) - }) - - describe('Trigger methods', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - it('should open on hover when triggerMethod is hover', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - act(() => { - fireEvent.mouseEnter(trigger!) - }) - expect(screen.queryByText('Tooltip content')).toBeInTheDocument() - }) - - it('should close on mouse leave when triggerMethod is hover and needsDelay is false', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - act(() => { - fireEvent.mouseEnter(trigger!) - fireEvent.mouseLeave(trigger!) - }) - expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() - }) - - it('should toggle on click when triggerMethod is click', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - act(() => { - fireEvent.click(trigger!) - }) - expect(screen.queryByText('Tooltip content')).toBeInTheDocument() - - // Test toggle off - act(() => { - fireEvent.click(trigger!) - }) - expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() - }) - - it('should do nothing on mouse enter if triggerMethod is click', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - act(() => { - fireEvent.mouseEnter(trigger!) - }) - expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() - }) - - it('should delay closing on mouse leave when needsDelay is true', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - - act(() => { - fireEvent.mouseEnter(trigger!) - }) - expect(screen.getByText('Tooltip content')).toBeInTheDocument() - - act(() => { - fireEvent.mouseLeave(trigger!) - }) - // Shouldn't close immediately - expect(screen.getByText('Tooltip content')).toBeInTheDocument() - - act(() => { - vi.advanceTimersByTime(350) - }) - // Should close after delay - expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() - }) - - it('should not close if mouse enters popup before delay finishes', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - - act(() => { - fireEvent.mouseEnter(trigger!) - }) - - const popup = screen.getByText('Tooltip content') - expect(popup).toBeInTheDocument() - - act(() => { - fireEvent.mouseLeave(trigger!) - }) - - act(() => { - vi.advanceTimersByTime(150) - // Simulate mouse entering popup area itself during the delay timeframe - fireEvent.mouseEnter(popup) - }) - - act(() => { - vi.advanceTimersByTime(200) // Complete the 300ms original delay - }) - - // Should still be open because we are hovering the popup - expect(screen.getByText('Tooltip content')).toBeInTheDocument() - - // Now mouse leaves popup - act(() => { - fireEvent.mouseLeave(popup) - }) - - act(() => { - vi.advanceTimersByTime(350) - }) - // Should now close - expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() - }) - - it('should do nothing on mouse enter/leave of popup when triggerMethod is not hover', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - - act(() => { - fireEvent.click(trigger!) - }) - - const popup = screen.getByText('Tooltip content') - - act(() => { - fireEvent.mouseEnter(popup) - fireEvent.mouseLeave(popup) - vi.advanceTimersByTime(350) - }) - - // Should still be open because click method requires another click to close, not hover leave - expect(screen.getByText('Tooltip content')).toBeInTheDocument() - }) - - it('should clear close timeout if trigger is hovered again before delay finishes', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - - act(() => { - fireEvent.mouseEnter(trigger!) - }) - expect(screen.getByText('Tooltip content')).toBeInTheDocument() - - act(() => { - fireEvent.mouseLeave(trigger!) - }) - - act(() => { - vi.advanceTimersByTime(150) - // Re-hover trigger before it closes - fireEvent.mouseEnter(trigger!) - }) - - act(() => { - vi.advanceTimersByTime(200) // Original 300ms would be up - }) - - // Should still be open because we reset it - expect(screen.getByText('Tooltip content')).toBeInTheDocument() - }) - - it('should test clear close timeout if trigger is hovered again before delay finishes and isHoverPopupRef is true', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - - act(() => { - fireEvent.mouseEnter(trigger!) - }) - - const popup = screen.getByText('Tooltip content') - expect(popup).toBeInTheDocument() - - act(() => { - fireEvent.mouseEnter(popup) - fireEvent.mouseLeave(trigger!) - }) - - act(() => { - vi.advanceTimersByTime(350) - }) - - // Should still be open because we are hovering the popup - expect(screen.getByText('Tooltip content')).toBeInTheDocument() - }) - }) - - describe('TooltipManager', () => { - it('should close active tooltips when triggered centrally, overriding other closes', () => { - const triggerClassName1 = 'custom-trigger-1' - const triggerClassName2 = 'custom-trigger-2' - - const { container } = render( -
- - -
, - ) - - const trigger1 = container.querySelector(`.${triggerClassName1}`) - const trigger2 = container.querySelector(`.${triggerClassName2}`) - - expect(trigger2).not.toBeNull() - - // Open first tooltip - act(() => { - fireEvent.mouseEnter(trigger1!) - }) - expect(screen.queryByText('Tooltip content 1')).toBeInTheDocument() - - // TooltipManager should keep track of it - // Next, immediately open the second one without leaving first (e.g., via TooltipManager) - // TooltipManager registers the newest one and closes the old one when doing full external operations, but internally the manager allows direct closing - - act(() => { - tooltipManager.closeActiveTooltip() - }) - - expect(screen.queryByText('Tooltip content 1')).not.toBeInTheDocument() - - // Safe to call again - expect(() => tooltipManager.closeActiveTooltip()).not.toThrow() - }) - }) - - describe('Styling and positioning', () => { - it('should apply custom trigger className', () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - expect(trigger?.className).toContain('custom-trigger') - }) - - it('should pass triggerTestId to the fallback icon wrapper', () => { - render() - expect(screen.getByTestId('test-tooltip-icon')).toBeInTheDocument() - }) - - it('should apply custom popup className', async () => { - const triggerClassName = 'custom-trigger' - const { container } = render() - const trigger = container.querySelector(`.${triggerClassName}`) - act(() => { - fireEvent.mouseEnter(trigger!) - }) - expect((await screen.findByText('Tooltip content'))?.className).toContain('custom-popup') - }) - - it('should apply noDecoration when specified', async () => { - const triggerClassName = 'custom-trigger' - const { container } = render( - , - ) - const trigger = container.querySelector(`.${triggerClassName}`) - act(() => { - fireEvent.mouseEnter(trigger!) - }) - expect((await screen.findByText('Tooltip content'))?.className).not.toContain('bg-components-panel-bg') - }) - }) -}) diff --git a/web/app/components/base/tooltip/content.tsx b/web/app/components/base/tooltip/content.tsx deleted file mode 100644 index 191ee933f1..0000000000 --- a/web/app/components/base/tooltip/content.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { FC, PropsWithChildren, ReactNode } from 'react' - -type ToolTipContentProps = { - title?: ReactNode - action?: ReactNode -} & PropsWithChildren - -export const ToolTipContent: FC = ({ - title, - action, - children, -}) => { - return ( -
- {!!title && ( -
{title}
- )} -
{children}
- {!!action &&
{action}
} -
- ) -} diff --git a/web/app/components/base/tooltip/index.stories.tsx b/web/app/components/base/tooltip/index.stories.tsx deleted file mode 100644 index 69d0c5d2b6..0000000000 --- a/web/app/components/base/tooltip/index.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import Tooltip from '.' - -const TooltipGrid = () => { - return ( -
-
Hover tooltips
-
- - - - - - Right tooltip - - -
-
Click tooltips
-
- - - - - - Plain content - - -
-
- ) -} - -const meta = { - title: 'Base/Feedback/Tooltip', - component: TooltipGrid, - parameters: { - layout: 'centered', - docs: { - description: { - component: 'Portal-based tooltip component supporting hover and click triggers, custom placements, and decorated content.', - }, - }, - }, - tags: ['autodocs'], -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Playground: Story = {} diff --git a/web/app/components/base/tooltip/index.tsx b/web/app/components/base/tooltip/index.tsx deleted file mode 100644 index 85c63cdeaf..0000000000 --- a/web/app/components/base/tooltip/index.tsx +++ /dev/null @@ -1,231 +0,0 @@ -'use client' -import type { Placement } from '@langgenius/dify-ui/popover' -/** - * @deprecated Use `@langgenius/dify-ui/tooltip` instead. - * This component will be removed after migration is complete. - * See: https://github.com/langgenius/dify/issues/32767 - */ -import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { RiQuestionLine } from '@remixicon/react' -import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' -import { tooltipManager } from './TooltipManager' - -type TooltipOffset = number | { - mainAxis?: number - crossAxis?: number -} - -type TooltipProps = { - position?: Placement - triggerMethod?: 'hover' | 'click' - triggerClassName?: string - triggerTestId?: string - disabled?: boolean - popupContent?: React.ReactNode - children?: React.ReactNode - popupClassName?: string - portalContentClassName?: string - noDecoration?: boolean - offset?: TooltipOffset - needsDelay?: boolean - asChild?: boolean -} - -const Tooltip: FC = ({ - position = 'top', - triggerMethod = 'hover', - triggerClassName, - triggerTestId, - disabled = false, - popupContent, - children, - popupClassName, - portalContentClassName, - noDecoration, - offset, - asChild = true, - needsDelay = true, -}) => { - const [open, setOpen] = useState(false) - const resolvedOffset = offset ?? 8 - const sideOffset = typeof resolvedOffset === 'number' ? resolvedOffset : (resolvedOffset.mainAxis ?? 0) - const alignOffset = typeof resolvedOffset === 'number' ? 0 : (resolvedOffset.crossAxis ?? 0) - const [isHoverPopup, { - setTrue: setHoverPopup, - setFalse: setNotHoverPopup, - }] = useBoolean(false) - - const isHoverPopupRef = useRef(isHoverPopup) - useEffect(() => { - isHoverPopupRef.current = isHoverPopup - }, [isHoverPopup]) - - const [isHoverTrigger, { - setTrue: setHoverTrigger, - setFalse: setNotHoverTrigger, - }] = useBoolean(false) - - const isHoverTriggerRef = useRef(isHoverTrigger) - useEffect(() => { - isHoverTriggerRef.current = isHoverTrigger - }, [isHoverTrigger]) - - const closeTimeoutRef = useRef | null>(null) - const clearCloseTimeout = useCallback(() => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - closeTimeoutRef.current = null - } - }, []) - - useEffect(() => { - return () => { - clearCloseTimeout() - } - }, [clearCloseTimeout]) - - const close = () => setOpen(false) - const handleOpenChange = (nextOpen: boolean) => { - if (disabled) { - setOpen(false) - return - } - if (triggerMethod === 'click') - setOpen(nextOpen) - else if (!nextOpen) - setOpen(false) - } - - const handleLeave = (isTrigger: boolean) => { - if (isTrigger) - setNotHoverTrigger() - else - setNotHoverPopup() - - // give time to move to the popup - if (needsDelay) { - clearCloseTimeout() - closeTimeoutRef.current = setTimeout(() => { - closeTimeoutRef.current = null - if (!isHoverPopupRef.current && !isHoverTriggerRef.current) { - setOpen(false) - tooltipManager.clear(close) - } - }, 300) - } - else { - clearCloseTimeout() - setOpen(false) - tooltipManager.clear(close) - } - } - const handleTriggerMouseEnter = () => { - if (triggerMethod === 'hover') { - clearCloseTimeout() - setHoverTrigger() - tooltipManager.register(close) - setOpen(true) - } - } - const handleTriggerMouseLeave = () => { - if (triggerMethod === 'hover') - handleLeave(true) - } - const handlePopupMouseEnter = () => { - if (triggerMethod === 'hover') { - clearCloseTimeout() - setHoverPopup() - } - } - const handlePopupMouseLeave = () => { - if (triggerMethod === 'hover') - handleLeave(false) - } - - const fallbackTrigger = ( -
- -
- ) - const triggerContent = children || fallbackTrigger - const childElement = React.isValidElement>(triggerContent) - ? triggerContent - : fallbackTrigger - const nativeButton = typeof childElement.type !== 'string' || childElement.type === 'button' - - const renderAsChildTrigger = () => { - const childProps = childElement.props - return React.cloneElement(childElement, { - onMouseEnter: (event: React.MouseEvent) => { - childProps.onMouseEnter?.(event) - handleTriggerMouseEnter() - }, - onMouseLeave: (event: React.MouseEvent) => { - childProps.onMouseLeave?.(event) - handleTriggerMouseLeave() - }, - }) - } - const effectiveOpen = !disabled && open - - return ( - - {asChild - ? ( - - ) - : ( - - )} - > - {triggerContent} - - )} - {effectiveOpen && !!popupContent && ( - - {popupContent} - - )} - - ) -} - -export default React.memo(Tooltip) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx index 615579bc6c..568a2656ba 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx @@ -241,7 +241,7 @@ describe('CloudPlanItem', () => { ) // Sandbox viewed from a higher plan is disabled, but let's verify no API calls - const button = screen.getByRole('button') + const button = screen.getByRole('button', { name: 'billing.plansCommon.startForFree' }) fireEvent.click(button) await waitFor(() => { diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx index 5a06509355..e6a0d78273 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { Plan } from '../../../../../type' import List from '../index' @@ -12,11 +13,13 @@ describe('CloudPlanItem/List', () => { expect(screen.getByText('billing.plansCommon.startNodes.limited:{"count":2}')).toBeInTheDocument() }) - it('should show professional monthly quotas and tooltips', () => { + it('should show professional monthly quotas and tooltips', async () => { + const user = userEvent.setup() render() expect(screen.getByText('billing.plansCommon.messageRequest.titlePerMonth:{"count":5000}')).toBeInTheDocument() - expect(screen.getByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument() + await user.hover(screen.getByRole('button', { name: 'billing.plansCommon.vectorSpaceTooltip' })) + expect(await screen.findByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.workflowExecution.faster')).toBeInTheDocument() }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx index e1aada80f8..f75b334fd9 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Item from '../index' describe('Item', () => { @@ -20,14 +21,16 @@ describe('Item', () => { // Toggling the optional tooltip indicator describe('Tooltip behavior', () => { - it('should render tooltip content when tooltip text is provided', () => { + it('should render tooltip content when tooltip text is provided', async () => { + const user = userEvent.setup() const label = 'Workspace seats' const tooltip = 'Seats define how many teammates can join the workspace.' const { container } = render() expect(screen.getByText(label)).toBeInTheDocument() - expect(screen.getByText(tooltip)).toBeInTheDocument() + await user.hover(screen.getByRole('button', { name: tooltip })) + expect(await screen.findByText(tooltip)).toBeInTheDocument() expect(container.querySelector('.group')).not.toBeNull() }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx index 86e4cb1061..c744fdb60e 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Tooltip from '../tooltip' describe('Tooltip', () => { @@ -8,12 +9,14 @@ describe('Tooltip', () => { // Rendering the info tooltip container describe('Rendering', () => { - it('should render the content panel when provide with text', () => { + it('should render the content panel when hovered', async () => { + const user = userEvent.setup() const content = 'Usage resets on the first day of every month.' render() + await user.hover(screen.getByRole('button', { name: content })) - expect(() => screen.getByText(content)).not.toThrow() + expect(await screen.findByText(content)).toBeInTheDocument() }) }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx index fe6aa9c2cb..be53ef6b1b 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx @@ -1,3 +1,4 @@ +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiInfoI } from '@remixicon/react' import * as React from 'react' @@ -11,14 +12,20 @@ const Tooltip = ({ if (!content) return null return ( -
-
- {content} -
-
+ + -
-
+ + + {content} + + ) } diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index 8692da927d..7dc184aee4 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -16,15 +16,16 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean, useDebounceFn } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import Tooltip from '@/app/components/base/tooltip' import { IS_CE_EDITION } from '@/config' import { DataSourceType, DocumentActionType } from '@/models/datasets' import { useRouter } from '@/next/navigation' @@ -205,11 +206,12 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele <> {archived ? ( - -
- -
-
+ + } /> + + {t('list.action.enableWarning', { ns: 'datasetDocuments' })} + + ) : handleSwitch(v ? 'enable' : 'disable')} size="md" />} @@ -217,16 +219,24 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele )} {embeddingAvailable && ( <> - - + + router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)} + > + + + )} + /> + + {t('list.action.settings', { ns: 'datasetDocuments' })} + ({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( -
{children}
- ), -})) - vi.mock('../file-icon', () => ({ default: () => , })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx index 5018806265..8d9bfe0dff 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx @@ -1,12 +1,10 @@ -import type { Placement } from '@floating-ui/react' import type { OnlineDriveFile } from '@/models/pipeline' -import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import Radio from '@/app/components/base/radio/ui' -import Tooltip from '@/app/components/base/tooltip' import { formatFileSize } from '@/utils/format' import FileIcon from './file-icon' @@ -33,14 +31,7 @@ const Item = ({ const isBucket = type === 'bucket' const isFolder = type === 'folder' - const Wrapper = disabled ? Tooltip : React.Fragment - const wrapperProps = disabled - ? { - popupContent: t('onlineDrive.notSupportedFileType', { ns: 'datasetPipeline' }), - position: 'top-end' as Placement, - offset: { mainAxis: 4, crossAxis: -104 }, - } - : {} + const disabledTip = t('onlineDrive.notSupportedFileType', { ns: 'datasetPipeline' }) const handleSelect = useCallback((e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation() @@ -80,27 +71,44 @@ const Item = ({ onCheck={handleSelect} /> )} - -
+ + + + {name} + + {!isFolder && typeof size === 'number' && ( + {formatFileSize(size)} + )} + + + {disabledTip} + + + ) + : ( +
+ + + {name} + + {!isFolder && typeof size === 'number' && ( + {formatFileSize(size)} + )} +
)} - > - - - {name} - - {!isFolder && typeof size === 'number' && ( - {formatFileSize(size)} - )} -
-
) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index 797f7b296a..58d77b387c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -4,6 +4,7 @@ import type { InitialDocumentDetail } from '@/models/pipeline' import type { RETRIEVE_METHOD } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAedFill, RiArrowRightLine, @@ -17,7 +18,6 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import NotionIcon from '@/app/components/base/notion-icon' -import Tooltip from '@/app/components/base/tooltip' import PriorityLabel from '@/app/components/billing/priority-label' import { Plan } from '@/app/components/billing/type' import UpgradeBtn from '@/app/components/billing/upgrade-btn' @@ -203,15 +203,18 @@ const EmbeddingProcess = ({
{`${getSourcePercent(indexingStatusDetail)}%`}
)} {indexingStatusDetail.indexing_status === 'error' && ( - - + + - - + + + {indexingStatusDetail.error} + + )} {indexingStatusDetail.indexing_status === 'completed' && ( diff --git a/web/app/components/datasets/documents/detail/completed/display-toggle.tsx b/web/app/components/datasets/documents/detail/completed/display-toggle.tsx index 6e961ac43f..6735399cd2 100644 --- a/web/app/components/datasets/documents/detail/completed/display-toggle.tsx +++ b/web/app/components/datasets/documents/detail/completed/display-toggle.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiLineHeight } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { Collapse } from '@/app/components/base/icons/src/vender/knowledge' -import Tooltip from '@/app/components/base/tooltip' type DisplayToggleProps = { isCollapsed: boolean @@ -15,25 +15,30 @@ const DisplayToggle: FC = ({ toggleCollapsed, }) => { const { t } = useTranslation() + const label = isCollapsed ? t('segment.expandChunks', { ns: 'datasetDocuments' }) : t('segment.collapseChunks', { ns: 'datasetDocuments' }) return ( - - - + + + { + isCollapsed + ? + : + } + + )} + /> + + {label} + ) } diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx index 1111bb6411..865ffbce15 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx @@ -10,13 +10,13 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import Divider from '@/app/components/base/divider' -import Tooltip from '@/app/components/base/tooltip' import ImageList from '@/app/components/datasets/common/image-list' import { ChunkingMode } from '@/models/datasets' import { formatNumber } from '@/utils/format' @@ -182,35 +182,43 @@ const SegmentCard: FC = ({ > {!archived && ( <> - -
{ - e.stopPropagation() - onClickEdit?.() - }} - > - -
+ + { + e.stopPropagation() + onClickEdit?.() + }} + > + + + )} + /> + Edit - -
{ - e.stopPropagation() - setShowModal(true) - }} - > - -
+ + { + e.stopPropagation() + setShowModal(true) + }} + > + + + )} + /> + Delete diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx index 8253f7faf6..f76284b36f 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import type { BuiltInMetadataItem, MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types' import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' -import { RiQuestionLine } from '@remixicon/react' import { produce } from 'immer' import * as React from 'react' import { useCallback, useState } from 'react' @@ -11,8 +10,8 @@ import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { useCreateMetaData } from '@/service/knowledge/use-metadata' import Checkbox from '../../../base/checkbox' +import { Infotip } from '../../../base/infotip' import Modal from '../../../base/modal' -import Tooltip from '../../../base/tooltip' import AddMetadataButton from '../add-metadata-button' import useCheckMetadataName from '../hooks/use-check-metadata-name' import SelectMetadataModal from '../metadata-dataset/select-metadata-modal' @@ -115,11 +114,14 @@ const EditMetadataBatchModal: FC = ({ datasetId, documentNum, list, onSav
setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)} id="apply-to-all" />
{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}
- {t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}
}> -
- -
-
+ + {t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })} +
} /> + + {t(`${i18nPrefix}.builtInDescription`, { ns: 'dataset' })} +
diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx index f9c923e6a1..73aa8f9bfc 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx @@ -1,5 +1,6 @@ import type { CustomModel, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import SwitchCredentialInLoadBalancing from '../switch-credential-in-load-balancing' @@ -105,7 +106,8 @@ describe('SwitchCredentialInLoadBalancing', () => { expect(mockSetCustomModelCredential).toHaveBeenCalledWith(mockCredentials[0]) }) - it('should show tooltip when empty and custom credentials not allowed', () => { + it('should show tooltip when empty and custom credentials not allowed', async () => { + const user = userEvent.setup() const restrictedProvider = { ...mockProvider, allow_custom_token: false } render( { />, ) - fireEvent.mouseEnter(screen.getByText(/auth.credentialUnavailableInButton/)) - expect(screen.getByText('plugin.auth.credentialUnavailable'))!.toBeInTheDocument() + await user.hover(screen.getByRole('button', { name: /auth.credentialUnavailableInButton/ })) + expect(await screen.findByText('plugin.auth.credentialUnavailable'))!.toBeInTheDocument() }) // Empty credentials with allowed custom: no tooltip but still shows unavailable text diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx index 4a268168ba..7529ef9afb 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx @@ -5,6 +5,7 @@ import type { import { Button, } from '@langgenius/dify-ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiEqualizer2Line, } from '@remixicon/react' @@ -13,7 +14,6 @@ import { useCallback, } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import Authorized from './authorized' import { useCredentialStatus } from './hooks' @@ -53,11 +53,11 @@ const ConfigProvider = ({ ) if (notAllowCustomCredential && !hasCredential) { return ( - - {Item} + + + + {t('auth.credentialUnavailable', { ns: 'plugin' })} + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx index 58ffc180dd..8ccfc0a640 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx @@ -6,6 +6,7 @@ import type { } from '../declarations' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiArrowDownSLine } from '@remixicon/react' import { memo, @@ -13,7 +14,6 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import Tooltip from '@/app/components/base/tooltip' import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import Indicator from '@/app/components/header/indicator' import Authorized from './authorized' @@ -89,11 +89,11 @@ const SwitchCredentialInLoadBalancing = ({ ) if (empty && notAllowCustomCredential) { return ( - - {Item} + + + + {t('auth.credentialUnavailable', { ns: 'plugin' })} + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx index dc7c512f78..b204462ab5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx @@ -21,10 +21,10 @@ describe('StatusIndicators', () => { installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }] }) - const getTooltipTrigger = (container: HTMLElement) => { - const trigger = container.querySelector('[role="button"][aria-haspopup="dialog"]') + const getPopoverTrigger = (name: string) => { + const trigger = screen.getByRole('button', { name }) expect(trigger).toBeInTheDocument() - return trigger as HTMLElement + return trigger } it('should render nothing when model is available and enabled', () => { @@ -43,7 +43,7 @@ describe('StatusIndicators', () => { it('should render deprecated tooltip when provider model is disabled and in model list', async () => { const user = userEvent.setup() - const { container } = render( + render( { />, ) - await user.hover(getTooltipTrigger(container)) + await user.hover(getPopoverTrigger('nodes.agent.modelSelectorTooltips.deprecated')) expect(await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument() }) it('should render model-not-support tooltip when disabled model is not in model list and has no pluginInfo', async () => { const user = userEvent.setup() - const { container } = render( + render( { />, ) - await user.hover(getTooltipTrigger(container)) + await user.hover(getPopoverTrigger('nodes.agent.modelNotSupport.title')) expect(await screen.findByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument() }) @@ -125,7 +125,7 @@ describe('StatusIndicators', () => { it('should render marketplace warning tooltip when provider is unavailable', async () => { const user = userEvent.setup() - const { container } = render( + render( { />, ) - await user.hover(getTooltipTrigger(container)) + await user.hover(getPopoverTrigger('nodes.agent.modelNotInMarketplace.title')) expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx index cca5846390..bc505657e2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx @@ -1,5 +1,6 @@ +import type { ReactNode } from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiErrorWarningFill } from '@remixicon/react' -import Tooltip from '@/app/components/base/tooltip' import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version' import Link from '@/next/link' import { useInstalledPluginList } from '@/service/use-plugins' @@ -13,6 +14,28 @@ type StatusIndicatorsProps = { t: any } +type StatusPopoverProps = { + ariaLabel: string + content: ReactNode + children: ReactNode +} + +const StatusPopover = ({ ariaLabel, content, children }: StatusPopoverProps) => ( + + e.stopPropagation()} + > + {children} + + + {content} + + +) + const StatusIndicators = ({ needsConfiguration, modelProvider, inModelList, disabled, pluginInfo, t }: StatusIndicatorsProps) => { const { data: pluginList } = useInstalledPluginList() const renderTooltipContent = (title: string, description?: string, linkText?: string, linkHref?: string) => { @@ -48,27 +71,26 @@ const StatusIndicators = ({ needsConfiguration, modelProvider, inModelList, disa <> {inModelList ? ( - - + ) : !pluginInfo ? ( - - + ) : ( )} {!modelProvider && !pluginInfo && ( - - + )} ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index 3c4fea6f51..e198853ddd 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -42,10 +42,6 @@ vi.mock('../feature-icon', () => ({ default: ({ feature }: { feature: string }) => {feature}, })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children: ReactNode }) =>
{children}
, -})) - const mockCredentialPanelState = vi.hoisted(() => vi.fn()) vi.mock('../../provider-added-card/use-credential-panel-state', () => ({ useCredentialPanelState: mockCredentialPanelState, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx index 305ef71c50..8ef0f11901 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx @@ -1,5 +1,6 @@ import type { ModelItem, ModelProvider } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Switch } from '@langgenius/dify-ui/switch' import { useQueryClient } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' @@ -7,7 +8,6 @@ import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' -import Tooltip from '@/app/components/base/tooltip' import { Plan } from '@/app/components/billing/type' import { useAppContext } from '@/context/app-context' import { useProviderContext, useProviderContextSelector } from '@/context/provider-context' @@ -102,14 +102,12 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad { model.deprecated ? ( - {t('modelProvider.modelHasBeenDeprecated', { ns: 'common' })} - } - offset={{ mainAxis: 4 }} - > - - + + } /> + + {t('modelProvider.modelHasBeenDeprecated', { ns: 'common' })} + + ) : (isCurrentWorkspaceManager && ( {
- - {data.endpoint} -
- )} - position="left" - > -
- {data.endpoint} -
-
+ {data.endpoint + ? ( + + + {data.endpoint} + + + {data.endpoint} + + + ) + : ( +
+ {data.endpoint} +
+ )}
·
{data.workflows_in_use > 0 ? t('subscription.list.item.usedByNum', { ns: 'pluginTrigger', num: data.workflows_in_use }) : t('subscription.list.item.noUsed', { ns: 'pluginTrigger' })} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx index 016eda373d..50db3887b0 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx @@ -54,10 +54,6 @@ vi.mock('@langgenius/dify-ui/switch', () => ({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children?: React.ReactNode }) => <>{children}, -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => 'en_US', })) @@ -233,7 +229,7 @@ describe('ReasoningConfigForm', () => { it('should open schema modal for object fields and support app selection', () => { const onChange = vi.fn() - const { container } = render( + render( { />, ) - fireEvent.click(container.querySelector('div.ml-0\\.5.cursor-pointer')!) + fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.clickToViewParameterSchema' })) expect(screen.getByTestId('schema-modal')).toHaveTextContent('Config') fireEvent.click(screen.getByTestId('close-schema')) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx index e6af05065f..1baae6d3ca 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx @@ -9,6 +9,7 @@ import type { import { cn } from '@langgenius/dify-ui/cn' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiArrowRightUpLine, RiBracesLine, @@ -16,9 +17,8 @@ import { import { useBoolean } from 'ahooks' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' -// eslint-disable-next-line no-restricted-imports -- legacy tooltip migration is handled separately from this change -import Tooltip from '@/app/components/base/tooltip' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector' @@ -127,17 +127,16 @@ const ReasoningConfigForm: React.FC = ({ } = schema const auto = value[variable]?.auto const fieldTitle = getFieldTitle(label, language) - const tooltipContent = (tooltip && ( - - {tooltip[language] || tooltip.en_US} -
- )} - triggerClassName="ml-0.5 w-4 h-4" - asChild={false} - /> - )) + const tooltipText = tooltip?.[language] || tooltip?.en_US + const tooltipContent = tooltipText && ( + + {tooltipText} + + ) const varInput = value[variable]!.value const { isString, @@ -173,20 +172,22 @@ const ReasoningConfigForm: React.FC = ({ · {resolveTargetVarType(type)} {isShowJSONEditor && ( - - {t('nodes.agent.clickToViewParameterSchema', { ns: 'workflow' })} - - )} - asChild={false} - > -
showSchema(input_schema as SchemaRoot, fieldTitle!)} - > - -
+ + showSchema(input_schema as SchemaRoot, fieldTitle!)} + > + + + )} + /> + + {t('nodes.agent.clickToViewParameterSchema', { ns: 'workflow' })} + )} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx index ba85957108..d92c59b457 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx @@ -1,8 +1,8 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Switch } from '@langgenius/dify-ui/switch' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiDeleteBinLine, RiEqualizer2Line, @@ -14,7 +14,6 @@ import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import AppIcon from '@/app/components/base/app-icon' import { Group } from '@/app/components/base/icons/src/vender/other' -import { ToolTipContent } from '@/app/components/base/tooltip/content' import Indicator from '@/app/components/header/indicator' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' @@ -144,11 +143,14 @@ const ToolItem = ({ className="-mt-1" uniqueIdentifier={installInfo} tooltip={( - - {`${t('detailPanel.toolSelector.unsupportedContent', { ns: 'plugin' })} ${t('detailPanel.toolSelector.unsupportedContent2', { ns: 'plugin' })}`} - +
+
+ {t('detailPanel.toolSelector.unsupportedTitle', { ns: 'plugin' })} +
+
+ {`${t('detailPanel.toolSelector.unsupportedContent', { ns: 'plugin' })} ${t('detailPanel.toolSelector.unsupportedContent2', { ns: 'plugin' })}`} +
+
)} onChange={() => { onInstall?.() @@ -167,18 +169,18 @@ const ToolItem = ({ /> )} {isError && ( - - - - - )} - /> - + + + + + {errorTip} - - + + )} ) diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 5843dffbe9..6e6aaf88c9 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { PluginDetail } from '../types' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowRightUpLine, RiBugLine, @@ -13,7 +14,6 @@ import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { API_PREFIX } from '@/config' import { useAppContext } from '@/context/app-context' @@ -124,12 +124,18 @@ const PluginItem: FC = ({ {verified && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />} {!isDifyVersionCompatible && ( - <Tooltip popupContent={ - t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: declarationMeta.minimum_dify_version }) - } - > - <RiErrorWarningLine color="red" className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" /> - </Tooltip> + <Popover> + <PopoverTrigger + openOnHover + aria-label={t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: declarationMeta.minimum_dify_version })} + className="ml-0.5 inline-flex h-4 w-4 shrink-0 border-0 bg-transparent p-0" + > + <RiErrorWarningLine color="red" className="h-4 w-4 text-text-accent" /> + </PopoverTrigger> + <PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary"> + {t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: declarationMeta.minimum_dify_version })} + </PopoverContent> + </Popover> )} <Badge className="ml-1 shrink-0" diff --git a/web/app/components/rag-pipeline/components/panel/input-field/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/index.tsx index 95a76d5e86..3572d6012f 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/index.tsx @@ -13,7 +13,7 @@ import { import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import Divider from '@/app/components/base/divider' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import { useStore } from '@/app/components/workflow/store' @@ -137,10 +137,12 @@ const InputFieldPanel = () => { <span className="system-sm-semibold-uppercase text-text-secondary"> {t('inputFieldPanel.uniqueInputs.title', { ns: 'datasetPipeline' })} </span> - <Tooltip - popupContent={t('inputFieldPanel.uniqueInputs.tooltip', { ns: 'datasetPipeline' })} + <Infotip + aria-label={t('inputFieldPanel.uniqueInputs.tooltip', { ns: 'datasetPipeline' })} popupClassName="max-w-[240px]" - /> + > + {t('inputFieldPanel.uniqueInputs.tooltip', { ns: 'datasetPipeline' })} + </Infotip> </div> <div className="flex flex-col gap-y-1 py-1"> { diff --git a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx index bee775e80b..9ed7c45165 100644 --- a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx @@ -6,9 +6,9 @@ import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useTranslation } from 'react-i18next' import Drawer from '@/app/components/base/drawer-plus' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import Radio from '@/app/components/base/radio/ui' -import Tooltip from '@/app/components/base/tooltip' import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' type Props = { @@ -123,14 +123,13 @@ const ConfigCredential: FC<Props> = ({ <div> <div className="flex items-center py-2 system-sm-medium text-text-primary"> {t('createTool.authMethod.key', { ns: 'tools' })} - <Tooltip - popupContent={( - <div className="w-[261px] text-text-tertiary"> - {t('createTool.authMethod.keyTooltip', { ns: 'tools' })} - </div> - )} - triggerClassName="ml-0.5 w-4 h-4" - /> + <Infotip + aria-label={t('createTool.authMethod.keyTooltip', { ns: 'tools' })} + className="ml-0.5 h-4 w-4" + popupClassName="w-[261px] text-text-tertiary" + > + {t('createTool.authMethod.keyTooltip', { ns: 'tools' })} + </Infotip> </div> <Input value={tempCredential.api_key_header} @@ -153,14 +152,13 @@ const ConfigCredential: FC<Props> = ({ <div> <div className="flex items-center py-2 system-sm-medium text-text-primary"> {t('createTool.authMethod.queryParam', { ns: 'tools' })} - <Tooltip - popupContent={( - <div className="w-[261px] text-text-tertiary"> - {t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })} - </div> - )} - triggerClassName="ml-0.5 w-4 h-4" - /> + <Infotip + aria-label={t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })} + className="ml-0.5 h-4 w-4" + popupClassName="w-[261px] text-text-tertiary" + > + {t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })} + </Infotip> </div> <Input value={tempCredential.api_key_query_param} 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 8c35232d35..3a8e3a539b 100644 --- a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -1,4 +1,3 @@ -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' @@ -28,21 +27,6 @@ vi.mock('@/app/components/tools/labels/selector', () => ({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ - children, - popupContent, - }: { - children?: ReactNode - popupContent?: ReactNode - }) => ( - <div> - {children} - {popupContent} - </div> - ), -})) - vi.mock('../confirm-modal', () => ({ default: ({ show, onClose, onConfirm }: { show: boolean, onClose: () => void, onConfirm: () => void }) => ( show diff --git a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx index 3002cafa0a..208d87c23a 100644 --- a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx @@ -23,21 +23,6 @@ const { }, })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ - children, - popupContent, - }: { - children: React.ReactNode - popupContent: React.ReactNode - }) => ( - <div> - <span>{popupContent}</span> - {children} - </div> - ), -})) - vi.mock('@/service/use-plugins', () => ({ useFeaturedToolsRecommendations: () => ({ plugins: [], @@ -121,11 +106,13 @@ describe('Tabs', () => { filterElem: <div>filter</div>, } - it('should render start content and disabled tab tooltip text', () => { + it('should render start content and disabled tab tooltip text', async () => { + const user = userEvent.setup() render(<Tabs {...baseProps} />) expect(screen.getByText('start-content'))!.toBeInTheDocument() - expect(screen.getByText('workflow.tabs.startDisabledTip'))!.toBeInTheDocument() + await user.hover(screen.getByText('Blocks')) + expect(await screen.findByText('workflow.tabs.startDisabledTip'))!.toBeInTheDocument() }) it('should switch tabs through click handlers and render tools content with normalized icons', () => { diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 48af942df7..0b38a2df2c 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -6,10 +6,10 @@ import type { ToolWithProvider, } from '../types' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' import { memo, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useFeaturedToolsRecommendations } from '@/service/use-plugins' import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools' @@ -129,19 +129,22 @@ const TabHeaderItem = ({ if (tab.disabled) { return ( - <Tooltip - key={tab.key} - position="top" - popupClassName="max-w-[200px]" - popupContent={disabledTip} - > - <div - className={className} - aria-disabled={tab.disabled} - onClick={handleClick} - > - {tab.name} - </div> + <Tooltip key={tab.key}> + <TooltipTrigger + render={( + <button + type="button" + className={className} + aria-disabled={tab.disabled} + onClick={handleClick} + > + {tab.name} + </button> + )} + /> + <TooltipContent placement="top" className="max-w-[200px]"> + {disabledTip} + </TooltipContent> </Tooltip> ) } diff --git a/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx b/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx index 671459bbbd..8c2ee7f976 100644 --- a/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx +++ b/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx @@ -1,22 +1,23 @@ 'use client' import type { FC } from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAlertFill } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' const McpToolNotSupportTooltip: FC = () => { const { t } = useTranslation() + const tip = t('detailPanel.toolSelector.unsupportedMCPTool', { ns: 'plugin' }) + return ( - <Tooltip - popupContent={( - <div className="w-[256px]"> - {t('detailPanel.toolSelector.unsupportedMCPTool', { ns: 'plugin' })} - </div> - )} - > - <RiAlertFill className="size-4 text-text-warning-secondary" /> - </Tooltip> + <Popover> + <PopoverTrigger openOnHover aria-label={tip} className="inline-flex border-0 bg-transparent p-0"> + <RiAlertFill className="size-4 text-text-warning-secondary" /> + </PopoverTrigger> + <PopoverContent popupClassName="w-[256px] px-3 py-2 system-xs-regular text-text-tertiary"> + {tip} + </PopoverContent> + </Popover> ) } export default React.memo(McpToolNotSupportTooltip) diff --git a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx index 141323e5b3..fc2b328950 100644 --- a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx +++ b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx @@ -2,13 +2,13 @@ import type { FC, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowLeftRightLine, RiExternalLinkLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { Badge as Badge2, BadgeState } from '@/app/components/base/badge/index' -import Tooltip from '@/app/components/base/tooltip' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import { pluginManifestToCardPluginProps } from '@/app/components/plugins/install-plugin/utils' import PluginMutationModel from '@/app/components/plugins/plugin-mutation-model' @@ -67,76 +67,91 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => { if (!uniqueIdentifier || !pluginId) return null + const content = ( + <div className={cn('flex w-fit items-center justify-center', className)} onClick={e => e.stopPropagation()}> + {isShowUpdateModal && pluginDetail && ( + <PluginMutationModel + onCancel={hideUpdateModal} + plugin={pluginManifestToCardPluginProps({ + ...pluginDetail.declaration, + icon: icon!, + })} + mutation={mutation} + mutate={install} + confirmButtonText={t('nodes.agent.installPlugin.install', { ns: 'workflow' })} + cancelButtonText={t('nodes.agent.installPlugin.cancel', { ns: 'workflow' })} + modelTitle={t('nodes.agent.installPlugin.title', { ns: 'workflow' })} + description={t('nodes.agent.installPlugin.desc', { ns: 'workflow' })} + cardTitleLeft={( + <> + <Badge2 className="mx-1" size="s" state={BadgeState.Warning}> + {`${pluginDetail.version} -> ${target!.version}`} + </Badge2> + </> + )} + modalBottomLeft={( + <Link + className="flex items-center justify-center gap-1" + href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)} + target="_blank" + rel="noopener noreferrer" + > + <span className="system-xs-regular text-xs text-text-accent"> + {t('nodes.agent.installPlugin.changelog', { ns: 'workflow' })} + </span> + <RiExternalLinkLine className="size-3 text-text-accent" /> + </Link> + )} + /> + )} + {pluginDetail && ( + <PluginVersionPicker + isShow={isShow} + onShowChange={setIsShow} + pluginID={pluginId} + currentVersion={pluginDetail.version} + onSelect={(state) => { + setTarget({ + pluginUniqueIden: state.unique_identifier, + version: state.version, + }) + showUpdateModal() + }} + trigger={( + <Badge + className={cn( + 'mx-1 flex hover:bg-state-base-hover', + isShow && 'bg-state-base-hover', + )} + uppercase={true} + text={( + <> + <div>{pluginDetail.version}</div> + <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" /> + </> + )} + hasRedCornerMark={true} + /> + )} + /> + )} + </div> + ) + + if (!tooltip || isShow || isShowUpdateModal) + return content + return ( - <Tooltip popupContent={!isShow && !isShowUpdateModal && tooltip} triggerMethod="hover"> - <div className={cn('flex w-fit items-center justify-center', className)} onClick={e => e.stopPropagation()}> - {isShowUpdateModal && pluginDetail && ( - <PluginMutationModel - onCancel={hideUpdateModal} - plugin={pluginManifestToCardPluginProps({ - ...pluginDetail.declaration, - icon: icon!, - })} - mutation={mutation} - mutate={install} - confirmButtonText={t('nodes.agent.installPlugin.install', { ns: 'workflow' })} - cancelButtonText={t('nodes.agent.installPlugin.cancel', { ns: 'workflow' })} - modelTitle={t('nodes.agent.installPlugin.title', { ns: 'workflow' })} - description={t('nodes.agent.installPlugin.desc', { ns: 'workflow' })} - cardTitleLeft={( - <> - <Badge2 className="mx-1" size="s" state={BadgeState.Warning}> - {`${pluginDetail.version} -> ${target!.version}`} - </Badge2> - </> - )} - modalBottomLeft={( - <Link - className="flex items-center justify-center gap-1" - href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)} - target="_blank" - rel="noopener noreferrer" - > - <span className="system-xs-regular text-xs text-text-accent"> - {t('nodes.agent.installPlugin.changelog', { ns: 'workflow' })} - </span> - <RiExternalLinkLine className="size-3 text-text-accent" /> - </Link> - )} - /> - )} - {pluginDetail && ( - <PluginVersionPicker - isShow={isShow} - onShowChange={setIsShow} - pluginID={pluginId} - currentVersion={pluginDetail.version} - onSelect={(state) => { - setTarget({ - pluginUniqueIden: state.unique_identifier, - version: state.version, - }) - showUpdateModal() - }} - trigger={( - <Badge - className={cn( - 'mx-1 flex hover:bg-state-base-hover', - isShow && 'bg-state-base-hover', - )} - uppercase={true} - text={( - <> - <div>{pluginDetail.version}</div> - <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" /> - </> - )} - hasRedCornerMark={true} - /> - )} - /> - )} - </div> - </Tooltip> + <Popover> + <PopoverTrigger + openOnHover + nativeButton={false} + aria-label={typeof tooltip === 'string' ? tooltip : t('nodes.agent.installPlugin.title', { ns: 'workflow' })} + render={content} + /> + <PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary"> + {tooltip} + </PopoverContent> + </Popover> ) } diff --git a/web/app/components/workflow/nodes/iteration-start/index.tsx b/web/app/components/workflow/nodes/iteration-start/index.tsx index 90a57bef26..ec4f1bfa7f 100644 --- a/web/app/components/workflow/nodes/iteration-start/index.tsx +++ b/web/app/components/workflow/nodes/iteration-start/index.tsx @@ -1,8 +1,8 @@ import type { NodeProps } from 'reactflow' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiHome5Fill } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle' const IterationStartNode = ({ id, data }: NodeProps) => { @@ -10,10 +10,14 @@ const IterationStartNode = ({ id, data }: NodeProps) => { return ( <div className="nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs"> - <Tooltip popupContent={t('blocks.iteration-start', { ns: 'workflow' })} asChild={false}> - <div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500"> + <Tooltip> + <TooltipTrigger + aria-label={t('blocks.iteration-start', { ns: 'workflow' })} + className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" + > <RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" /> - </div> + </TooltipTrigger> + <TooltipContent>{t('blocks.iteration-start', { ns: 'workflow' })}</TooltipContent> </Tooltip> <NodeSourceHandle id={id} @@ -30,10 +34,14 @@ export const IterationStartNodeDumb = () => { return ( <div className="nodrag relative top-[21px] left-[17px] z-11 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg"> - <Tooltip popupContent={t('blocks.iteration-start', { ns: 'workflow' })} asChild={false}> - <div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500"> + <Tooltip> + <TooltipTrigger + aria-label={t('blocks.iteration-start', { ns: 'workflow' })} + className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" + > <RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" /> - </div> + </TooltipTrigger> + <TooltipContent>{t('blocks.iteration-start', { ns: 'workflow' })}</TooltipContent> </Tooltip> </div> ) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx index a1f601cce9..54b37f9b52 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx @@ -17,7 +17,7 @@ import { import { useTranslation } from 'react-i18next' import WeightedScoreComponent from '@/app/components/app/configuration/dataset-config/params-config/weighted-score' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import { DEFAULT_WEIGHTED_SCORE } from '@/models/datasets' import { HybridSearchModeEnum, @@ -174,10 +174,13 @@ const SearchMethodOption = ({ disabled={readonly} /> {t('modelProvider.rerankModel.key', { ns: 'common' })} - <Tooltip - triggerClassName="ml-0.5 shrink-0 w-3.5 h-3.5" - popupContent={t('modelProvider.rerankModel.tip', { ns: 'common' })} - /> + <Infotip + aria-label={t('modelProvider.rerankModel.tip', { ns: 'common' })} + className="ml-0.5 h-3.5 w-3.5 shrink-0" + iconClassName="h-3.5 w-3.5" + > + {t('modelProvider.rerankModel.tip', { ns: 'common' })} + </Infotip> </div> ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx index 2f5baeb089..61d693de29 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx @@ -5,7 +5,7 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import Collapse from '@/app/components/workflow/nodes/_base/components/collapse' import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' @@ -46,13 +46,9 @@ const MetadataFilter = ({ <div className="mr-0.5 system-sm-semibold-uppercase text-text-secondary"> {t('nodes.knowledgeRetrieval.metadata.title', { ns: 'workflow' })} </div> - <Tooltip - popupContent={( - <div className="w-[200px]"> - {t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })} - </div> - )} - /> + <Infotip aria-label={t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })} popupClassName="w-[200px]"> + {t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })} + </Infotip> {collapseIcon} </div> <div className="flex items-center"> diff --git a/web/app/components/workflow/nodes/loop-start/index.tsx b/web/app/components/workflow/nodes/loop-start/index.tsx index a7bd18c7a5..9900b84856 100644 --- a/web/app/components/workflow/nodes/loop-start/index.tsx +++ b/web/app/components/workflow/nodes/loop-start/index.tsx @@ -1,8 +1,8 @@ import type { NodeProps } from 'reactflow' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiHome5Fill } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle' const LoopStartNode = ({ id, data }: NodeProps) => { @@ -10,10 +10,14 @@ const LoopStartNode = ({ id, data }: NodeProps) => { return ( <div className="nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg"> - <Tooltip popupContent={t('blocks.loop-start', { ns: 'workflow' })} asChild={false}> - <div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500"> + <Tooltip> + <TooltipTrigger + aria-label={t('blocks.loop-start', { ns: 'workflow' })} + className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" + > <RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" /> - </div> + </TooltipTrigger> + <TooltipContent>{t('blocks.loop-start', { ns: 'workflow' })}</TooltipContent> </Tooltip> <NodeSourceHandle id={id} @@ -30,10 +34,14 @@ export const LoopStartNodeDumb = () => { return ( <div className="nodrag relative top-[21px] left-[17px] z-11 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg"> - <Tooltip popupContent={t('blocks.loop-start', { ns: 'workflow' })} asChild={false}> - <div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500"> + <Tooltip> + <TooltipTrigger + aria-label={t('blocks.loop-start', { ns: 'workflow' })} + className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" + > <RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" /> - </div> + </TooltipTrigger> + <TooltipContent>{t('blocks.loop-start', { ns: 'workflow' })}</TooltipContent> </Tooltip> </div> ) diff --git a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx index a116a6303d..9165d53394 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx @@ -3,7 +3,7 @@ import type { ParameterExtractorNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse' import Field from '@/app/components/workflow/nodes/_base/components/field' @@ -131,14 +131,14 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ title={( <div className="flex items-center space-x-1"> <span className="uppercase">{t(`${i18nPrefix}.instruction`, { ns: 'workflow' })}</span> - <Tooltip - popupContent={( - <div className="w-[120px]"> - {t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })} - </div> - )} - triggerClassName="w-3.5 h-3.5 ml-0.5" - /> + <Infotip + aria-label={t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })} + className="ml-0.5 h-3.5 w-3.5" + iconClassName="h-3.5 w-3.5" + popupClassName="w-[120px]" + > + {t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })} + </Infotip> </div> )} value={inputs.instruction} diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx index c11f78bc08..ada3fc43cc 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx @@ -344,8 +344,8 @@ describe('question-classifier path', () => { ) expect(screen.getByText(`${longName.slice(0, 50)}...`)).toBeInTheDocument() - await user.hover(screen.getByText(`${longName.slice(0, 50)}...`)) - expect(screen.getByText(longName)).toBeInTheDocument() + await user.hover(screen.getByRole('button', { name: longName })) + expect(await screen.findByText(longName)).toBeInTheDocument() rerender( <Node diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx index a7e72c343c..ad411639e9 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx @@ -1,25 +1,10 @@ import type { QuestionClassifierNodeType, Topic } from '../types' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' -vi.mock('@/app/components/base/tooltip', () => ({ - __esModule: true, - default: ({ - children, - popupContent, - }: { - children: React.ReactNode - popupContent: React.ReactNode - }) => ( - <div> - {children} - {popupContent} - </div> - ), -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useTextGenerationCurrentProviderAndModelAndModelList: vi.fn(), })) @@ -101,7 +86,8 @@ describe('question-classifier/node', () => { expect(screen.getByText('handle-topic-2')).toBeInTheDocument() }) - it('returns nothing when neither model nor classes are configured and truncates long class names', () => { + it('returns nothing when neither model nor classes are configured and truncates long class names', async () => { + const user = userEvent.setup() const longName = 'L'.repeat(60) const { container, rerender } = render( <Node @@ -119,7 +105,8 @@ describe('question-classifier/node', () => { ) expect(screen.getByText(`${longName.slice(0, 50)}...`)).toBeInTheDocument() - expect(screen.getByText(longName)).toBeInTheDocument() + await user.hover(screen.getByRole('button', { name: longName })) + expect(await screen.findByText(longName)).toBeInTheDocument() rerender( <Node diff --git a/web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx b/web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx index 90d53f7271..d788d2518f 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import type { Memory, Node, NodeOutPutVar } from '@/app/components/workflow/types' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' import MemoryConfig from '../../_base/components/memory-config' @@ -48,14 +48,14 @@ const AdvancedSetting: FC<Props> = ({ title={( <div className="flex items-center space-x-1"> <span className="uppercase">{t(`${i18nPrefix}.instruction`, { ns: 'workflow' })}</span> - <Tooltip - popupContent={( - <div className="w-[120px]"> - {t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })} - </div> - )} - triggerClassName="w-3.5 h-3.5 ml-0.5" - /> + <Infotip + aria-label={t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })} + className="ml-0.5 h-3.5 w-3.5" + iconClassName="h-3.5 w-3.5" + popupClassName="w-[120px]" + > + {t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })} + </Infotip> </div> )} value={instruction} diff --git a/web/app/components/workflow/nodes/question-classifier/node.tsx b/web/app/components/workflow/nodes/question-classifier/node.tsx index 2aae8debcf..305eacc204 100644 --- a/web/app/components/workflow/nodes/question-classifier/node.tsx +++ b/web/app/components/workflow/nodes/question-classifier/node.tsx @@ -2,9 +2,9 @@ import type { TFunction } from 'i18next' import type { FC } from 'react' import type { NodeProps } from 'reactflow' import type { QuestionClassifierNodeType } from './types' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { useTextGenerationCurrentProviderAndModelAndModelList, } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -47,15 +47,18 @@ const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId, </div> {shouldShowTooltip ? ( - <Tooltip - popupContent={( - <div className="max-w-[300px] wrap-break-word"> - <ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} /> - </div> - )} - > - {content} - </Tooltip> + <Popover> + <PopoverTrigger + openOnHover + aria-label={topic.name} + className="w-full border-0 bg-transparent p-0 text-left" + > + {content} + </PopoverTrigger> + <PopoverContent popupClassName="max-w-[300px] px-3 py-2 system-xs-regular wrap-break-word text-text-tertiary"> + <ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} /> + </PopoverContent> + </Popover> ) : content} </div> diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx index 44eb9d44c6..3cae373d48 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx @@ -9,7 +9,7 @@ import { RiBracesLine, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components' @@ -57,15 +57,13 @@ const TriggerFormItem: FC<Props> = ({ <div className="ml-1 system-xs-regular text-text-destructive-secondary">*</div> )} {!showDescription && tooltip && ( - <Tooltip - popupContent={( - <div className="w-[200px]"> - {tooltip[language] || tooltip.en_US} - </div> - )} - triggerClassName="ml-1 w-4 h-4" - asChild={false} - /> + <Infotip + aria-label={tooltip[language] || tooltip.en_US} + className="ml-1 h-4 w-4" + popupClassName="w-[200px]" + > + {tooltip[language] || tooltip.en_US} + </Infotip> )} {showSchemaButton && ( <> diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx index a1f5f1e2c8..4d60e77ba2 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx @@ -94,10 +94,6 @@ vi.mock('@/app/components/base/input-with-copy', () => ({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children: React.ReactNode }) => <>{children}</>, -})) - vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ default: ({ title, children }: { title: string, children: React.ReactNode }) => ( <div> diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx index fb6bfacf38..53498c52f2 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -11,12 +11,12 @@ import { } from '@langgenius/dify-ui/number-field' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import copy from 'copy-to-clipboard' import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import InputWithCopy from '@/app/components/base/input-with-copy' -import Tooltip from '@/app/components/base/tooltip' import Field from '@/app/components/workflow/nodes/_base/components/field' import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars' import Split from '@/app/components/workflow/nodes/_base/components/split' @@ -118,32 +118,38 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ </div> {inputs.webhook_debug_url && ( <div className="space-y-2"> - <Tooltip - popupContent={debugUrlCopied ? t(`${i18nPrefix}.debugUrlCopied`, { ns: 'workflow' }) : t(`${i18nPrefix}.debugUrlCopy`, { ns: 'workflow' })} - popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-xs rounded-md px-1.5 py-1" - position="top" - offset={{ mainAxis: -20 }} - needsDelay={true} - > - <div - className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 transition-colors" - style={{ width: '368px', height: '38px' }} - onClick={() => { - copy(inputs.webhook_debug_url || '') - setDebugUrlCopied(true) - setTimeout(() => setDebugUrlCopied(false), 2000) - }} + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={t(`${i18nPrefix}.debugUrlCopy`, { ns: 'workflow' })} + className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 text-left transition-colors" + style={{ width: '368px', height: '38px' }} + onClick={() => { + copy(inputs.webhook_debug_url || '') + setDebugUrlCopied(true) + setTimeout(() => setDebugUrlCopied(false), 2000) + }} + > + <span className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }} /> + <span className="flex-1" style={{ width: '352px', height: '32px' }}> + <span className="block text-xs leading-4 text-text-tertiary"> + {t(`${i18nPrefix}.debugUrlTitle`, { ns: 'workflow' })} + </span> + <span className="block truncate text-xs leading-4 text-text-primary"> + {inputs.webhook_debug_url} + </span> + </span> + </button> + )} + /> + <TooltipContent + placement="top" + className="rounded-md border border-components-panel-border bg-components-tooltip-bg px-1.5 py-1 system-xs-regular text-text-primary shadow-lg backdrop-blur-xs" > - <div className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }}></div> - <div className="flex-1" style={{ width: '352px', height: '32px' }}> - <div className="text-xs leading-4 text-text-tertiary"> - {t(`${i18nPrefix}.debugUrlTitle`, { ns: 'workflow' })} - </div> - <div className="truncate text-xs leading-4 text-text-primary"> - {inputs.webhook_debug_url} - </div> - </div> - </div> + {debugUrlCopied ? t(`${i18nPrefix}.debugUrlCopied`, { ns: 'workflow' }) : t(`${i18nPrefix}.debugUrlCopy`, { ns: 'workflow' })} + </TooltipContent> </Tooltip> {isPrivateOrLocalAddress(inputs.webhook_debug_url) && ( <div className="mt-1 px-0 py-[2px] system-xs-regular text-text-warning"> diff --git a/web/app/components/workflow/panel/env-panel/variable-modal.tsx b/web/app/components/workflow/panel/env-panel/variable-modal.tsx index 267c014e1d..2560ab968e 100644 --- a/web/app/components/workflow/panel/env-panel/variable-modal.tsx +++ b/web/app/components/workflow/panel/env-panel/variable-modal.tsx @@ -7,8 +7,8 @@ import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' -import Tooltip from '@/app/components/base/tooltip' import { useWorkflowStore } from '@/app/components/workflow/store' import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' @@ -129,14 +129,14 @@ const VariableModal = ({ onClick={() => setType('secret')} > <span>Secret</span> - <Tooltip - popupContent={( - <div className="w-[240px]"> - {t('env.modal.secretTip', { ns: 'workflow' })} - </div> - )} - triggerClassName="ml-0.5 w-3.5 h-3.5" - /> + <Infotip + aria-label={t('env.modal.secretTip', { ns: 'workflow' })} + className="ml-0.5 h-3.5 w-3.5" + iconClassName="h-3.5 w-3.5" + popupClassName="w-[240px]" + > + {t('env.modal.secretTip', { ns: 'workflow' })} + </Infotip> </div> </div> </div> diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index a87922eb54..85607a1342 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -17,7 +17,7 @@ import { RiLoader2Line, RiPauseCircleFill, } from '@remixicon/react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip' @@ -68,16 +68,6 @@ const NodePanel: FC<Props> = ({ return doSetCollapseState(state) }, [hideProcessDetail]) - const titleRef = useRef<HTMLDivElement>(null) - const [isTooltipOpen, setIsTooltipOpen] = useState(false) - const handleTooltipOpenChange = useCallback((open: boolean) => { - if (open) { - const el = titleRef.current - if (!el || el.scrollWidth <= el.clientWidth) - return - } - setIsTooltipOpen(open) - }, []) const { t } = useTranslation() const docLink = useDocLink() @@ -142,11 +132,10 @@ const NodePanel: FC<Props> = ({ /> )} <BlockIcon size={inMessage ? 'xs' : 'sm'} className={cn('mr-2 shrink-0', inMessage && 'mr-1!')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} /> - <Tooltip open={isTooltipOpen} onOpenChange={handleTooltipOpenChange}> + <Tooltip> <TooltipTrigger render={( <div - ref={titleRef} className={cn( 'min-w-0 grow truncate system-xs-semibold-uppercase text-text-secondary', hideInfo && 'text-xs!', diff --git a/web/app/components/workflow/variable-inspect/listening.tsx b/web/app/components/workflow/variable-inspect/listening.tsx index 3994355d58..cf702a623a 100644 --- a/web/app/components/workflow/variable-inspect/listening.tsx +++ b/web/app/components/workflow/variable-inspect/listening.tsx @@ -4,12 +4,12 @@ import type { Node } from 'reactflow' import type { ScheduleTriggerNodeType } from '@/app/components/workflow/nodes/trigger-schedule/types' import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types' import { Button } from '@langgenius/dify-ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import copy from 'copy-to-clipboard' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' -import Tooltip from '@/app/components/base/tooltip' import BlockIcon from '@/app/components/workflow/block-icon' import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator' @@ -179,28 +179,32 @@ const Listening: FC<ListeningProps> = ({ <div className="shrink-0 system-xs-regular whitespace-pre-line text-text-tertiary"> {t('nodes.triggerWebhook.debugUrlTitle', { ns: 'workflow' })} </div> - <Tooltip - popupContent={debugUrlCopied - ? t('nodes.triggerWebhook.debugUrlCopied', { ns: 'workflow' }) - : t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' })} - popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-xs rounded-md px-1.5 py-1" - position="top" - offset={{ mainAxis: -4 }} - needsDelay={true} - > - <button - type="button" - aria-label={t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' }) || ''} - className={`inline-flex items-center rounded-md border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-hidden focus-visible:outline-2 focus-visible:outline-components-panel-border focus-visible:outline-solid ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`} - onClick={() => { - copy(webhookDebugUrl) - setDebugUrlCopied(true) - }} + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' }) || ''} + className={`inline-flex items-center rounded-md border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-hidden focus-visible:outline-2 focus-visible:outline-components-panel-border focus-visible:outline-solid ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`} + onClick={() => { + copy(webhookDebugUrl) + setDebugUrlCopied(true) + }} + > + <span className="whitespace-nowrap text-text-primary"> + {webhookDebugUrl} + </span> + </button> + )} + /> + <TooltipContent + placement="top" + className="rounded-md border border-components-panel-border bg-components-tooltip-bg px-1.5 py-1 system-xs-regular text-text-primary shadow-lg backdrop-blur-xs" > - <span className="whitespace-nowrap text-text-primary"> - {webhookDebugUrl} - </span> - </button> + {debugUrlCopied + ? t('nodes.triggerWebhook.debugUrlCopied', { ns: 'workflow' }) + : t('nodes.triggerWebhook.debugUrlCopy', { ns: 'workflow' })} + </TooltipContent> </Tooltip> </div> )} diff --git a/web/app/components/workflow/workflow-preview/components/nodes/base.tsx b/web/app/components/workflow/workflow-preview/components/nodes/base.tsx index 34c8d753ce..5bfbd52561 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/base.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/base.tsx @@ -6,12 +6,12 @@ import type { NodeProps, } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { cloneElement, memo, } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import BlockIcon from '@/app/components/workflow/block-icon' import { BlockEnum, @@ -91,19 +91,21 @@ const BaseCard = ({ </div> { data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && ( - <Tooltip popupContent={( - <div className="w-[180px]"> + <Popover> + <PopoverTrigger + openOnHover + aria-label={t('nodes.iteration.parallelModeEnableTitle', { ns: 'workflow' })} + className="ml-1 flex items-center justify-center rounded-[5px] border border-text-warning bg-transparent px-[5px] py-[3px] system-2xs-medium-uppercase text-text-warning" + > + {t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })} + </PopoverTrigger> + <PopoverContent popupClassName="w-[180px] px-3 py-2 system-xs-regular text-text-tertiary"> <div className="font-extrabold"> {t('nodes.iteration.parallelModeEnableTitle', { ns: 'workflow' })} </div> {t('nodes.iteration.parallelModeEnableDesc', { ns: 'workflow' })} - </div> - )} - > - <div className="ml-1 flex items-center justify-center rounded-[5px] border border-text-warning px-[5px] py-[3px] system-2xs-medium-uppercase text-text-warning"> - {t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })} - </div> - </Tooltip> + </PopoverContent> + </Popover> ) } </div> diff --git a/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx b/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx index 6a69e5f2aa..391150649f 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx @@ -1,8 +1,8 @@ import type { NodeProps } from 'reactflow' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiHome5Fill } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { NodeSourceHandle } from '../../node-handle' const IterationStartNode = ({ id, data }: NodeProps) => { @@ -10,10 +10,14 @@ const IterationStartNode = ({ id, data }: NodeProps) => { return ( <div className="nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs"> - <Tooltip popupContent={t('blocks.iteration-start', { ns: 'workflow' })} asChild={false}> - <div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500"> + <Tooltip> + <TooltipTrigger + aria-label={t('blocks.iteration-start', { ns: 'workflow' })} + className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" + > <RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" /> - </div> + </TooltipTrigger> + <TooltipContent>{t('blocks.iteration-start', { ns: 'workflow' })}</TooltipContent> </Tooltip> <NodeSourceHandle id={id} diff --git a/web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx b/web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx index e67c0d9f10..67865be470 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx @@ -1,8 +1,8 @@ import type { NodeProps } from 'reactflow' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiHome5Fill } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { NodeSourceHandle } from '../../node-handle' const LoopStartNode = ({ id, data }: NodeProps) => { @@ -10,10 +10,14 @@ const LoopStartNode = ({ id, data }: NodeProps) => { return ( <div className="nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg"> - <Tooltip popupContent={t('blocks.loop-start', { ns: 'workflow' })} asChild={false}> - <div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500"> + <Tooltip> + <TooltipTrigger + aria-label={t('blocks.loop-start', { ns: 'workflow' })} + className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" + > <RiHome5Fill className="h-3 w-3 text-text-primary-on-surface" /> - </div> + </TooltipTrigger> + <TooltipContent>{t('blocks.loop-start', { ns: 'workflow' })}</TooltipContent> </Tooltip> <NodeSourceHandle id={id} diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index b849159867..3d94d82e64 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -7,7 +7,6 @@ This document tracks the Dify-web migration away from legacy overlay APIs. ## Scope - Deprecated imports: - - `@/app/components/base/tooltip` - `@/app/components/base/modal` - `@/app/components/base/dialog` - `@/app/components/base/drawer` @@ -36,6 +35,8 @@ This document tracks the Dify-web migration away from legacy overlay APIs. 1. Business/UI features outside `app/components/base/**` - Migrate old calls to semantic primitives from `@langgenius/dify-ui/*`. - Keep deprecated imports out of newly touched files. + - Use `@langgenius/dify-ui/tooltip` only for short, non-interactive labels where the trigger already has its own accessible name. + - Use `@langgenius/dify-ui/popover` or the web `Infotip` wrapper for explanatory, long-form, structured, or interactive content. 1. Legacy base components - Migrate legacy base callers gradually. - Keep deprecated imports out of newly touched files. @@ -75,6 +76,9 @@ back to `z-9999`. parent legacy overlay should be migrated instead. - When migrating a legacy overlay that has a high z-index, remove the z-index entirely — the new primitive's default `z-1002` handles it. +- When using Base UI trigger `render`, render a real `button` for button-like + triggers. If the trigger must render a non-button element, the primitive must + explicitly opt out of the native button behavior where that API is available. ### Post-migration cleanup diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index f74c5c9115..eb85d5d902 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -45,13 +45,6 @@ export const WEB_RESTRICTED_IMPORT_PATTERNS = [ ] export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ - { - group: [ - '**/base/tooltip', - '**/base/tooltip/index', - ], - message: 'Deprecated: use @langgenius/dify-ui/tooltip instead. See issue #32767.', - }, { group: [ '**/base/modal', From 2c9e30426d9049305dbc661e148bf54fb8f49066 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 14:49:26 +0800 Subject: [PATCH 05/53] refactor(web): migrate headless-ui components to dify-ui (#35962) --- web/__tests__/header/nav-flow.test.tsx | 27 +- web/app/account/(commonLayout)/avatar.tsx | 119 ++++---- .../header-opts/__tests__/index.spec.tsx | 152 +++++++---- .../app/annotation/header-opts/index.tsx | 131 ++++----- .../__tests__/access-control-dialog.spec.tsx | 3 +- .../__tests__/access-control.spec.tsx | 6 +- .../access-control-dialog.tsx | 49 +--- .../app/app-access-control/index.tsx | 2 +- .../__tests__/param-config-content.spec.tsx | 48 +--- .../text-to-speech/param-config-content.tsx | 182 ++++--------- .../credential-selector/index.tsx | 142 ++++------ .../operation/transfer-ownership.tsx | 64 ++--- .../__tests__/priority-selector.spec.tsx | 29 -- .../provider-added-card/priority-selector.tsx | 77 ------ .../app-selector/__tests__/index.spec.tsx | 172 ------------ .../components/header/app-selector/index.tsx | 117 -------- .../header/nav/__tests__/index.spec.tsx | 52 ++-- .../nav/nav-selector/__tests__/index.spec.tsx | 17 +- .../header/nav/nav-selector/index.tsx | 256 +++++++++--------- .../form-input-item.branches.spec.tsx | 8 +- .../form-input-item.sections.spec.tsx | 12 +- .../components/form-input-item.sections.tsx | 81 +++--- 22 files changed, 583 insertions(+), 1163 deletions(-) delete mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/priority-selector.spec.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx delete mode 100644 web/app/components/header/app-selector/__tests__/index.spec.tsx delete mode 100644 web/app/components/header/app-selector/index.tsx diff --git a/web/__tests__/header/nav-flow.test.tsx b/web/__tests__/header/nav-flow.test.tsx index 667f1e36b7..58c95f0a01 100644 --- a/web/__tests__/header/nav-flow.test.tsx +++ b/web/__tests__/header/nav-flow.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Nav from '@/app/components/header/nav' @@ -192,27 +193,23 @@ describe('Header Nav Flow', () => { }) it('opens the nested create menu and emits all app creation branches', async () => { - renderNav() - - fireEvent.click(screen.getByRole('button', { name: /Alpha/i })) - - const openCreateMenu = async () => { - fireEvent.click(await screen.findByText('menus.newApp')) - return screen.findByText('newApp.startFromBlank') + const user = userEvent.setup() + const clickCreateBranch = async (optionName: string) => { + const { unmount } = renderNav() + await user.click(screen.getByRole('button', { name: /Alpha/i })) + await user.hover(await screen.findByRole('menuitem', { name: /menus\.newApp/i })) + fireEvent.click(await screen.findByRole('menuitem', { name: optionName })) + unmount() } - await openCreateMenu() - fireEvent.click(await screen.findByText('newApp.startFromBlank')) - - await openCreateMenu() - fireEvent.click(await screen.findByText('newApp.startFromTemplate')) - - await openCreateMenu() - fireEvent.click(await screen.findByText('importDSL')) + await clickCreateBranch('newApp.startFromBlank') + await clickCreateBranch('newApp.startFromTemplate') + await clickCreateBranch('importDSL') expect(mockOnCreate).toHaveBeenNthCalledWith(1, 'blank') expect(mockOnCreate).toHaveBeenNthCalledWith(2, 'template') expect(mockOnCreate).toHaveBeenNthCalledWith(3, 'dsl') + expect(mockOnCreate).toHaveBeenCalledTimes(3) }) it('keeps the current nav label in sync with prop updates', async () => { diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index ccae182c9a..3fefb8a319 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -1,11 +1,13 @@ 'use client' -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' import { Avatar } from '@langgenius/dify-ui/avatar' +import { cn } from '@langgenius/dify-ui/cn' import { - RiGraduationCapFill, -} from '@remixicon/react' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { useSuspenseQuery } from '@tanstack/react-query' -import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' @@ -38,73 +40,48 @@ export default function AppSelector() { } return ( - <Menu as="div" className="relative inline-block text-left"> - { - ({ open }) => ( - <> - <div> - <MenuButton - className={` - p-1x inline-flex - items-center rounded-[20px] text-sm - text-text-primary - mobile:px-1 - ${open && 'bg-components-panel-bg-blur'} - `} - > - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} /> - </MenuButton> + <DropdownMenu modal={false}> + <DropdownMenuTrigger + aria-label={userProfile.name} + className={cn( + 'inline-flex items-center rounded-[20px] text-sm text-text-primary outline-hidden mobile:px-1', + 'hover:bg-components-panel-bg-blur focus-visible:bg-components-panel-bg-blur focus-visible:ring-1 focus-visible:ring-components-input-border-hover data-popup-open:bg-components-panel-bg-blur', + )} + > + <Avatar avatar={userProfile.avatar_url} name={userProfile.name} /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + popupClassName="w-60 max-w-80 divide-y divide-divider-subtle bg-components-panel-bg-blur p-0" + > + <div className="p-1"> + <div className="flex flex-nowrap items-center px-3 py-2"> + <div className="min-w-0 grow"> + <div className="system-md-medium break-all text-text-primary"> + {userProfile.name} + {isEducationAccount && ( + <PremiumBadge size="s" color="blue" className="ml-1 px-2!"> + <span className="mr-1 i-ri-graduation-cap-fill h-3 w-3" /> + <span className="system-2xs-medium">EDU</span> + </PremiumBadge> + )} + </div> + <div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div> </div> - <Transition - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" - > - <MenuItems - className=" - absolute -top-1 -right-2 w-60 max-w-80 - origin-top-right divide-y divide-divider-subtle rounded-lg bg-components-panel-bg-blur - shadow-lg - " - > - <MenuItem> - <div className="p-1"> - <div className="flex flex-nowrap items-center px-3 py-2"> - <div className="grow"> - <div className="system-md-medium break-all text-text-primary"> - {userProfile.name} - {isEducationAccount && ( - <PremiumBadge size="s" color="blue" className="ml-1 px-2!"> - <RiGraduationCapFill className="mr-1 h-3 w-3" /> - <span className="system-2xs-medium">EDU</span> - </PremiumBadge> - )} - </div> - <div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div> - </div> - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} /> - </div> - </div> - </MenuItem> - <MenuItem> - <div className="p-1" onClick={() => handleLogout()}> - <div - className="group flex h-9 cursor-pointer items-center justify-start rounded-lg px-3 hover:bg-state-base-hover" - > - <LogOut01 className="mr-1 flex h-4 w-4 text-text-tertiary" /> - <div className="text-[14px] font-normal text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</div> - </div> - </div> - </MenuItem> - </MenuItems> - </Transition> - </> - ) - } - </Menu> + <Avatar avatar={userProfile.avatar_url} name={userProfile.name} /> + </div> + </div> + <div className="p-1"> + <DropdownMenuItem + className="h-9 justify-start px-3" + onClick={handleLogout} + > + <LogOut01 className="mr-1 flex h-4 w-4 text-text-tertiary" /> + <span className="text-[14px] font-normal text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</span> + </DropdownMenuItem> + </div> + </DropdownMenuContent> + </DropdownMenu> ) } diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 944a8563eb..5e7b2dc1d0 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import type { ComponentProps } from 'react' import type { Mock } from 'vitest' import type { AnnotationItemBasic } from '../../type' import type { Locale } from '@/i18n-config' -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { useLocale } from '@/context/i18n' @@ -128,21 +128,15 @@ vi.mock('@headlessui/react', () => { } }) -let lastCSVDownloaderProps: Record<string, unknown> | undefined -const mockCSVDownloader = vi.fn(({ children, ...props }) => { - lastCSVDownloaderProps = props - return ( - <div data-testid="csv-downloader"> - {children} - </div> - ) -}) +const mockJsonToCSV = vi.fn((_: unknown) => 'csv-content') +const mockCSVDownloader = vi.fn(({ children }) => <>{children}</>) vi.mock('react-papaparse', () => ({ useCSVDownloader: () => ({ CSVDownloader: (props: any) => mockCSVDownloader(props), Type: { Link: 'link' }, }), + jsonToCSV: (data: unknown) => mockJsonToCSV(data), })) vi.mock('@/service/annotation', () => ({ @@ -194,33 +188,28 @@ const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) = const expandExportMenu = async (user: ReturnType<typeof userEvent.setup>) => { await openOperationsPopover(user) - const exportLabel = await screen.findByText('appAnnotation.table.header.bulkExport') - const exportButton = exportLabel.closest('button') as HTMLButtonElement - expect(exportButton).toBeTruthy() - await user.click(exportButton) + const exportItem = await screen.findByRole('menuitem', { name: /appAnnotation\.table\.header\.bulkExport/i }) + await user.hover(exportItem) } -const getExportButtons = async () => { - const csvLabel = await screen.findByText('CSV') - const jsonLabel = await screen.findByText('JSONL') - const csvButton = csvLabel.closest('button') as HTMLButtonElement - const jsonButton = jsonLabel.closest('button') as HTMLButtonElement - expect(csvButton).toBeTruthy() - expect(jsonButton).toBeTruthy() +const getExportItems = async () => { + const csvItem = await screen.findByRole('menuitem', { name: 'CSV' }) + const jsonItem = await screen.findByRole('menuitem', { name: 'JSONL' }) return { - csvButton, - jsonButton, + csvItem, + jsonItem, } } -const clickOperationAction = async ( - user: ReturnType<typeof userEvent.setup>, - translationKey: string, -) => { - const label = await screen.findByText(translationKey) - const button = label.closest('button') as HTMLButtonElement - expect(button).toBeTruthy() - await user.click(button) +const clickMenuItem = async (item: HTMLElement) => { + await act(async () => { + item.click() + }) +} + +const clickOperationAction = async (translationKey: string) => { + const item = await screen.findByRole('menuitem', { name: translationKey }) + await clickMenuItem(item) } const mockAnnotations: AnnotationItemBasic[] = [ @@ -237,11 +226,14 @@ describe('HeaderOptions', () => { beforeEach(() => { vi.clearAllMocks() vi.useRealTimers() - mockCSVDownloader.mockClear() - lastCSVDownloaderProps = undefined + mockJsonToCSV.mockReturnValue('csv-content') mockedFetchAnnotations.mockResolvedValue({ data: [] }) }) + afterEach(() => { + vi.restoreAllMocks() + }) + it('should fetch annotations on mount and render enabled export actions when data exist', async () => { mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations }) const user = userEvent.setup() @@ -253,22 +245,69 @@ describe('HeaderOptions', () => { await expandExportMenu(user) - const { csvButton, jsonButton } = await getExportButtons() + const { csvItem, jsonItem } = await getExportItems() - expect(csvButton).not.toBeDisabled() - expect(jsonButton).not.toBeDisabled() + expect(csvItem).not.toHaveAttribute('data-disabled') + expect(jsonItem).not.toHaveAttribute('data-disabled') - await waitFor(() => { - expect(lastCSVDownloaderProps).toMatchObject({ - bom: true, - filename: 'annotations-en-US', - type: 'link', - data: [ - ['Question', 'Answer'], - ['Question 1', 'Answer 1'], - ], + await clickMenuItem(csvItem) + + expect(mockJsonToCSV).toHaveBeenCalledWith([ + ['Question', 'Answer'], + ['Question 1', 'Answer 1'], + ]) + }) + + it('should trigger CSV download with locale-specific filename', async () => { + mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations }) + const user = userEvent.setup() + const originalCreateElement = document.createElement.bind(document) + const anchor = originalCreateElement('a') as HTMLAnchorElement + const clickSpy = vi.spyOn(anchor, 'click').mockImplementation(vi.fn()) + const createElementSpy = vi.spyOn(document, 'createElement') + .mockImplementation((tagName: Parameters<Document['createElement']>[0]) => { + if (tagName === 'a') + return anchor + return originalCreateElement(tagName) }) + let capturedBlob: Blob | null = null + const objectURLSpy = vi.spyOn(URL, 'createObjectURL') + .mockImplementation((blob) => { + capturedBlob = blob as Blob + return 'blob://mock-url' + }) + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(vi.fn()) + + renderComponent({}, LanguagesSupported[1]) + + await expandExportMenu(user) + + const { csvItem } = await getExportItems() + await clickMenuItem(csvItem) + + expect(mockJsonToCSV).toHaveBeenCalledWith([ + ['问题', '答案'], + ['Question 1', 'Answer 1'], + ]) + expect(createElementSpy).toHaveBeenCalled() + expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.csv`) + expect(clickSpy).toHaveBeenCalled() + expect(revokeSpy).toHaveBeenCalledWith('blob://mock-url') + + expect(capturedBlob).toBeInstanceOf(Blob) + expect(capturedBlob!.type).toBe('text/csv;charset=utf-8;') + + const blobContent = await new Promise<string>((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.readAsText(capturedBlob!) }) + expect(blobContent).toBe('csv-content') + + clickSpy.mockRestore() + createElementSpy.mockRestore() + objectURLSpy.mockRestore() + revokeSpy.mockRestore() }) it('should disable export actions when there are no annotations', async () => { @@ -277,14 +316,11 @@ describe('HeaderOptions', () => { await expandExportMenu(user) - const { csvButton, jsonButton } = await getExportButtons() + const { csvItem, jsonItem } = await getExportItems() - expect(csvButton)!.toBeDisabled() - expect(jsonButton)!.toBeDisabled() - - expect(lastCSVDownloaderProps).toMatchObject({ - data: [['Question', 'Answer']], - }) + expect(csvItem).toHaveAttribute('data-disabled') + expect(jsonItem).toHaveAttribute('data-disabled') + expect(mockJsonToCSV).not.toHaveBeenCalled() }) it('should open the add annotation modal and forward the onAdd callback', async () => { @@ -321,7 +357,7 @@ describe('HeaderOptions', () => { renderComponent({ onAdded }) await openOperationsPopover(user) - await clickOperationAction(user, 'appAnnotation.table.header.bulkImport') + await clickOperationAction('appAnnotation.table.header.bulkImport') expect(await screen.findByText('appAnnotation.batchModal.title'))!.toBeInTheDocument() await user.click( @@ -354,10 +390,8 @@ describe('HeaderOptions', () => { await expandExportMenu(user) - await waitFor(() => expect(mockCSVDownloader).toHaveBeenCalled()) - - const { jsonButton } = await getExportButtons() - await user.click(jsonButton) + const { jsonItem } = await getExportItems() + await clickMenuItem(jsonItem) expect(createElementSpy).toHaveBeenCalled() expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.jsonl`) @@ -396,7 +430,7 @@ describe('HeaderOptions', () => { renderComponent({ onAdded }) await openOperationsPopover(user) - await clickOperationAction(user, 'appAnnotation.table.header.clearAll') + await clickOperationAction('appAnnotation.table.header.clearAll') await screen.findByText('appAnnotation.table.header.clearAllConfirm') const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) @@ -416,7 +450,7 @@ describe('HeaderOptions', () => { renderComponent({ onAdded }) await openOperationsPopover(user) - await clickOperationAction(user, 'appAnnotation.table.header.clearAll') + await clickOperationAction('appAnnotation.table.header.clearAll') await screen.findByText('appAnnotation.table.header.clearAllConfirm') const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) await user.click(confirmButton) diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index fc27524c71..6814c3692c 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -1,19 +1,21 @@ 'use client' import type { FC } from 'react' import type { AnnotationItemBasic } from '../type' -import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' -import { Fragment, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { - useCSVDownloader, + jsonToCSV, } from 'react-papaparse' import { useLocale } from '@/context/i18n' @@ -54,6 +56,15 @@ const downloadAnnotationJsonl = (list: AnnotationItemBasic[], locale: string) => downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` }) } +const downloadAnnotationCsv = (list: AnnotationItemBasic[], locale: string) => { + const content = jsonToCSV([ + locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN, + ...list.map(item => [item.question, item.answer]), + ]) + const file = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' }) + downloadBlob({ data: file, fileName: `annotations-${locale}.csv` }) +} + const OperationsMenu: FC<OperationsMenuProps> = ({ list, onClose, @@ -63,88 +74,62 @@ const OperationsMenu: FC<OperationsMenuProps> = ({ }) => { const { t } = useTranslation() const locale = useLocale() - const { CSVDownloader, Type } = useCSVDownloader() const annotationUnavailable = list.length === 0 return ( - <div className="w-full py-1"> - <button - type="button" - className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50" + <> + <DropdownMenuItem + className="gap-2" onClick={() => { onClose() onBulkImport() }} > - <span aria-hidden className="i-custom-vender-line-files-file-plus-02 h-4 w-4 text-text-tertiary" /> - <span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkImport', { ns: 'appAnnotation' })}</span> - </button> - <Menu as="div" className="relative h-full w-full"> - <MenuButton className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"> - <span aria-hidden className="i-custom-vender-line-files-file-download-02 h-4 w-4 text-text-tertiary" /> - <span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkExport', { ns: 'appAnnotation' })}</span> - <span aria-hidden className="i-custom-vender-line-arrows-chevron-right h-[14px] w-[14px] shrink-0 text-text-tertiary" /> - </MenuButton> - <Transition - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" + <span aria-hidden className="i-custom-vender-line-files-file-plus-02 size-4 shrink-0 text-text-tertiary" /> + {t('table.header.bulkImport', { ns: 'appAnnotation' })} + </DropdownMenuItem> + <DropdownMenuSub> + <DropdownMenuSubTrigger className="gap-2"> + <span aria-hidden className="i-custom-vender-line-files-file-download-02 size-4 shrink-0 text-text-tertiary" /> + {t('table.header.bulkExport', { ns: 'appAnnotation' })} + </DropdownMenuSubTrigger> + <DropdownMenuSubContent + placement="left-start" + sideOffset={4} + popupClassName="min-w-[100px]" > - <MenuItems - className={cn( - 'absolute top-px left-1 z-10 min-w-[100px] origin-top-right -translate-x-full rounded-xl border-[0.5px] border-components-panel-on-panel-item-bg bg-components-panel-bg py-1 shadow-xs', - )} + <DropdownMenuItem + disabled={annotationUnavailable} + onClick={() => { + onClose() + downloadAnnotationCsv(list, locale) + }} > - <CSVDownloader - type={Type.Link} - filename={`annotations-${locale}`} - bom={true} - data={[ - locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN, - ...list.map(item => [item.question, item.answer]), - ]} - > - <button - type="button" - disabled={annotationUnavailable} - className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50" - onClick={onClose} - > - <span className="grow text-left system-sm-regular text-text-secondary">CSV</span> - </button> - </CSVDownloader> - <button - type="button" - disabled={annotationUnavailable} - className={cn('mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50', 'border-0!')} - onClick={() => { - onClose() - onExportJsonl() - }} - > - <span className="grow text-left system-sm-regular text-text-secondary">JSONL</span> - </button> - </MenuItems> - </Transition> - </Menu> - <button - type="button" + CSV + </DropdownMenuItem> + <DropdownMenuItem + disabled={annotationUnavailable} + onClick={() => { + onClose() + onExportJsonl() + }} + > + JSONL + </DropdownMenuItem> + </DropdownMenuSubContent> + </DropdownMenuSub> + <DropdownMenuItem + variant="destructive" + className="gap-2" onClick={() => { onClose() onClearAll() }} - className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50" > - <span aria-hidden className="i-ri-delete-bin-line h-4 w-4" /> - <span className="grow text-left system-sm-regular"> - {t('table.header.clearAll', { ns: 'appAnnotation' })} - </span> - </button> - </div> + <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> + {t('table.header.clearAll', { ns: 'appAnnotation' })} + </DropdownMenuItem> + </> ) } @@ -204,7 +189,7 @@ const HeaderOptions: FC<Props> = ({ <span aria-hidden className="mr-0.5 i-ri-add-line h-4 w-4" /> <div>{t('table.header.addAnnotation', { ns: 'appAnnotation' })}</div> </Button> - <DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}> + <DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}> <DropdownMenuTrigger aria-label={t('operation.more', { ns: 'common' })} className="mr-0 box-border inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-0 text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover data-popup-open:border-components-button-secondary-border-hover data-popup-open:bg-components-button-secondary-bg-hover" @@ -214,7 +199,7 @@ const HeaderOptions: FC<Props> = ({ <DropdownMenuContent placement="bottom-end" sideOffset={4} - popupClassName="w-[155px] overflow-visible py-0" + popupClassName="w-[155px]" > <OperationsMenu list={list} diff --git a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx index 9b3dd8ee05..13331f3f9c 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx @@ -21,8 +21,7 @@ describe('AccessControlDialog', () => { </AccessControlDialog>, ) - const closeButton = document.body.querySelector('div.absolute.right-5.top-5') as HTMLElement - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) await waitFor(() => { expect(onClose).toHaveBeenCalledTimes(1) diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 21dd8c5fc2..4aaea1670f 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -176,7 +176,7 @@ describe('AccessControlItem', () => { }) }) -// AccessControlDialog renders a headless UI dialog with a manual close control +// AccessControlDialog renders the shared dialog primitive with a close control. describe('AccessControlDialog', () => { it('should render dialog content when visible', () => { render( @@ -191,13 +191,13 @@ describe('AccessControlDialog', () => { it('should trigger onClose when clicking the close control', async () => { const handleClose = vi.fn() - const { container } = render( + render( <AccessControlDialog show onClose={handleClose}> <div>Dialog Content</div> </AccessControlDialog>, ) - const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement + const closeButton = screen.getByRole('button', { name: 'Close' }) fireEvent.click(closeButton) await waitFor(() => { diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx index bbf5329c9d..611c6f1c92 100644 --- a/web/app/components/app/app-access-control/access-control-dialog.tsx +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -1,8 +1,11 @@ import type { ReactNode } from 'react' -import { Dialog, Transition } from '@headlessui/react' import { cn } from '@langgenius/dify-ui/cn' -import { RiCloseLine } from '@remixicon/react' -import { Fragment, useCallback } from 'react' +import { + Dialog, + DialogCloseButton, + DialogContent, +} from '@langgenius/dify-ui/dialog' +import { useCallback } from 'react' type DialogProps = { className?: string @@ -21,40 +24,12 @@ const AccessControlDialog = ({ onClose?.() }, [onClose]) return ( - <Transition appear show={show} as={Fragment}> - <Dialog as="div" open={true} className="relative z-99" onClose={() => null}> - <Transition.Child - as={Fragment} - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - <div className="fixed inset-0 bg-background-overlay" /> - </Transition.Child> - - <div className="fixed inset-0 flex items-center justify-center"> - <Transition.Child - as={Fragment} - enter="ease-out duration-300" - enterFrom="opacity-0 scale-95" - enterTo="opacity-100 scale-100" - leave="ease-in duration-200" - leaveFrom="opacity-100 scale-100" - leaveTo="opacity-0 scale-95" - > - <Dialog.Panel className={cn('relative h-auto min-h-[323px] w-[600px] overflow-y-auto rounded-2xl bg-components-panel-bg p-0 shadow-xl transition-all', className)}> - <div onClick={() => close()} className="absolute top-5 right-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center"> - <RiCloseLine className="h-5 w-5 text-text-tertiary" /> - </div> - {children} - </Dialog.Panel> - </Transition.Child> - </div> - </Dialog> - </Transition> + <Dialog open={show} onOpenChange={open => !open && close()}> + <DialogContent className={cn('min-h-[323px] w-[600px] p-0', className)}> + <DialogCloseButton className="top-5 right-5 h-8 w-8" /> + {children} + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index cff670e10f..593664c918 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -1,8 +1,8 @@ 'use client' import type { Subject } from '@/models/access-control' import type { App } from '@/types/app' -import { Description as DialogDescription, DialogTitle } from '@headlessui/react' import { Button } from '@langgenius/dify-ui/button' +import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx index b4d5beefa6..27a6cd96d0 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx @@ -64,6 +64,9 @@ const renderWithProvider = ( ) } +const getLanguageSelect = () => screen.getByRole('combobox', { name: /voice\.voiceSettings\.language/ }) +const getVoiceSelect = () => screen.getByRole('combobox', { name: /voice\.voiceSettings\.voice/ }) + describe('ParamConfigContent', () => { beforeEach(() => { vi.clearAllMocks() @@ -116,16 +119,13 @@ describe('ParamConfigContent', () => { it('should display language listbox button', () => { renderWithProvider() - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(1) + expect(getLanguageSelect()).toBeInTheDocument() }) it('should display current voice in listbox button', () => { renderWithProvider() - const buttons = screen.getAllByRole('button') - const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy')) - expect(voiceButton)!.toBeInTheDocument() + expect(getVoiceSelect()).toHaveTextContent('Alloy') }) it('should render audition button when language has example', () => { @@ -152,8 +152,7 @@ describe('ParamConfigContent', () => { text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled }, }) - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThan(0) + expect(getLanguageSelect()).toBeInTheDocument() }) it('should render with no voice set and use first as default', () => { @@ -161,9 +160,7 @@ describe('ParamConfigContent', () => { text2speech: { enabled: true, language: 'en-US', voice: 'nonexistent', autoPlay: TtsAutoPlay.disabled }, }) - const buttons = screen.getAllByRole('button') - const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy')) - expect(voiceButton)!.toBeInTheDocument() + expect(getVoiceSelect()).toHaveTextContent('Alloy') }) }) @@ -239,10 +236,7 @@ describe('ParamConfigContent', () => { it('should open language listbox and show options', async () => { renderWithProvider() - const buttons = screen.getAllByRole('button') - const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.')) - expect(languageButton).toBeDefined() - await userEvent.click(languageButton!) + await userEvent.click(getLanguageSelect()) const options = await screen.findAllByRole('option') expect(options.length).toBeGreaterThanOrEqual(2) @@ -252,10 +246,7 @@ describe('ParamConfigContent', () => { const onChange = vi.fn() renderWithProvider({ onChange }) - const buttons = screen.getAllByRole('button') - const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.')) - expect(languageButton).toBeDefined() - await userEvent.click(languageButton!) + await userEvent.click(getLanguageSelect()) const options = await screen.findAllByRole('option') expect(options.length).toBeGreaterThan(1) await userEvent.click(options[1]!) @@ -266,10 +257,7 @@ describe('ParamConfigContent', () => { const onChange = vi.fn() renderWithProvider({ onChange }) - const buttons = screen.getAllByRole('button') - const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy')) - expect(voiceButton).toBeDefined() - await userEvent.click(voiceButton!) + await userEvent.click(getVoiceSelect()) const options = await screen.findAllByRole('option') expect(options.length).toBeGreaterThan(1) await userEvent.click(options[1]!) @@ -279,10 +267,7 @@ describe('ParamConfigContent', () => { it('should show selected language option in listbox', async () => { renderWithProvider() - const buttons = screen.getAllByRole('button') - const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.')) - expect(languageButton).toBeDefined() - await userEvent.click(languageButton!) + await userEvent.click(getLanguageSelect()) const options = await screen.findAllByRole('option') expect(options.length).toBeGreaterThanOrEqual(1) @@ -294,10 +279,7 @@ describe('ParamConfigContent', () => { it('should show selected voice option in listbox', async () => { renderWithProvider() - const buttons = screen.getAllByRole('button') - const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy')) - expect(voiceButton).toBeDefined() - await userEvent.click(voiceButton!) + await userEvent.click(getVoiceSelect()) const options = await screen.findAllByRole('option') expect(options.length).toBeGreaterThanOrEqual(1) @@ -320,11 +302,7 @@ describe('ParamConfigContent', () => { const placeholderTexts = screen.getAllByText(/placeholder\.select/) expect(placeholderTexts.length).toBeGreaterThanOrEqual(2) - const disabledButtons = screen - .getAllByRole('button') - .filter(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true') - - expect(disabledButtons.length).toBeGreaterThanOrEqual(1) + expect(getVoiceSelect()).toHaveAttribute('data-disabled') }) it('should call useAppVoices with empty appId when pathname has no app segment', () => { diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index f7c3b738a9..24670fa748 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -1,11 +1,15 @@ 'use client' import type { OnFeaturesChange } from '@/app/components/base/features/types' -import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react' -import { cn } from '@langgenius/dify-ui/cn' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' import { produce } from 'immer' -import * as React from 'react' -import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import { replace } from 'string-ts' import AudioBtn from '@/app/components/base/audio-btn' @@ -35,6 +39,9 @@ const VoiceParamConfig = ({ const appId = (matched?.length && matched[1]) ? matched[1] : '' const text2speech = useFeatures(state => state.features.text2speech) const featuresStore = useFeaturesStore() + const formatLanguageName = (item: SelectOption) => { + return t(`voice.language.${replace(String(item.value), '-', '')}`, item.name, { ns: 'common' as const }) + } let languageItem = languages.find(item => item.value === text2speech?.language) if (languages && !languageItem) @@ -70,21 +77,14 @@ const VoiceParamConfig = ({ <> <div className="mb-4 flex items-center justify-between"> <div className="system-xl-semibold text-text-primary">{t('voice.voiceSettings.title', { ns: 'appDebug' })}</div> - <div - className="cursor-pointer p-1" - role="button" - tabIndex={0} + <button + type="button" + className="rounded-md p-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden" aria-label={t('appDebug:voice.voiceSettings.close')} onClick={onClose} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onClose() - } - }} > - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> - </div> + <span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" /> + </button> </div> <div className="mb-3"> <div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary"> @@ -100,129 +100,63 @@ const VoiceParamConfig = ({ ))} </Infotip> </div> - <Listbox - value={languageItem} - onChange={(value: SelectOption) => { + <Select + value={languageItem ? String(languageItem.value) : null} + onValueChange={(nextValue) => { + if (!nextValue) + return handleChange({ - language: String(value.value), + language: nextValue, }) }} > - <div className="relative h-8"> - <ListboxButton - className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pr-10 pl-3 group-hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden sm:text-sm sm:leading-6" - > - <span className={cn('block truncate text-left text-text-secondary', !languageItem?.name && 'text-text-tertiary')}> - {languageItem?.name - ? t(`voice.language.${replace(languageItem?.value ?? '', '-', '')}`, languageItem?.name, { ns: 'common' as const }) - : localLanguagePlaceholder} - </span> - <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> - <span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" /> - </span> - </ListboxButton> - <Transition - as={Fragment} - leave="transition ease-in duration-100" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - - <ListboxOptions - className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm" - > - {languages.map(item => ( - <ListboxOption - key={item.value} - className="relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover data-active:bg-state-base-active" - value={item} - disabled={false} - > - {({ /* active, */ selected }) => ( - <> - <span - className={cn('block', selected && 'font-normal')} - > - {t(`voice.language.${replace((item.value), '-', '')}`, item.name, { ns: 'common' as const })} - </span> - {(selected || item.value === text2speech?.language) && ( - <span - className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')} - > - <span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" /> - </span> - )} - </> - )} - </ListboxOption> - ))} - </ListboxOptions> - </Transition> - </div> - </Listbox> + <SelectTrigger aria-label={t('voice.voiceSettings.language', { ns: 'appDebug' })} className="w-full"> + {languageItem ? formatLanguageName(languageItem) : localLanguagePlaceholder} + </SelectTrigger> + <SelectContent listClassName="max-h-60"> + {languages.map(item => ( + <SelectItem key={item.value} value={String(item.value)}> + <SelectItemText> + {formatLanguageName(item)} + </SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> </div> <div className="mb-3"> <div className="mb-1 py-1 system-sm-semibold text-text-secondary"> {t('voice.voiceSettings.voice', { ns: 'appDebug' })} </div> <div className="flex items-center gap-1"> - <Listbox - value={voiceItem} + <Select + value={voiceItem ? String(voiceItem.value) : null} disabled={!languageItem} - onChange={(value: SelectOption) => { + onValueChange={(nextValue) => { + if (!nextValue) + return handleChange({ - voice: String(value.value), + voice: nextValue, }) }} > - <div className="relative h-8 grow"> - <ListboxButton - className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pr-10 pl-3 group-hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden sm:text-sm sm:leading-6" - > - <span - className={cn('block truncate text-left text-text-secondary', !voiceItem?.name && 'text-text-tertiary')} - > - {voiceItem?.name ?? localVoicePlaceholder} - </span> - <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> - <span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" /> - </span> - </ListboxButton> - <Transition - as={Fragment} - leave="transition ease-in duration-100" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - - <ListboxOptions - className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm" - > - {voiceItems?.map((item: SelectOption) => ( - <ListboxOption - key={item.value} - className="relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover data-active:bg-state-base-active" - value={item} - disabled={false} - > - {({ /* active, */ selected }) => ( - <> - <span className={cn('block', selected && 'font-normal')}>{item.name}</span> - {(selected || item.value === text2speech?.voice) && ( - <span - className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')} - > - <span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" /> - </span> - )} - </> - )} - </ListboxOption> - ))} - </ListboxOptions> - </Transition> + <div className="grow"> + <SelectTrigger aria-label={t('voice.voiceSettings.voice', { ns: 'appDebug' })} className="w-full"> + {voiceItem?.name ?? localVoicePlaceholder} + </SelectTrigger> + <SelectContent listClassName="max-h-60"> + {voiceItems?.map((item: SelectOption) => ( + <SelectItem key={item.value} value={String(item.value)}> + <SelectItemText> + {item.name} + </SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> </div> - </Listbox> + </Select> {languageItem?.example && ( <div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1" data-testid="audition-button"> <AudioBtn @@ -253,4 +187,4 @@ const VoiceParamConfig = ({ ) } -export default React.memo(VoiceParamConfig) +export default VoiceParamConfig diff --git a/web/app/components/base/notion-page-selector/credential-selector/index.tsx b/web/app/components/base/notion-page-selector/credential-selector/index.tsx index c8db7bc978..81ee1c06d8 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/index.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/index.tsx @@ -1,7 +1,12 @@ 'use client' -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' -import * as React from 'react' -import { Fragment, useMemo } from 'react' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { CredentialIcon } from '@/app/components/datasets/common/credential-icon' export type NotionCredential = { @@ -17,99 +22,66 @@ type CredentialSelectorProps = { onSelect: (v: string) => void } +const getDisplayName = (item?: NotionCredential) => { + return item?.workspaceName || item?.credentialName || '' +} + const CredentialSelector = ({ value, items, onSelect, }: CredentialSelectorProps) => { - const currentCredential = items.find(item => item.credentialId === value)! - - const getDisplayName = (item: NotionCredential) => { - return item.workspaceName || item.credentialName - } - - const currentDisplayName = useMemo(() => { - return getDisplayName(currentCredential) - }, [currentCredential]) + const currentCredential = items.find(item => item.credentialId === value) ?? items[0] + const currentDisplayName = getDisplayName(currentCredential) return ( - <Menu as="div" className="relative inline-block text-left"> - { - ({ open }) => ( - <> - <MenuButton - className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`} - data-testid="notion-credential-selector-btn" + <Select value={currentCredential?.credentialId ?? null} onValueChange={nextValue => nextValue && onSelect(nextValue)}> + <SelectTrigger + className="w-[168px]" + data-testid="notion-credential-selector-btn" + > + <span className="flex min-w-0 items-center"> + <CredentialIcon + className="mr-2 shrink-0" + avatarUrl={currentCredential?.workspaceIcon} + name={currentDisplayName} + size={20} + /> + <span + className="truncate" + title={currentDisplayName} + data-testid="notion-credential-selector-name" + > + {currentDisplayName} + </span> + </span> + </SelectTrigger> + <SelectContent popupClassName="w-80" listClassName="max-h-50"> + {items.map((item) => { + const displayName = getDisplayName(item) + return ( + <SelectItem + key={item.credentialId} + value={item.credentialId} + className="h-9 px-3" + data-testid={`notion-credential-item-${item.credentialId}`} > <CredentialIcon - className="mr-2" - avatarUrl={currentCredential?.workspaceIcon} - name={currentDisplayName} + className="mr-2 shrink-0" + avatarUrl={item.workspaceIcon} + name={displayName} size={20} /> - <div - className="mr-1 w-[90px] truncate text-left text-sm font-medium text-text-secondary" - title={currentDisplayName} - data-testid="notion-credential-selector-name" - > - {currentDisplayName} - </div> - <div className="i-ri-arrow-down-s-line h-4 w-4 text-text-secondary" /> - </MenuButton> - <Transition - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" - > - <MenuItems - className="absolute top-8 left-0 z-10 w-80 - origin-top-right rounded-lg border-[0.5px] - border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5" - > - <div className="max-h-50 overflow-auto p-1"> - { - items.map((item) => { - const displayName = getDisplayName(item) - return ( - <MenuItem key={item.credentialId}> - <div - className="flex h-9 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover" - onClick={() => onSelect(item.credentialId)} - data-testid={`notion-credential-item-${item.credentialId}`} - > - <CredentialIcon - className="mr-2 shrink-0" - avatarUrl={item.workspaceIcon} - name={displayName} - size={20} - /> - <div - className="mr-2 grow truncate system-sm-medium text-text-secondary" - title={displayName} - > - {displayName} - </div> - {/* // ?Cannot get page length with new auth system */} - {/* <div className='system-xs-medium shrink-0 text-text-accent'> - {item.pages.length} {t('common.dataSource.notion.selector.pageSelected')} - </div> */} - </div> - </MenuItem> - ) - }) - } - </div> - </MenuItems> - </Transition> - </> - ) - } - </Menu> + <SelectItemText title={displayName}> + {displayName} + </SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ) + })} + </SelectContent> + </Select> ) } -export default React.memo(CredentialSelector) +export default CredentialSelector diff --git a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx index 97a4a5b2f2..e46989643e 100644 --- a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx +++ b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx @@ -1,11 +1,15 @@ 'use client' -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { RiArrowDownSLine, } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' -import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' @@ -31,39 +35,29 @@ const TransferOwnership = ({ onOperate }: Props) => { } return ( - <Menu as="div" className="relative h-full w-full"> - { - ({ open }) => ( - <> - <MenuButton className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}> - {t('members.owner', { ns: 'common' })} - <RiArrowDownSLine className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} /> - </MenuButton> - <Transition - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" - > - <MenuItems - className={cn('absolute top-[52px] right-0 z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs')} - > - <div className="p-1"> - <MenuItem> - <div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={onOperate}> - <div className="system-md-regular whitespace-nowrap text-text-secondary">{t('members.transferOwnership', { ns: 'common' })}</div> - </div> - </MenuItem> - </div> - </MenuItems> - </Transition> - </> - ) - } - </Menu> + <DropdownMenu modal={false}> + <DropdownMenuTrigger + className={cn( + 'group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary outline-hidden', + 'hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover data-popup-open:bg-state-base-hover', + )} + > + {t('members.owner', { ns: 'common' })} + <RiArrowDownSLine className="hidden h-4 w-4 group-hover:block group-data-popup-open:block" /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + popupClassName="bg-components-panel-bg-blur p-1 backdrop-blur-xs" + > + <DropdownMenuItem + className="h-auto px-3 py-2" + onClick={onOperate} + > + <span className="system-md-regular whitespace-nowrap text-text-secondary">{t('members.transferOwnership', { ns: 'common' })}</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/priority-selector.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/priority-selector.spec.tsx deleted file mode 100644 index d122bf921b..0000000000 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/priority-selector.spec.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import PrioritySelector from '../priority-selector' - -describe('PrioritySelector', () => { - const mockOnSelect = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render selector button', () => { - render(<PrioritySelector value="system" onSelect={mockOnSelect} />) - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should call onSelect when option clicked', () => { - render(<PrioritySelector value="system" onSelect={mockOnSelect} />) - fireEvent.click(screen.getByRole('button')) - const option = screen.getByText('common.modelProvider.apiKey') - fireEvent.click(option) - expect(mockOnSelect).toHaveBeenCalled() - }) - - it('should display priority use header in popover', () => { - render(<PrioritySelector value="custom" onSelect={mockOnSelect} />) - fireEvent.click(screen.getByRole('button')) - expect(screen.getByText('common.modelProvider.card.priorityUse')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx deleted file mode 100644 index a74c400035..0000000000 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { FC } from 'react' -import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { - RiCheckLine, - RiMoreFill, -} from '@remixicon/react' -import { Fragment } from 'react' -import { useTranslation } from 'react-i18next' -import { PreferredProviderTypeEnum } from '../declarations' - -type SelectorProps = { - value?: string - onSelect: (key: PreferredProviderTypeEnum) => void -} -const Selector: FC<SelectorProps> = ({ - value, - onSelect, -}) => { - const { t } = useTranslation() - const options = [ - { - key: PreferredProviderTypeEnum.custom, - text: t('modelProvider.apiKey', { ns: 'common' }), - }, - { - key: PreferredProviderTypeEnum.system, - text: t('modelProvider.quota', { ns: 'common' }), - }, - ] - - return ( - <Popover className="relative"> - <PopoverButton as="div"> - { - ({ open }) => ( - <Button className={cn( - 'h-6 w-6 rounded-md px-0', - open && 'bg-components-button-secondary-bg-hover', - )} - > - <RiMoreFill className="h-3 w-3" /> - </Button> - ) - } - </PopoverButton> - <Transition - as={Fragment} - leave="transition ease-in duration-100" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - <PopoverPanel className="absolute top-7 right-0 z-10 w-[144px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"> - <div className="p-1"> - <div className="px-3 pt-2 pb-1 text-sm font-medium text-text-secondary">{t('modelProvider.card.priorityUse', { ns: 'common' })}</div> - { - options.map(option => ( - <PopoverButton as={Fragment} key={option.key}> - <div - className="flex h-9 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-components-panel-on-panel-item-bg-hover" - onClick={() => onSelect(option.key)} - > - <div className="grow">{option.text}</div> - {value === option.key && <RiCheckLine className="h-4 w-4 text-text-accent" />} - </div> - </PopoverButton> - )) - } - </div> - </PopoverPanel> - </Transition> - </Popover> - ) -} - -export default Selector diff --git a/web/app/components/header/app-selector/__tests__/index.spec.tsx b/web/app/components/header/app-selector/__tests__/index.spec.tsx deleted file mode 100644 index 2d255c006e..0000000000 --- a/web/app/components/header/app-selector/__tests__/index.spec.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import type { AppDetailResponse } from '@/models/app' -import { act, fireEvent, render, screen } from '@testing-library/react' -import { vi } from 'vitest' -import { useAppContext } from '@/context/app-context' -import { useRouter } from '@/next/navigation' -import AppSelector from '../index' - -// Mock next/navigation -vi.mock('@/next/navigation', () => ({ - useRouter: vi.fn(), -})) - -// Mock app context -vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), -})) - -// Mock CreateAppDialog to avoid complex dependencies -vi.mock('@/app/components/app/create-app-dialog', () => ({ - default: ({ show, onClose }: { show: boolean, onClose: () => void }) => show - ? ( - <div data-testid="create-app-dialog"> - <button onClick={onClose}>Close</button> - </div> - ) - : null, -})) - -describe('AppSelector Component', () => { - const mockPush = vi.fn() - const mockAppItems = [ - { id: '1', name: 'App 1' }, - { id: '2', name: 'App 2' }, - ] as unknown as AppDetailResponse[] - const mockCurApp = mockAppItems[0]! - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(useRouter).mockReturnValue({ - push: mockPush, - } as unknown as ReturnType<typeof useRouter>) - vi.mocked(useAppContext).mockReturnValue({ - isCurrentWorkspaceEditor: true, - } as unknown as ReturnType<typeof useAppContext>) - }) - - describe('Rendering', () => { - it('should render current app name', () => { - render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />) - expect(screen.getByText('App 1'))!.toBeInTheDocument() - }) - }) - - describe('Interactions', () => { - it('should open menu and show app items', async () => { - render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />) - - const button = screen.getByRole('button', { name: /App 1/i }) - await act(async () => { - fireEvent.click(button) - }) - - expect(screen.getByText('App 2'))!.toBeInTheDocument() - }) - - it('should navigate to configuration when an app is clicked and user is editor', async () => { - render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />) - - const button = screen.getByRole('button', { name: /App 1/i }) - await act(async () => { - fireEvent.click(button) - }) - - const app2Item = screen.getByText('App 2') - await act(async () => { - fireEvent.click(app2Item) - }) - - expect(mockPush).toHaveBeenCalledWith('/app/2/configuration') - }) - - it('should navigate to overview when an app is clicked and user is not editor', async () => { - vi.mocked(useAppContext).mockReturnValue({ - isCurrentWorkspaceEditor: false, - } as unknown as ReturnType<typeof useAppContext>) - - render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />) - - const button = screen.getByRole('button', { name: /App 1/i }) - await act(async () => { - fireEvent.click(button) - }) - - const app2Item = screen.getByText('App 2') - await act(async () => { - fireEvent.click(app2Item) - }) - - expect(mockPush).toHaveBeenCalledWith('/app/2/overview') - }) - }) - - describe('New App Dialog', () => { - it('should show "New App" button for editor and open dialog', async () => { - render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />) - - const button = screen.getByRole('button', { name: /App 1/i }) - await act(async () => { - fireEvent.click(button) - }) - - const newAppBtn = screen.getByText('common.menus.newApp') - await act(async () => { - fireEvent.click(newAppBtn) - }) - - expect(screen.getByTestId('create-app-dialog'))!.toBeInTheDocument() - }) - - it('should not show "New App" button for non-editor', async () => { - vi.mocked(useAppContext).mockReturnValue({ - isCurrentWorkspaceEditor: false, - } as unknown as ReturnType<typeof useAppContext>) - - render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />) - - const button = screen.getByRole('button', { name: /App 1/i }) - await act(async () => { - fireEvent.click(button) - }) - - expect(screen.queryByText('common.menus.newApp')).not.toBeInTheDocument() - }) - - it('should close dialog when onClose is called', async () => { - render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />) - - const button = screen.getByRole('button', { name: /App 1/i }) - await act(async () => { - fireEvent.click(button) - }) - - const newAppBtn = screen.getByText('common.menus.newApp') - await act(async () => { - fireEvent.click(newAppBtn) - }) - - const closeBtn = screen.getByText('Close') - await act(async () => { - fireEvent.click(closeBtn) - }) - - expect(screen.queryByTestId('create-app-dialog')).not.toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should render nothing in menu if appItems is empty', async () => { - render(<AppSelector appItems={[]} curApp={mockCurApp} />) - - const button = screen.getByRole('button', { name: /App 1/i }) - await act(async () => { - fireEvent.click(button) - }) - - expect(screen.queryByText('App 2')).not.toBeInTheDocument() - // "New App" should still be there if editor - // "New App" should still be there if editor - expect(screen.getByText('common.menus.newApp'))!.toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/header/app-selector/index.tsx b/web/app/components/header/app-selector/index.tsx deleted file mode 100644 index 52e60de2b4..0000000000 --- a/web/app/components/header/app-selector/index.tsx +++ /dev/null @@ -1,117 +0,0 @@ -'use client' -import type { AppDetailResponse } from '@/models/app' -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' -import { ChevronDownIcon, PlusIcon } from '@heroicons/react/24/solid' -import { noop } from 'es-toolkit/function' -import { Fragment, useState } from 'react' -import { useTranslation } from 'react-i18next' -import CreateAppDialog from '@/app/components/app/create-app-dialog' -import AppIcon from '@/app/components/base/app-icon' -import { useAppContext } from '@/context/app-context' -import { useRouter } from '@/next/navigation' -import Indicator from '../indicator' - -type IAppSelectorProps = { - appItems: AppDetailResponse[] - curApp: AppDetailResponse -} - -export default function AppSelector({ appItems, curApp }: IAppSelectorProps) { - const router = useRouter() - const { isCurrentWorkspaceEditor } = useAppContext() - const [showNewAppDialog, setShowNewAppDialog] = useState(false) - const { t } = useTranslation() - - const itemClassName = ` - flex items-center w-full h-10 px-3 text-gray-700 text-[14px] - rounded-lg font-normal hover:bg-gray-100 cursor-pointer - ` - - return ( - <div className=""> - <Menu as="div" className="relative inline-block text-left"> - <div> - <MenuButton - className=" - inline-flex h-7 w-full items-center justify-center - rounded-[10px] pr-2.5 pl-2 text-[14px] font-semibold - text-[#1C64F2] hover:bg-[#EBF5FF] - " - > - {curApp?.name} - <ChevronDownIcon - className="ml-1 h-3 w-3" - aria-hidden="true" - /> - </MenuButton> - </div> - <Transition - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" - > - <MenuItems - className=" - absolute right-0 -left-11 mt-1.5 w-60 max-w-80 - origin-top-right divide-y divide-gray-100 rounded-lg bg-white - shadow-lg - " - > - {!!appItems.length && ( - <div className="overflow-auto px-1 py-1" style={{ maxHeight: '50vh' }}> - { - appItems.map((app: AppDetailResponse) => ( - <MenuItem key={app.id}> - <div - className={itemClassName} - onClick={() => - router.push(`/app/${app.id}/${isCurrentWorkspaceEditor ? 'configuration' : 'overview'}`)} - > - <div className="relative mr-2 h-6 w-6 rounded-md bg-[#D5F5F6]"> - <AppIcon size="tiny" /> - <div className="absolute -right-0.5 -bottom-0.5 flex h-2.5 w-2.5 items-center justify-center rounded-sm bg-white"> - <Indicator /> - </div> - </div> - {app.name} - </div> - </MenuItem> - )) - } - </div> - )} - {isCurrentWorkspaceEditor && ( - <MenuItem> - <div className="p-1" onClick={() => setShowNewAppDialog(true)}> - <div - className="flex h-12 cursor-pointer items-center rounded-lg hover:bg-gray-100" - > - <div - className=" - mr-2 ml-4 flex - h-6 w-6 items-center justify-center rounded-md border-[0.5px] - border-dashed border-gray-200 bg-gray-100 - " - > - <PlusIcon className="h-4 w-4 text-gray-500" /> - </div> - <div className="text-[14px] font-normal text-gray-700">{t('menus.newApp', { ns: 'common' })}</div> - </div> - </div> - </MenuItem> - )} - </MenuItems> - </Transition> - </Menu> - <CreateAppDialog - show={showNewAppDialog} - onClose={() => setShowNewAppDialog(false)} - onSuccess={noop} - /> - </div> - ) -} diff --git a/web/app/components/header/nav/__tests__/index.spec.tsx b/web/app/components/header/nav/__tests__/index.spec.tsx index f4a1399638..6f9b448981 100644 --- a/web/app/components/header/nav/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/__tests__/index.spec.tsx @@ -7,6 +7,7 @@ import { screen, waitFor, } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { use } from 'react' import { vi } from 'vitest' @@ -291,45 +292,24 @@ describe('Nav Component', () => { }) it('should show sub-menu and call onCreate with types when isApp is true', async () => { - render(<Nav {...defaultProps} curNav={curNav} isApp />) - const selectorButton = screen.getByRole('button', { name: /Item 1/i }) - - await act(async () => { - fireEvent.click(selectorButton) - }) - - const openCreateMenu = async () => { - const createButton = await screen.findByText('Create New') - await act(async () => { - fireEvent.click(createButton) - }) - return screen.findByText(/app\.newApp\.startFromBlank/i) + const user = userEvent.setup() + const clickCreateBranch = async (optionName: RegExp) => { + const { unmount } = render(<Nav {...defaultProps} curNav={curNav} isApp />) + await user.click(screen.getByRole('button', { name: /Item 1/i })) + const createButton = await screen.findByRole('menuitem', { name: /Create New/i }) + await user.hover(createButton) + fireEvent.click(await screen.findByRole('menuitem', { name: optionName })) + unmount() } - await openCreateMenu() - const blankOption = await screen.findByText( - /app\.newApp\.startFromBlank/i, - ) - await act(async () => { - fireEvent.click(blankOption) - }) - expect(mockOnCreate).toHaveBeenCalledWith('blank') + await clickCreateBranch(/app\.newApp\.startFromBlank/i) + await clickCreateBranch(/app\.newApp\.startFromTemplate/i) + await clickCreateBranch(/app\.importDSL/i) - await openCreateMenu() - const templateOption = await screen.findByText( - /app\.newApp\.startFromTemplate/i, - ) - await act(async () => { - fireEvent.click(templateOption) - }) - expect(mockOnCreate).toHaveBeenCalledWith('template') - - await openCreateMenu() - const dslOption = await screen.findByText(/app\.importDSL/i) - await act(async () => { - fireEvent.click(dslOption) - }) - expect(mockOnCreate).toHaveBeenCalledWith('dsl') + expect(mockOnCreate).toHaveBeenNthCalledWith(1, 'blank') + expect(mockOnCreate).toHaveBeenNthCalledWith(2, 'template') + expect(mockOnCreate).toHaveBeenNthCalledWith(3, 'dsl') + expect(mockOnCreate).toHaveBeenCalledTimes(3) }) it('should not show create button if NOT an editor', async () => { diff --git a/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx b/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx index b1de3ab5e3..32de0691dd 100644 --- a/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx @@ -1,6 +1,7 @@ import type { INavSelectorProps, NavItem } from '../index' import type { AppContextValue } from '@/context/app-context' import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { vi } from 'vitest' import { useStore as useAppStore } from '@/app/components/app/store' @@ -198,6 +199,7 @@ describe('NavSelector Component', () => { }) it('should show extended create menu in app mode', async () => { + const user = userEvent.setup() render(<NavSelector {...defaultProps} isApp />) const button = screen.getByRole('button') await act(async () => { @@ -205,10 +207,10 @@ describe('NavSelector Component', () => { }) const openCreateMenu = async () => { - const createBtn = screen.getByText('Create New') - await act(async () => { - fireEvent.click(createBtn) - }) + if (!screen.queryByRole('menuitem', { name: /Create New/i })) + await user.click(screen.getByRole('button', { name: /Item 1/i })) + const createBtn = await screen.findByRole('menuitem', { name: /Create New/i }) + await user.hover(createBtn) return screen.findByText(/app\.newApp\.startFromBlank/i) } @@ -235,16 +237,15 @@ describe('NavSelector Component', () => { }) it('should open extended create menu on hover in app mode', async () => { + const user = userEvent.setup() render(<NavSelector {...defaultProps} isApp />) const button = screen.getByRole('button') await act(async () => { fireEvent.click(button) }) - const createBtn = screen.getByText('Create New') - await act(async () => { - fireEvent.mouseEnter(createBtn) - }) + const createBtn = await screen.findByRole('menuitem', { name: /Create New/i }) + await user.hover(createBtn) expect(await screen.findByText(/app\.newApp\.startFromBlank/i))!.toBeInTheDocument() }) diff --git a/web/app/components/header/nav/nav-selector/index.tsx b/web/app/components/header/nav/nav-selector/index.tsx index 9b42d30308..5a8c1ae9e3 100644 --- a/web/app/components/header/nav/nav-selector/index.tsx +++ b/web/app/components/header/nav/nav-selector/index.tsx @@ -1,14 +1,21 @@ 'use client' import type { AppIconType, AppModeEnum } from '@/types/app' -import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react' import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { RiAddLine, RiArrowDownSLine, - RiArrowRightSLine, } from '@remixicon/react' import { debounce } from 'es-toolkit/compat' -import { useCallback, useState } from 'react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { AppTypeIcon } from '@/app/components/app/type-selector' @@ -53,57 +60,54 @@ const AppCreateMenu = ({ importDSLText, onCreate, }: AppCreateMenuProps) => { - const [open, setOpen] = useState(false) - const handleCreate = (state: string) => { - setOpen(false) onCreate(state) } return ( - <div className="relative h-full w-full" onMouseLeave={() => setOpen(false)}> - <button - type="button" - className="w-full p-1 text-left" - onClick={() => setOpen(value => !value)} - onMouseEnter={() => setOpen(true)} - > - <div className={cn( - 'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover', - open && 'bg-state-base-hover!', - )} + <DropdownMenuSub> + <div className="p-1"> + <DropdownMenuSubTrigger + className="h-9 gap-2 px-3 py-[6px]" > <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-background-default"> - <RiAddLine className="h-4 w-4 text-text-primary" /> + <span className="i-ri-add-line h-4 w-4 text-text-primary" /> </div> - <div className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</div> - <RiArrowRightSLine className="h-3.5 w-3.5 shrink-0 text-text-primary" /> + <span className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</span> + </DropdownMenuSubTrigger> + </div> + <DropdownMenuSubContent + placement="right-start" + sideOffset={4} + popupClassName="min-w-[200px] bg-components-panel-bg-blur p-0" + > + <div className="p-1"> + <DropdownMenuItem + className="h-9 px-3 py-[6px] font-normal text-text-secondary" + onClick={() => handleCreate('blank')} + > + <FilePlus01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" /> + {startFromBlankText} + </DropdownMenuItem> + <DropdownMenuItem + className="h-9 px-3 py-[6px] font-normal text-text-secondary" + onClick={() => handleCreate('template')} + > + <FilePlus02 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" /> + {startFromTemplateText} + </DropdownMenuItem> </div> - </button> - {open && ( - <div - className="absolute top-[3px] right-[-198px] z-10 min-w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg" - onMouseEnter={() => setOpen(true)} - > - <div className="p-1"> - <button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('blank')}> - <FilePlus01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" /> - {startFromBlankText} - </button> - <button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('template')}> - <FilePlus02 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" /> - {startFromTemplateText} - </button> - </div> - <div className="border-t border-divider-regular p-1"> - <button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('dsl')}> - <FileArrow01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" /> - {importDSLText} - </button> - </div> + <div className="border-t border-divider-regular p-1"> + <DropdownMenuItem + className="h-9 px-3 py-[6px] font-normal text-text-secondary" + onClick={() => handleCreate('dsl')} + > + <FileArrow01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" /> + {importDSLText} + </DropdownMenuItem> </div> - )} - </div> + </DropdownMenuSubContent> + </DropdownMenuSub> ) } @@ -123,94 +127,86 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL }, 50), []) return ( - <Menu as="div" className="relative"> - {({ open }) => ( - <> - <MenuButton className={cn( - 'hover:hover:bg-components-main-nav-nav-button-bg-active-hover group inline-flex h-7 w-full items-center justify-center rounded-[10px] pr-2.5 pl-2 text-[14px] font-semibold text-components-main-nav-nav-button-text-active', - open && 'bg-components-main-nav-nav-button-bg-active', - )} - > - <div className="max-w-[157px] truncate" title={curNav?.name}>{curNav?.name}</div> - <RiArrowDownSLine - className={cn('ml-1 h-3 w-3 shrink-0 opacity-50 group-hover:opacity-100', open && 'opacity-100!')} - aria-hidden="true" - /> - </MenuButton> - <MenuItems - className=" - absolute right-0 -left-11 mt-1.5 w-60 max-w-80 - origin-top-right divide-y divide-divider-regular rounded-lg bg-components-panel-bg-blur - shadow-lg outline-hidden - " - > - <div className="overflow-auto px-1 py-1" style={{ maxHeight: '50vh' }} onScroll={handleScroll}> - { - navigationItems.map(nav => ( - <MenuItem key={nav.id}> - <div - className="flex w-full cursor-pointer items-center truncate rounded-lg px-3 py-[6px] text-[14px] font-normal text-text-secondary hover:bg-state-base-hover" - onClick={() => { - if (curNav?.id === nav.id) - return - setAppDetail() - router.push(nav.link) - }} - title={nav.name} - > - <div className="relative mr-2 h-6 w-6 rounded-md"> - <AppIcon - size="tiny" - iconType={nav.icon_type} - icon={nav.icon} - background={nav.icon_background} - imageUrl={nav.icon_url} - /> - {!!nav.mode && ( - <AppTypeIcon type={nav.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 shadow-sm" className="h-2.5 w-2.5" /> - )} - </div> - <div className="truncate"> - {nav.name} - </div> - </div> - </MenuItem> - )) - } - {isLoadingMore && ( - <div className="flex justify-center py-2"> - <Loading /> - </div> - )} - </div> - {!isApp && isCurrentWorkspaceEditor && ( - <MenuItem as="div" className="w-full p-1"> - <div - onClick={() => onCreate('')} - className={cn( - 'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover', + <DropdownMenu modal={false}> + <DropdownMenuTrigger + className={cn( + 'hover:hover:bg-components-main-nav-nav-button-bg-active-hover group inline-flex h-7 items-center justify-center rounded-[10px] pr-2.5 pl-2 text-[14px] font-semibold text-components-main-nav-nav-button-text-active outline-hidden', + 'focus-visible:bg-components-main-nav-nav-button-bg-active focus-visible:ring-1 focus-visible:ring-components-input-border-hover data-popup-open:bg-components-main-nav-nav-button-bg-active', + )} + > + <div className="max-w-[157px] truncate" title={curNav?.name}>{curNav?.name}</div> + <RiArrowDownSLine + className="ml-1 h-3 w-3 shrink-0 opacity-50 group-hover:opacity-100 group-data-popup-open:opacity-100" + aria-hidden="true" + /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={6} + popupClassName="w-60 max-w-80 divide-y divide-divider-regular bg-components-panel-bg-blur p-0" + > + <div className="max-h-[50vh] overflow-auto px-1 py-1" onScroll={handleScroll}> + { + navigationItems.map(nav => ( + <DropdownMenuItem + key={nav.id} + className="h-auto truncate px-3 py-[6px] text-[14px] font-normal text-text-secondary" + onClick={() => { + if (curNav?.id === nav.id) + return + setAppDetail() + router.push(nav.link) + }} + title={nav.name} + > + <div className="relative mr-2 h-6 w-6 shrink-0 rounded-md"> + <AppIcon + size="tiny" + iconType={nav.icon_type} + icon={nav.icon} + background={nav.icon_background} + imageUrl={nav.icon_url} + /> + {!!nav.mode && ( + <AppTypeIcon type={nav.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 shadow-sm" className="h-2.5 w-2.5" /> )} - > - <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-background-default"> - <RiAddLine className="h-4 w-4 text-text-primary" /> - </div> - <div className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</div> </div> - </MenuItem> - )} - {isApp && isCurrentWorkspaceEditor && ( - <AppCreateMenu - createText={createText} - startFromBlankText={t('newApp.startFromBlank', { ns: 'app' })} - startFromTemplateText={t('newApp.startFromTemplate', { ns: 'app' })} - importDSLText={t('importDSL', { ns: 'app' })} - onCreate={onCreate} - /> - )} - </MenuItems> - </> - )} - </Menu> + <div className="min-w-0 truncate"> + {nav.name} + </div> + </DropdownMenuItem> + )) + } + {isLoadingMore && ( + <div className="flex justify-center py-2"> + <Loading /> + </div> + )} + </div> + {!isApp && isCurrentWorkspaceEditor && ( + <div className="p-1"> + <DropdownMenuItem + className="h-9 gap-2 px-3 py-[6px]" + onClick={() => onCreate('')} + > + <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-background-default"> + <RiAddLine className="h-4 w-4 text-text-primary" /> + </div> + <div className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</div> + </DropdownMenuItem> + </div> + )} + {isApp && isCurrentWorkspaceEditor && ( + <AppCreateMenu + createText={createText} + startFromBlankText={t('newApp.startFromBlank', { ns: 'app' })} + startFromTemplateText={t('newApp.startFromTemplate', { ns: 'app' })} + importDSLText={t('importDSL', { ns: 'app' })} + onCreate={onCreate} + /> + )} + </DropdownMenuContent> + </DropdownMenu> ) } diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx index 38fa62a728..9ca932e54c 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -2,6 +2,7 @@ import type { ComponentProps } from 'react' import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { AppSelectorValue } from '@/app/components/plugins/plugin-detail-panel/app-selector' import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' @@ -207,7 +208,8 @@ describe('FormInputItem branches', () => { }) }) - it('should render static multi-select values and update selected labels', () => { + it('should render static multi-select values and update selected labels', async () => { + const user = userEvent.setup() const { onChange } = renderFormInputItem({ schema: createSchema({ multiple: true, @@ -226,8 +228,8 @@ describe('FormInputItem branches', () => { }) expect(screen.getByText('alpha')).toBeInTheDocument() - fireEvent.click(screen.getByText('alpha').closest('button') as HTMLButtonElement) - fireEvent.click(screen.getByText('beta')) + await user.click(screen.getByRole('combobox', { name: 'alpha' })) + await user.click(await screen.findByRole('option', { name: 'beta' })) expect(onChange).toHaveBeenCalledWith({ field: { diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx index 9a98e60483..34382f2be0 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { JsonEditorField, @@ -18,7 +19,7 @@ describe('form-input-item sections', () => { />, ) - expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Options' })).toHaveTextContent('Loading') }) it('should render the shared json editor section', () => { @@ -33,7 +34,8 @@ describe('form-input-item sections', () => { expect(screen.getByText('JSON')).toBeInTheDocument() }) - it('should render placeholder, icons, and select multi-select options', () => { + it('should render placeholder, icons, and select multi-select options', async () => { + const user = userEvent.setup() const onChange = vi.fn() renderWorkflowComponent( @@ -51,8 +53,8 @@ describe('form-input-item sections', () => { ) expect(screen.getByText('Choose options')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button')) - fireEvent.click(screen.getByText('Alpha')) + await user.click(screen.getByRole('combobox', { name: 'Choose options' })) + await user.click(await screen.findByRole('option', { name: 'Alpha' })) expect(document.querySelector('img[src="/alpha.svg"]')).toBeInTheDocument() expect(onChange).toHaveBeenCalled() diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx index 84cfb4629d..896250508c 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx @@ -2,10 +2,16 @@ import type { FC, ReactElement } from 'react' import type { SelectItem } from './form-input-item.helpers' -import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react' -import { ChevronDownIcon } from '@heroicons/react/20/solid' import { cn } from '@langgenius/dify-ui/cn' -import { RiCheckLine, RiLoader4Line } from '@remixicon/react' +import { + SelectItem as DifySelectItem, + Select, + SelectContent, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { RiLoader4Line } from '@remixicon/react' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' @@ -20,20 +26,7 @@ type MultiSelectFieldProps = { } const LoadingIndicator = () => ( - <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" /> -) - -const ToggleIndicator = () => ( - <ChevronDownIcon - className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary" - aria-hidden="true" - /> -) - -const SelectedMark = () => ( - <span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent"> - <RiCheckLine className="h-4 w-4" aria-hidden="true" /> - </span> + <RiLoader4Line className="mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-secondary motion-reduce:animate-none" aria-hidden="true" /> ) export const MultiSelectField: FC<MultiSelectFieldProps> = ({ @@ -56,48 +49,44 @@ export const MultiSelectField: FC<MultiSelectFieldProps> = ({ const renderLabel = () => { if (isLoading) - return 'Loading...' + return 'Loading…' return selectedLabel || placeholder || 'Select options' } return ( - <Listbox multiple value={value} onChange={onChange} disabled={disabled}> - <div className="group/simple-select relative h-8 grow"> - <ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pr-10 pl-3 group-hover/simple-select:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt focus-visible:outline-hidden sm:text-sm sm:leading-6"> - <span className={textClassName}> + <Select multiple value={value} onValueChange={onChange} disabled={disabled || isLoading}> + <div className="grow"> + <SelectTrigger aria-label={placeholder || selectedLabel || 'Options'}> + <span className={cn('flex min-w-0 items-center', textClassName)}> + {isLoading && <LoadingIndicator />} {renderLabel()} </span> - <span className="absolute inset-y-0 right-0 flex items-center pr-2"> - {isLoading ? <LoadingIndicator /> : <ToggleIndicator />} - </span> - </ListboxButton> - <ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-xs focus:outline-hidden sm:text-sm"> + </SelectTrigger> + <SelectContent + popupClassName="w-(--anchor-width) bg-components-panel-bg-blur backdrop-blur-xs" + listClassName="max-h-60" + > {items.map(item => ( - <ListboxOption + <DifySelectItem key={item.value} value={item.value} - className={({ focus }) => - cn('relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover', focus && 'bg-state-base-hover')} + className="h-auto py-2 pr-9 pl-3" > - {({ selected }) => ( - <> - <div className="flex items-center"> - {item.icon && ( - <img src={item.icon} alt="" className="mr-2 h-4 w-4" /> - )} - <span className={cn('block truncate', selected && 'font-normal')}> - {item.name} - </span> - </div> - {selected && <SelectedMark />} - </> - )} - </ListboxOption> + <div className="flex min-w-0 items-center"> + {item.icon && ( + <img src={item.icon} alt="" width={16} height={16} className="mr-2 h-4 w-4 shrink-0" /> + )} + <SelectItemText> + {item.name} + </SelectItemText> + </div> + <SelectItemIndicator /> + </DifySelectItem> ))} - </ListboxOptions> + </SelectContent> </div> - </Listbox> + </Select> ) } From f3eb3ab4dd770461ff639bd1f598d27ac50bd4bc Mon Sep 17 00:00:00 2001 From: Joel <iamjoel007@gmail.com> Date: Sat, 9 May 2026 15:01:35 +0800 Subject: [PATCH 06/53] fix: mismatched button label in prefilled WebApp launch description (#35964) --- web/i18n/ja-JP/app-overview.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/i18n/ja-JP/app-overview.json b/web/i18n/ja-JP/app-overview.json index 745f02273c..e7c59e926a 100644 --- a/web/i18n/ja-JP/app-overview.json +++ b/web/i18n/ja-JP/app-overview.json @@ -106,7 +106,7 @@ "overview.appInfo.settings.workflow.subTitle": "ワークフローの詳細", "overview.appInfo.settings.workflow.title": "ワークフローステップ", "overview.appInfo.title": "Web App", - "overview.appInfo.workflowLaunchHiddenInputs.description": "非表示フィールドに値を入力後、<bold>起動</bold>をクリックすると、事前入力された値が適用された WebApp が開きます。", + "overview.appInfo.workflowLaunchHiddenInputs.description": "非表示フィールドに値を入力後、<bold>公開</bold>をクリックすると、事前入力された値が適用された WebApp が開きます。", "overview.appInfo.workflowLaunchHiddenInputs.title": "非表示フィールドを事前入力", "overview.disableTooltip.triggerMode": "トリガーノードモードでは{{feature}}機能を使用できません。", "overview.status.disable": "無効", From 19476109da30ddea47c835d4f1bd2bb7c0a8796e Mon Sep 17 00:00:00 2001 From: -LAN- <laipz8200@outlook.com> Date: Sat, 9 May 2026 15:30:03 +0800 Subject: [PATCH 07/53] chore(api): upgrade graphon to v0.3.0 (#35469) Signed-off-by: -LAN- <laipz8200@outlook.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: WH-2099 <wh2099@pm.me> --- api/core/app/llm/__init__.py | 14 +- api/core/app/llm/quota.py | 181 +++-- api/core/app/workflow/layers/llm_quota.py | 146 +++-- api/core/entities/provider_configuration.py | 28 +- api/core/helper/moderation.py | 8 +- api/core/plugin/impl/model_runtime.py | 184 +++++- api/core/plugin/impl/model_runtime_factory.py | 48 +- api/core/provider_manager.py | 6 +- api/core/workflow/node_factory.py | 8 +- api/core/workflow/nodes/agent/agent_node.py | 4 +- .../nodes/datasource/datasource_node.py | 4 +- .../knowledge_index/knowledge_index_node.py | 4 +- .../knowledge_retrieval_node.py | 4 +- api/core/workflow/system_variables.py | 24 +- api/core/workflow/workflow_entry.py | 11 +- .../update_provider_when_message_created.py | 28 +- api/pyproject.toml | 2 +- api/services/credit_pool_service.py | 86 ++- .../workflow_draft_variable_service.py | 4 +- api/services/workflow_service.py | 2 +- .../test_datasource_node_integration.py | 2 +- .../workflow/nodes/__mock/model.py | 7 +- .../workflow/nodes/test_code.py | 4 +- .../workflow/nodes/test_http.py | 10 +- .../workflow/nodes/test_llm.py | 4 +- .../nodes/test_parameter_extractor.py | 4 +- .../workflow/nodes/test_template_transform.py | 4 +- .../workflow/nodes/test_tool.py | 4 +- .../layers/test_pause_state_persist_layer.py | 4 +- .../test_human_input_resume_node_execution.py | 8 +- .../services/test_credit_pool_service.py | 24 +- .../test_generate_task_pipeline_core.py | 16 +- .../core/app/apps/test_pause_resume.py | 6 +- .../app/apps/test_workflow_app_runner_core.py | 12 +- .../test_workflow_app_runner_single_node.py | 2 +- .../test_generate_task_pipeline_core.py | 20 +- .../app/layers/test_trigger_post_layer.py | 12 +- .../unit_tests/core/app/test_llm_quota.py | 617 ++++++++++++++++++ .../core/app/workflow/test_node_factory.py | 4 +- .../app/workflow/test_persistence_layer.py | 5 +- .../test_entities_provider_configuration.py | 80 ++- .../unit_tests/core/helper/test_moderation.py | 14 +- .../test_model_provider_factory.py | 83 +-- .../plugin/impl/test_model_runtime_factory.py | 2 +- .../core/plugin/test_model_runtime_adapter.py | 191 +++++- .../unit_tests/core/test_provider_manager.py | 6 +- .../graph_engine/layers/test_llm_quota.py | 332 +++++++--- .../graph_engine/test_mock_factory.py | 8 +- .../workflow/graph_engine/test_mock_nodes.py | 4 +- .../test_parallel_human_input_join_resume.py | 10 +- .../core/workflow/nodes/answer/test_answer.py | 137 +--- .../nodes/datasource/test_datasource_node.py | 2 +- .../test_http_request_executor.py | 30 +- .../http_request/test_http_request_node.py | 7 +- .../nodes/human_input/test_entities.py | 18 +- .../test_human_input_form_filled_event.py | 14 +- .../test_iteration_child_engine_errors.py | 4 +- .../test_knowledge_index_node.py | 4 +- .../test_knowledge_retrieval_node.py | 28 +- .../workflow/nodes/list_operator/node_spec.py | 38 +- .../core/workflow/nodes/llm/test_node.py | 13 +- .../template_transform_node_spec.py | 2 +- .../test_template_transform_node.py | 2 +- .../core/workflow/nodes/test_base_node.py | 16 +- .../nodes/test_document_extractor_node.py | 72 +- .../core/workflow/nodes/test_if_else.py | 15 +- .../core/workflow/nodes/test_list_operator.py | 2 +- .../nodes/test_start_node_json_object.py | 4 +- .../workflow/nodes/tool/test_tool_node.py | 4 +- .../trigger_plugin/test_trigger_event_node.py | 2 +- .../webhook/test_webhook_file_conversion.py | 2 +- .../nodes/webhook/test_webhook_node.py | 2 +- .../core/workflow/test_node_factory.py | 85 ++- .../core/workflow/test_variable_pool.py | 4 +- .../core/workflow/test_workflow_entry.py | 22 +- .../workflow/test_workflow_entry_helpers.py | 77 ++- ...st_update_provider_when_message_created.py | 130 ++++ .../services/test_credit_pool_service.py | 158 +++++ .../services/test_workflow_service.py | 2 +- api/uv.lock | 8 +- 80 files changed, 2526 insertions(+), 673 deletions(-) create mode 100644 api/tests/unit_tests/core/app/test_llm_quota.py create mode 100644 api/tests/unit_tests/events/test_update_provider_when_message_created.py create mode 100644 api/tests/unit_tests/services/test_credit_pool_service.py diff --git a/api/core/app/llm/__init__.py b/api/core/app/llm/__init__.py index f069bede74..d20a5b2344 100644 --- a/api/core/app/llm/__init__.py +++ b/api/core/app/llm/__init__.py @@ -1,5 +1,15 @@ """LLM-related application services.""" -from .quota import deduct_llm_quota, ensure_llm_quota_available +from .quota import ( + deduct_llm_quota, + deduct_llm_quota_for_model, + ensure_llm_quota_available, + ensure_llm_quota_available_for_model, +) -__all__ = ["deduct_llm_quota", "ensure_llm_quota_available"] +__all__ = [ + "deduct_llm_quota", + "deduct_llm_quota_for_model", + "ensure_llm_quota_available", + "ensure_llm_quota_available_for_model", +] diff --git a/api/core/app/llm/quota.py b/api/core/app/llm/quota.py index b6039e1e4e..5bf3334a7b 100644 --- a/api/core/app/llm/quota.py +++ b/api/core/app/llm/quota.py @@ -1,4 +1,14 @@ -from sqlalchemy import update +"""Tenant-scoped helpers for checking and deducting LLM provider quota. + +System-hosted quota accounting is currently defined only for LLM models. Keep +the public helpers LLM-specific so callers do not carry unused model-type +plumbing, and fail loudly if the deprecated ``ModelInstance`` wrappers are used +with a non-LLM model. +""" + +import warnings + +from sqlalchemy import select from sqlalchemy.orm import sessionmaker from configs import dify_config @@ -6,44 +16,47 @@ from core.entities.model_entities import ModelStatus from core.entities.provider_entities import ProviderQuotaType, QuotaUnit from core.errors.error import QuotaExceededError from core.model_manager import ModelInstance +from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager from extensions.ext_database import db from graphon.model_runtime.entities.llm_entities import LLMUsage +from graphon.model_runtime.entities.model_entities import ModelType from libs.datetime_utils import naive_utc_now from models.provider import Provider, ProviderType from models.provider_ids import ModelProviderID -def ensure_llm_quota_available(*, model_instance: ModelInstance) -> None: - provider_model_bundle = model_instance.provider_model_bundle - provider_configuration = provider_model_bundle.configuration +def _get_provider_configuration(*, tenant_id: str, provider: str): + """Resolve the tenant-bound provider configuration for quota decisions.""" + provider_manager = create_plugin_provider_manager(tenant_id=tenant_id) + provider_configuration = provider_manager.get_configurations(tenant_id).get(provider) + if provider_configuration is None: + raise ValueError(f"Provider {provider} does not exist.") + return provider_configuration + +def ensure_llm_quota_available_for_model(*, tenant_id: str, provider: str, model: str) -> None: + """Raise when a tenant-bound LLM model is already out of quota.""" + provider_configuration = _get_provider_configuration(tenant_id=tenant_id, provider=provider) if provider_configuration.using_provider_type != ProviderType.SYSTEM: return provider_model = provider_configuration.get_provider_model( - model_type=model_instance.model_type_instance.model_type, - model=model_instance.model_name, + model_type=ModelType.LLM, + model=model, ) if provider_model and provider_model.status == ModelStatus.QUOTA_EXCEEDED: - raise QuotaExceededError(f"Model provider {model_instance.provider} quota exceeded.") + raise QuotaExceededError(f"Model provider {provider} quota exceeded.") -def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LLMUsage) -> None: - provider_model_bundle = model_instance.provider_model_bundle - provider_configuration = provider_model_bundle.configuration - - if provider_configuration.using_provider_type != ProviderType.SYSTEM: - return - - system_configuration = provider_configuration.system_configuration - +def _resolve_llm_used_quota(*, system_configuration, model: str, usage: LLMUsage) -> int | None: + """Compute the quota impact for an LLM invocation under the current quota mode.""" quota_unit = None for quota_configuration in system_configuration.quota_configurations: if quota_configuration.quota_type == system_configuration.current_quota_type: quota_unit = quota_configuration.quota_unit if quota_configuration.quota_limit == -1: - return + return None break @@ -52,42 +65,136 @@ def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LL if quota_unit == QuotaUnit.TOKENS: used_quota = usage.total_tokens elif quota_unit == QuotaUnit.CREDITS: - used_quota = dify_config.get_model_credits(model_instance.model_name) + used_quota = dify_config.get_model_credits(model) else: used_quota = 1 + return used_quota + + +def _deduct_free_llm_quota( + *, + tenant_id: str, + provider: str, + quota_type: ProviderQuotaType, + used_quota: int, +) -> None: + """Deduct FREE provider quota, capping at the limit before reporting exhaustion.""" + quota_exceeded = False + with sessionmaker(bind=db.engine).begin() as session: + provider_record = session.scalar( + select(Provider) + .where( + Provider.tenant_id == tenant_id, + # TODO: Use provider name with prefix after the data migration. + Provider.provider_name == ModelProviderID(provider).provider_name, + Provider.provider_type == ProviderType.SYSTEM.value, + Provider.quota_type == quota_type, + ) + .with_for_update() + ) + if ( + provider_record is None + or provider_record.quota_limit is None + or provider_record.quota_used is None + or provider_record.quota_limit <= provider_record.quota_used + ): + quota_exceeded = True + else: + available_quota = provider_record.quota_limit - provider_record.quota_used + deducted_quota = min(used_quota, available_quota) + provider_record.quota_used += deducted_quota + provider_record.last_used = naive_utc_now() + quota_exceeded = deducted_quota < used_quota + + if quota_exceeded: + raise QuotaExceededError(f"Model provider {provider} quota exceeded.") + + +def _deduct_used_llm_quota(*, tenant_id: str, provider: str, provider_configuration, used_quota: int | None) -> None: + """Apply a resolved LLM quota charge against the current provider quota bucket.""" + if provider_configuration.using_provider_type != ProviderType.SYSTEM: + return + + system_configuration = provider_configuration.system_configuration if used_quota is not None and system_configuration.current_quota_type is not None: match system_configuration.current_quota_type: case ProviderQuotaType.TRIAL: from services.credit_pool_service import CreditPoolService - CreditPoolService.check_and_deduct_credits( + CreditPoolService.deduct_credits_capped( tenant_id=tenant_id, credits_required=used_quota, ) case ProviderQuotaType.PAID: from services.credit_pool_service import CreditPoolService - CreditPoolService.check_and_deduct_credits( + CreditPoolService.deduct_credits_capped( tenant_id=tenant_id, credits_required=used_quota, pool_type="paid", ) case ProviderQuotaType.FREE: - with sessionmaker(bind=db.engine).begin() as session: - stmt = ( - update(Provider) - .where( - Provider.tenant_id == tenant_id, - # TODO: Use provider name with prefix after the data migration. - Provider.provider_name == ModelProviderID(model_instance.provider).provider_name, - Provider.provider_type == ProviderType.SYSTEM.value, - Provider.quota_type == system_configuration.current_quota_type, - Provider.quota_limit > Provider.quota_used, - ) - .values( - quota_used=Provider.quota_used + used_quota, - last_used=naive_utc_now(), - ) - ) - session.execute(stmt) + _deduct_free_llm_quota( + tenant_id=tenant_id, + provider=provider, + quota_type=system_configuration.current_quota_type, + used_quota=used_quota, + ) + case _: + return + + +def deduct_llm_quota_for_model(*, tenant_id: str, provider: str, model: str, usage: LLMUsage) -> None: + """Deduct tenant-bound quota for the resolved LLM model identity.""" + provider_configuration = _get_provider_configuration(tenant_id=tenant_id, provider=provider) + used_quota = _resolve_llm_used_quota( + system_configuration=provider_configuration.system_configuration, + model=model, + usage=usage, + ) + _deduct_used_llm_quota( + tenant_id=tenant_id, + provider=provider, + provider_configuration=provider_configuration, + used_quota=used_quota, + ) + + +def _require_llm_model_instance(model_instance: ModelInstance) -> None: + """Reject deprecated wrapper calls that pass a non-LLM model instance.""" + if model_instance.model_type_instance.model_type != ModelType.LLM: + raise ValueError("LLM quota helpers only support LLM model instances.") + + +def ensure_llm_quota_available(*, model_instance: ModelInstance) -> None: + """Deprecated compatibility wrapper for callers that still pass ModelInstance.""" + warnings.warn( + "ensure_llm_quota_available(model_instance=...) is deprecated; " + "use ensure_llm_quota_available_for_model(...) instead.", + DeprecationWarning, + stacklevel=2, + ) + _require_llm_model_instance(model_instance) + ensure_llm_quota_available_for_model( + tenant_id=model_instance.provider_model_bundle.configuration.tenant_id, + provider=model_instance.provider, + model=model_instance.model_name, + ) + + +def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LLMUsage) -> None: + """Deprecated compatibility wrapper for callers that still pass ModelInstance.""" + warnings.warn( + "deduct_llm_quota(tenant_id=..., model_instance=..., usage=...) is deprecated; " + "use deduct_llm_quota_for_model(...) instead.", + DeprecationWarning, + stacklevel=2, + ) + _require_llm_model_instance(model_instance) + deduct_llm_quota_for_model( + tenant_id=tenant_id, + provider=model_instance.provider, + model=model_instance.model_name, + usage=usage, + ) diff --git a/api/core/app/workflow/layers/llm_quota.py b/api/core/app/workflow/layers/llm_quota.py index 4a7918032e..2422eed5a7 100644 --- a/api/core/app/workflow/layers/llm_quota.py +++ b/api/core/app/workflow/layers/llm_quota.py @@ -1,36 +1,48 @@ """ LLM quota deduction layer for GraphEngine. -This layer centralizes model-quota deduction outside node implementations. +This layer centralizes model-quota handling outside node implementations. + +Graphon LLM-backed nodes expose provider/model identity through public node +configuration and, after execution, through ``node_run_result.inputs``. Resolve +quota billing from that public identity instead of depending on +``ModelInstance`` reconstruction inside the workflow layer. Missing identity on +quota-tracked nodes is treated as a workflow bug and aborts execution so quota +handling is never silently skipped. """ import logging -from typing import TYPE_CHECKING, cast, final, override +from typing import final, override -from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext -from core.app.llm import deduct_llm_quota, ensure_llm_quota_available +from core.app.llm import deduct_llm_quota_for_model, ensure_llm_quota_available_for_model from core.errors.error import QuotaExceededError -from core.model_manager import ModelInstance -from graphon.enums import BuiltinNodeTypes +from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.graph_engine.entities.commands import AbortCommand, CommandType from graphon.graph_engine.layers import GraphEngineLayer from graphon.graph_events import GraphEngineEvent, GraphNodeEventBase, NodeRunSucceededEvent +from graphon.node_events import NodeRunResult from graphon.nodes.base.node import Node -if TYPE_CHECKING: - from graphon.nodes.llm.node import LLMNode - from graphon.nodes.parameter_extractor.parameter_extractor_node import ParameterExtractorNode - from graphon.nodes.question_classifier.question_classifier_node import QuestionClassifierNode - logger = logging.getLogger(__name__) +_QUOTA_NODE_TYPES = frozenset( + [ + BuiltinNodeTypes.LLM, + BuiltinNodeTypes.PARAMETER_EXTRACTOR, + BuiltinNodeTypes.QUESTION_CLASSIFIER, + ] +) @final class LLMQuotaLayer(GraphEngineLayer): - """Graph layer that applies LLM quota deduction after node execution.""" + """Graph layer that applies tenant-scoped quota checks to LLM-backed nodes.""" - def __init__(self) -> None: + tenant_id: str + _abort_sent: bool + + def __init__(self, tenant_id: str) -> None: super().__init__() + self.tenant_id = tenant_id self._abort_sent = False @override @@ -50,33 +62,49 @@ class LLMQuotaLayer(GraphEngineLayer): if self._abort_sent: return - model_instance = self._extract_model_instance(node) - if model_instance is None: + if not self._supports_quota(node): return + model_identity = self._extract_model_identity_from_node(node) + if model_identity is None: + reason = "LLM quota check requires public node model identity before execution." + self._abort_before_node_run(node=node, reason=reason, error_type="LLMQuotaIdentityError") + logger.error("LLM quota handling aborted, node_id=%s, reason=%s", node.id, reason) + return + + provider, model_name = model_identity try: - ensure_llm_quota_available(model_instance=model_instance) + ensure_llm_quota_available_for_model( + tenant_id=self.tenant_id, + provider=provider, + model=model_name, + ) except QuotaExceededError as exc: - self._set_stop_event(node) - self._send_abort_command(reason=str(exc)) + self._abort_before_node_run(node=node, reason=str(exc), error_type=QuotaExceededError.__name__) logger.warning("LLM quota check failed, node_id=%s, error=%s", node.id, exc) @override def on_node_run_end( self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None ) -> None: - if error is not None or not isinstance(result_event, NodeRunSucceededEvent): + if error is not None or not isinstance(result_event, NodeRunSucceededEvent) or not self._supports_quota(node): return - model_instance = self._extract_model_instance(node) - if model_instance is None: + model_identity = self._extract_model_identity_from_result_event(result_event) + if model_identity is None: + self._abort_for_missing_model_identity( + node=node, + reason="LLM quota deduction requires model identity in the node result event.", + ) return + provider, model_name = model_identity + try: - dify_ctx = DifyRunContext.model_validate(node.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) - deduct_llm_quota( - tenant_id=dify_ctx.tenant_id, - model_instance=model_instance, + deduct_llm_quota_for_model( + tenant_id=self.tenant_id, + provider=provider, + model=model_name, usage=result_event.node_run_result.llm_usage, ) except QuotaExceededError as exc: @@ -92,6 +120,27 @@ class LLMQuotaLayer(GraphEngineLayer): if stop_event is not None: stop_event.set() + def _abort_before_node_run(self, *, node: Node, reason: str, error_type: str) -> None: + self._set_stop_event(node) + node.node_data.error_strategy = None + node.node_data.retry_config.retry_enabled = False + + def quota_aborted_run() -> NodeRunResult: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error=reason, + error_type=error_type, + ) + + # TODO: Push Graphon to expose a public pre-run failure/skip hook, then replace this private _run override. + node._run = quota_aborted_run # type: ignore[method-assign] + self._send_abort_command(reason=reason) + + def _abort_for_missing_model_identity(self, *, node: Node, reason: str) -> None: + self._set_stop_event(node) + self._send_abort_command(reason=reason) + logger.error("LLM quota handling aborted, node_id=%s, reason=%s", node.id, reason) + def _send_abort_command(self, *, reason: str) -> None: if not self.command_channel or self._abort_sent: return @@ -108,29 +157,38 @@ class LLMQuotaLayer(GraphEngineLayer): logger.exception("Failed to send quota abort command") @staticmethod - def _extract_model_instance(node: Node) -> ModelInstance | None: - try: - match node.node_type: - case BuiltinNodeTypes.LLM: - model_instance = cast("LLMNode", node).model_instance - case BuiltinNodeTypes.PARAMETER_EXTRACTOR: - model_instance = cast("ParameterExtractorNode", node).model_instance - case BuiltinNodeTypes.QUESTION_CLASSIFIER: - model_instance = cast("QuestionClassifierNode", node).model_instance - case _: - return None - except AttributeError: + def _supports_quota(node: Node) -> bool: + return node.node_type in _QUOTA_NODE_TYPES + + @staticmethod + def _extract_model_identity_from_result_event(result_event: NodeRunSucceededEvent) -> tuple[str, str] | None: + provider = result_event.node_run_result.inputs.get("model_provider") + model_name = result_event.node_run_result.inputs.get("model_name") + if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name: + return provider, model_name + return None + + @staticmethod + def _extract_model_identity_from_node(node: Node) -> tuple[str, str] | None: + node_data = getattr(node, "node_data", None) + if node_data is None: + node_data = getattr(node, "data", None) + + model_config = getattr(node_data, "model", None) + if model_config is None: logger.warning( - "LLMQuotaLayer skipped quota deduction because node does not expose a model instance, node_id=%s", + "LLMQuotaLayer skipped quota handling because node model config is missing, node_id=%s", node.id, ) return None - if isinstance(model_instance, ModelInstance): - return model_instance - - raw_model_instance = getattr(model_instance, "_model_instance", None) - if isinstance(raw_model_instance, ModelInstance): - return raw_model_instance + provider = getattr(model_config, "provider", None) + model_name = getattr(model_config, "name", None) + if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name: + return provider, model_name + logger.warning( + "LLMQuotaLayer skipped quota handling because node model identity is invalid, node_id=%s", + node.id, + ) return None diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py index 38b87e2cd1..495fd1d898 100644 --- a/api/core/entities/provider_configuration.py +++ b/api/core/entities/provider_configuration.py @@ -23,7 +23,7 @@ from core.entities.provider_entities import ( ) from core.helper import encrypter from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType -from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory +from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_assembly from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType from graphon.model_runtime.entities.provider_entities import ( ConfigurateMethod, @@ -33,7 +33,7 @@ from graphon.model_runtime.entities.provider_entities import ( ) from graphon.model_runtime.model_providers.base.ai_model import AIModel from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory -from graphon.model_runtime.runtime import ModelRuntime +from graphon.model_runtime.protocols.runtime import ModelRuntime from libs.datetime_utils import naive_utc_now from models.engine import db from models.enums import CredentialSourceType @@ -106,11 +106,18 @@ class ProviderConfiguration(BaseModel): """Attach the already-composed runtime for request-bound call chains.""" self._bound_model_runtime = model_runtime + def _get_runtime_and_provider_factory(self) -> tuple[ModelRuntime, ModelProviderFactory]: + """Resolve a provider factory that stays aligned with the runtime used by the caller.""" + if self._bound_model_runtime is not None: + return self._bound_model_runtime, ModelProviderFactory(runtime=self._bound_model_runtime) + + model_assembly = create_plugin_model_assembly(tenant_id=self.tenant_id) + return model_assembly.model_runtime, model_assembly.model_provider_factory + def get_model_provider_factory(self) -> ModelProviderFactory: """Return a provider factory that preserves any request-bound runtime.""" - if self._bound_model_runtime is not None: - return ModelProviderFactory(model_runtime=self._bound_model_runtime) - return create_plugin_model_provider_factory(tenant_id=self.tenant_id) + _, model_provider_factory = self._get_runtime_and_provider_factory() + return model_provider_factory def get_current_credentials(self, model_type: ModelType, model: str) -> dict[str, Any] | None: """ @@ -1392,10 +1399,13 @@ class ProviderConfiguration(BaseModel): :param model_type: model type :return: """ - model_provider_factory = self.get_model_provider_factory() - - # Get model instance of LLM - return model_provider_factory.get_model_type_instance(provider=self.provider.provider, model_type=model_type) + model_runtime, model_provider_factory = self._get_runtime_and_provider_factory() + provider_schema = model_provider_factory.get_provider_schema(provider=self.provider.provider) + return create_model_type_instance( + runtime=model_runtime, + provider_schema=provider_schema, + model_type=model_type, + ) def get_model_schema( self, model_type: ModelType, model: str, credentials: dict[str, Any] | None diff --git a/api/core/helper/moderation.py b/api/core/helper/moderation.py index f169f247cf..18b9b72e9d 100644 --- a/api/core/helper/moderation.py +++ b/api/core/helper/moderation.py @@ -4,7 +4,7 @@ from typing import cast from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity from core.entities import DEFAULT_PLUGIN_ID -from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory +from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from extensions.ext_hosting_provider import hosting_configuration from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.invoke import InvokeBadRequestError @@ -41,10 +41,8 @@ def check_moderation(tenant_id: str, model_config: ModelConfigWithCredentialsEnt text_chunk = secrets.choice(text_chunks) try: - model_provider_factory = create_plugin_model_provider_factory(tenant_id=tenant_id) - - # Get model instance of LLM - model_type_instance = model_provider_factory.get_model_type_instance( + model_assembly = create_plugin_model_assembly(tenant_id=tenant_id) + model_type_instance = model_assembly.create_model_type_instance( provider=openai_provider_name, model_type=ModelType.MODERATION ) model_type_instance = cast(ModerationModel, model_type_instance) diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index 4e66d58b5e..62573ba2f5 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -4,23 +4,32 @@ import hashlib import logging from collections.abc import Generator, Iterable, Sequence from threading import Lock -from typing import IO, Any, Union +from typing import IO, Any, Literal, cast, overload from pydantic import ValidationError from redis import RedisError from configs import dify_config +from core.llm_generator.output_parser.structured_output import ( + invoke_llm_with_structured_output as invoke_llm_with_structured_output_helper, +) from core.plugin.entities.plugin_daemon import PluginModelProviderEntity from core.plugin.impl.asset import PluginAssetManager from core.plugin.impl.model import PluginModelClient from extensions.ext_redis import redis_client -from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk +from graphon.model_runtime.entities.llm_entities import ( + LLMResult, + LLMResultChunk, + LLMResultChunkWithStructuredOutput, + LLMResultWithStructuredOutput, +) from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType from graphon.model_runtime.entities.provider_entities import ProviderEntity from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult from graphon.model_runtime.entities.text_embedding_entities import EmbeddingInputType, EmbeddingResult -from graphon.model_runtime.runtime import ModelRuntime +from graphon.model_runtime.model_providers.base.large_language_model import normalize_non_stream_runtime_result +from graphon.model_runtime.protocols.runtime import ModelRuntime from models.provider_ids import ModelProviderID logger = logging.getLogger(__name__) @@ -29,6 +38,68 @@ logger = logging.getLogger(__name__) TENANT_SCOPE_SCHEMA_CACHE_USER_ID = "__DIFY_TS__" +# TODO(-LAN-): Move native structured-output invocation into Graphon's LLM node. +# TODO(-LAN-): Remove this Dify-side adapter once Graphon owns structured output end-to-end. +class _PluginStructuredOutputModelInstance: + """Bind plugin model identity to the shared structured-output helper. + + The structured-output parser is shared with legacy ``ModelInstance`` flows + and only needs an object exposing ``invoke_llm(...)``. ``PluginModelRuntime`` + intentionally exposes a lower-level API where provider, model, and + credentials are passed per call. This adapter supplies the small bound + ``invoke_llm`` surface the helper needs without constructing a full + ``ModelInstance`` or reintroducing model-manager dependencies into the + plugin runtime path. + """ + + def __init__( + self, + *, + runtime: PluginModelRuntime, + provider: str, + model: str, + credentials: dict[str, Any], + ) -> None: + self._runtime = runtime + self._provider = provider + self._model = model + self._credentials = credentials + + def invoke_llm( + self, + *, + prompt_messages: Sequence[PromptMessage], + model_parameters: dict[str, Any] | None = None, + tools: Sequence[PromptMessageTool] | None = None, + stop: Sequence[str] | None = None, + stream: bool = True, + callbacks: object | None = None, + ) -> LLMResult | Generator[LLMResultChunk, None, None]: + del callbacks + if stream: + return self._runtime.invoke_llm( + provider=self._provider, + model=self._model, + credentials=self._credentials, + model_parameters=model_parameters or {}, + prompt_messages=prompt_messages, + tools=list(tools) if tools else None, + stop=stop, + stream=True, + ) + + return self._runtime.invoke_llm( + provider=self._provider, + model=self._model, + credentials=self._credentials, + model_parameters=model_parameters or {}, + prompt_messages=prompt_messages, + tools=list(tools) if tools else None, + stop=stop, + stream=False, + ) + + class PluginModelRuntime(ModelRuntime): """Plugin-backed runtime adapter bound to tenant context and optional caller scope.""" @@ -195,6 +266,34 @@ class PluginModelRuntime(ModelRuntime): return schema + @overload + def invoke_llm( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + tools: list[PromptMessageTool] | None, + stop: Sequence[str] | None, + stream: Literal[False], + ) -> LLMResult: ... + + @overload + def invoke_llm( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + tools: list[PromptMessageTool] | None, + stop: Sequence[str] | None, + stream: Literal[True], + ) -> Generator[LLMResultChunk, None, None]: ... + def invoke_llm( self, *, @@ -206,9 +305,9 @@ class PluginModelRuntime(ModelRuntime): tools: list[PromptMessageTool] | None, stop: Sequence[str] | None, stream: bool, - ) -> Union[LLMResult, Generator[LLMResultChunk, None, None]]: + ) -> LLMResult | Generator[LLMResultChunk, None, None]: plugin_id, provider_name = self._split_provider(provider) - return self.client.invoke_llm( + result = self.client.invoke_llm( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, @@ -221,6 +320,81 @@ class PluginModelRuntime(ModelRuntime): stop=list(stop) if stop else None, stream=stream, ) + if stream: + return result + + return normalize_non_stream_runtime_result( + model=model, + prompt_messages=prompt_messages, + result=result, + ) + + @overload + def invoke_llm_with_structured_output( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + json_schema: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + stop: Sequence[str] | None, + stream: Literal[False], + ) -> LLMResultWithStructuredOutput: ... + + @overload + def invoke_llm_with_structured_output( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + json_schema: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + stop: Sequence[str] | None, + stream: Literal[True], + ) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ... + + def invoke_llm_with_structured_output( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + json_schema: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + stop: Sequence[str] | None, + stream: bool, + ) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]: + model_schema = self.get_model_schema( + provider=provider, + model_type=ModelType.LLM, + model=model, + credentials=credentials, + ) + if model_schema is None: + raise ValueError(f"Model schema not found for {model}") + + adapter = _PluginStructuredOutputModelInstance( + runtime=self, + provider=provider, + model=model, + credentials=credentials, + ) + return invoke_llm_with_structured_output_helper( + provider=provider, + model_schema=model_schema, + model_instance=cast(Any, adapter), + prompt_messages=prompt_messages, + json_schema=json_schema, + model_parameters=model_parameters, + tools=None, + stop=list(stop) if stop else None, + stream=stream, + ) def get_llm_num_tokens( self, diff --git a/api/core/plugin/impl/model_runtime_factory.py b/api/core/plugin/impl/model_runtime_factory.py index 35abd2ae8c..fbe307ea60 100644 --- a/api/core/plugin/impl/model_runtime_factory.py +++ b/api/core/plugin/impl/model_runtime_factory.py @@ -3,13 +3,46 @@ from __future__ import annotations from typing import TYPE_CHECKING from core.plugin.impl.model import PluginModelClient +from graphon.model_runtime.entities.model_entities import ModelType +from graphon.model_runtime.entities.provider_entities import ProviderEntity +from graphon.model_runtime.model_providers.base.ai_model import AIModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.moderation_model import ModerationModel +from graphon.model_runtime.model_providers.base.rerank_model import RerankModel +from graphon.model_runtime.model_providers.base.speech2text_model import Speech2TextModel +from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel +from graphon.model_runtime.model_providers.base.tts_model import TTSModel from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory +from graphon.model_runtime.protocols.runtime import ModelRuntime if TYPE_CHECKING: from core.model_manager import ModelManager from core.plugin.impl.model_runtime import PluginModelRuntime from core.provider_manager import ProviderManager +_MODEL_CLASS_BY_TYPE: dict[ModelType, type[AIModel]] = { + ModelType.LLM: LargeLanguageModel, + ModelType.TEXT_EMBEDDING: TextEmbeddingModel, + ModelType.RERANK: RerankModel, + ModelType.SPEECH2TEXT: Speech2TextModel, + ModelType.MODERATION: ModerationModel, + ModelType.TTS: TTSModel, +} + + +def create_model_type_instance( + *, + runtime: ModelRuntime, + provider_schema: ProviderEntity, + model_type: ModelType, +) -> AIModel: + """Build the graphon model wrapper explicitly against the request runtime.""" + model_class = _MODEL_CLASS_BY_TYPE.get(model_type) + if model_class is None: + raise ValueError(f"Unsupported model type: {model_type}") + + return model_class(provider_schema=provider_schema, model_runtime=runtime) + class PluginModelAssembly: """Compose request-scoped model views on top of a single plugin runtime.""" @@ -38,9 +71,22 @@ class PluginModelAssembly: @property def model_provider_factory(self) -> ModelProviderFactory: if self._model_provider_factory is None: - self._model_provider_factory = ModelProviderFactory(model_runtime=self.model_runtime) + self._model_provider_factory = ModelProviderFactory(runtime=self.model_runtime) return self._model_provider_factory + def create_model_type_instance( + self, + *, + provider: str, + model_type: ModelType, + ) -> AIModel: + provider_schema = self.model_provider_factory.get_provider_schema(provider=provider) + return create_model_type_instance( + runtime=self.model_runtime, + provider_schema=provider_schema, + model_type=model_type, + ) + @property def provider_manager(self) -> ProviderManager: if self._provider_manager is None: diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index b290ae456e..9faa70a0b8 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -56,7 +56,7 @@ from models.provider_ids import ModelProviderID from services.feature_service import FeatureService if TYPE_CHECKING: - from graphon.model_runtime.runtime import ModelRuntime + from graphon.model_runtime.protocols.runtime import ModelRuntime _credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any]) @@ -165,7 +165,7 @@ class ProviderManager: ) # Get all provider entities - model_provider_factory = ModelProviderFactory(model_runtime=self._model_runtime) + model_provider_factory = ModelProviderFactory(runtime=self._model_runtime) provider_entities = model_provider_factory.get_providers() # Get All preferred provider types of the workspace @@ -362,7 +362,7 @@ class ProviderManager: if not default_model: return None - model_provider_factory = ModelProviderFactory(model_runtime=self._model_runtime) + model_provider_factory = ModelProviderFactory(runtime=self._model_runtime) provider_schema = model_provider_factory.get_provider_schema(provider=default_model.provider_name) return DefaultModelEntity( diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index 895953a3c1..a306b1c9ac 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -374,11 +374,6 @@ class DifyNodeFactory(NodeFactory): # Re-validate using the resolved node class so workflow-local node schemas # stay explicit and constructors receive the concrete typed payload. resolved_node_data = self._validate_resolved_node_data(node_class, node_data) - config_for_node_init: BaseNodeData | dict[str, Any] - if isinstance(resolved_node_data, BaseNodeData): - config_for_node_init = resolved_node_data.model_dump(mode="python", by_alias=True) - else: - config_for_node_init = resolved_node_data node_type = node_data.type node_init_kwargs_factories: Mapping[NodeType, Callable[[], dict[str, object]]] = { BuiltinNodeTypes.CODE: lambda: { @@ -446,9 +441,10 @@ class DifyNodeFactory(NodeFactory): }, } node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})() + constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True) return node_class( node_id=node_id, - config=config_for_node_init, + data=constructor_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, **node_init_kwargs, diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 68a24e86b1..17d71668cb 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -35,7 +35,7 @@ class AgentNode(Node[AgentNodeData]): def __init__( self, node_id: str, - config: AgentNodeData, + data: AgentNodeData, *, graph_init_params: GraphInitParams, graph_runtime_state: GraphRuntimeState, @@ -46,7 +46,7 @@ class AgentNode(Node[AgentNodeData]): ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/nodes/datasource/datasource_node.py b/api/core/workflow/nodes/datasource/datasource_node.py index f3006c4242..a4ef3d1ea7 100644 --- a/api/core/workflow/nodes/datasource/datasource_node.py +++ b/api/core/workflow/nodes/datasource/datasource_node.py @@ -36,14 +36,14 @@ class DatasourceNode(Node[DatasourceNodeData]): def __init__( self, node_id: str, - config: DatasourceNodeData, + data: DatasourceNodeData, *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py index 9c1b7ab2c4..1d60f530a1 100644 --- a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py +++ b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py @@ -32,14 +32,14 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]): def __init__( self, node_id: str, - config: KnowledgeIndexNodeData, + data: KnowledgeIndexNodeData, *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py index 25f73e446d..1aba2737b0 100644 --- a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py +++ b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py @@ -71,14 +71,14 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD def __init__( self, node_id: str, - config: KnowledgeRetrievalNodeData, + data: KnowledgeRetrievalNodeData, *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/system_variables.py b/api/core/workflow/system_variables.py index 9d15a3fcea..77ef3826e9 100644 --- a/api/core/workflow/system_variables.py +++ b/api/core/workflow/system_variables.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Mapping, Sequence from enum import StrEnum -from typing import Any, Protocol, cast +from typing import Any, Protocol from uuid import uuid4 from graphon.enums import BuiltinNodeTypes @@ -82,13 +82,10 @@ def build_system_variables(values: Mapping[str, Any] | None = None, /, **kwargs: normalized = _normalize_system_variable_values(values, **kwargs) return [ - cast( - Variable, - segment_to_variable( - segment=build_segment(value), - selector=system_variable_selector(key), - name=key, - ), + segment_to_variable( + segment=build_segment(value), + selector=system_variable_selector(key), + name=key, ) for key, value in normalized.items() ] @@ -130,13 +127,10 @@ def build_bootstrap_variables( for node_id, value in rag_pipeline_variables_map.items(): variables.append( - cast( - Variable, - segment_to_variable( - segment=build_segment(value), - selector=(RAG_PIPELINE_VARIABLE_NODE_ID, node_id), - name=node_id, - ), + segment_to_variable( + segment=build_segment(value), + selector=(RAG_PIPELINE_VARIABLE_NODE_ID, node_id), + name=node_id, ) ) diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index 4e2f603e5b..3019704dac 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -46,6 +46,11 @@ _file_access_controller = DatabaseFileAccessController() class _WorkflowChildEngineBuilder: + tenant_id: str + + def __init__(self, *, tenant_id: str) -> None: + self.tenant_id = tenant_id + @staticmethod def _has_node_id(graph_config: Mapping[str, Any], node_id: str) -> bool | None: """ @@ -107,7 +112,7 @@ class _WorkflowChildEngineBuilder: config=config, child_engine_builder=self, ) - child_engine.layer(LLMQuotaLayer()) + child_engine.layer(LLMQuotaLayer(tenant_id=self.tenant_id)) return child_engine @@ -176,7 +181,7 @@ class WorkflowEntry: self.command_channel = command_channel execution_context = capture_current_context() graph_runtime_state.execution_context = execution_context - self._child_engine_builder = _WorkflowChildEngineBuilder() + self._child_engine_builder = _WorkflowChildEngineBuilder(tenant_id=tenant_id) self.graph_engine = GraphEngine( workflow_id=workflow_id, graph=graph, @@ -208,7 +213,7 @@ class WorkflowEntry: max_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME ) self.graph_engine.layer(limits_layer) - self.graph_engine.layer(LLMQuotaLayer()) + self.graph_engine.layer(LLMQuotaLayer(tenant_id=tenant_id)) # Add observability layer when OTel is enabled if dify_config.ENABLE_OTEL or is_instrument_flag_enabled(): diff --git a/api/events/event_handlers/update_provider_when_message_created.py b/api/events/event_handlers/update_provider_when_message_created.py index 1d615f0f87..8dec5876a9 100644 --- a/api/events/event_handlers/update_provider_when_message_created.py +++ b/api/events/event_handlers/update_provider_when_message_created.py @@ -137,17 +137,13 @@ def handle(sender: Message, **kwargs): if used_quota is not None: match provider_configuration.system_configuration.current_quota_type: case ProviderQuotaType.TRIAL: - from services.credit_pool_service import CreditPoolService - - CreditPoolService.check_and_deduct_credits( + _deduct_credit_pool_quota_capped( tenant_id=tenant_id, credits_required=used_quota, pool_type="trial", ) case ProviderQuotaType.PAID: - from services.credit_pool_service import CreditPoolService - - CreditPoolService.check_and_deduct_credits( + _deduct_credit_pool_quota_capped( tenant_id=tenant_id, credits_required=used_quota, pool_type="paid", @@ -200,6 +196,26 @@ def handle(sender: Message, **kwargs): raise +def _deduct_credit_pool_quota_capped(*, tenant_id: str, credits_required: int, pool_type: str) -> None: + """Apply post-generation credit accounting without failing message persistence on quota exhaustion.""" + from services.credit_pool_service import CreditPoolService + + deducted_credits = CreditPoolService.deduct_credits_capped( + tenant_id=tenant_id, + credits_required=credits_required, + pool_type=pool_type, + ) + if deducted_credits < credits_required: + logger.warning( + "Credit pool exhausted during message-created accounting, " + "tenant_id=%s, pool_type=%s, credits_required=%s, credits_deducted=%s", + tenant_id, + pool_type, + credits_required, + deducted_credits, + ) + + def _calculate_quota_usage( *, message: Message, system_configuration: SystemConfiguration, model_name: str ) -> int | None: diff --git a/api/pyproject.toml b/api/pyproject.toml index 0c488c34d9..6c30779f9d 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]~=0.7.0", - "graphon~=0.2.2", + "graphon~=0.3.0", "httpx-sse~=0.4.0", "json-repair~=0.59.4", ] diff --git a/api/services/credit_pool_service.py b/api/services/credit_pool_service.py index 2d210db121..1f419d7a5b 100644 --- a/api/services/credit_pool_service.py +++ b/api/services/credit_pool_service.py @@ -1,7 +1,7 @@ import logging -from sqlalchemy import select, update -from sqlalchemy.orm import sessionmaker +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from core.errors.error import QuotaExceededError @@ -13,6 +13,18 @@ logger = logging.getLogger(__name__) class CreditPoolService: + @staticmethod + def _get_locked_pool(session: Session, tenant_id: str, pool_type: str) -> TenantCreditPool | None: + return session.scalar( + select(TenantCreditPool) + .where( + TenantCreditPool.tenant_id == tenant_id, + TenantCreditPool.pool_type == pool_type, + ) + .limit(1) + .with_for_update() + ) + @classmethod def create_default_pool(cls, tenant_id: str) -> TenantCreditPool: """create default credit pool for new tenant""" @@ -59,31 +71,57 @@ class CreditPoolService: credits_required: int, pool_type: str = "trial", ) -> int: - """check and deduct credits, returns actual credits deducted""" - - pool = cls.get_pool(tenant_id, pool_type) - if not pool: - raise QuotaExceededError("Credit pool not found") - - if pool.remaining_credits <= 0: - raise QuotaExceededError("No credits remaining") - - # deduct all remaining credits if less than required - actual_credits = min(credits_required, pool.remaining_credits) + """Deduct exactly the requested credits or raise without mutating the pool.""" + if credits_required <= 0: + return 0 try: - with sessionmaker(db.engine).begin() as session: - stmt = ( - update(TenantCreditPool) - .where( - TenantCreditPool.tenant_id == tenant_id, - TenantCreditPool.pool_type == pool_type, - ) - .values(quota_used=TenantCreditPool.quota_used + actual_credits) - ) - session.execute(stmt) + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: + pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type) + if not pool: + raise QuotaExceededError("Credit pool not found") + + remaining_credits = pool.remaining_credits + if remaining_credits <= 0: + raise QuotaExceededError("No credits remaining") + if remaining_credits < credits_required: + raise QuotaExceededError("Insufficient credits remaining") + + pool.quota_used += credits_required + except QuotaExceededError: + raise except Exception: logger.exception("Failed to deduct credits for tenant %s", tenant_id) raise QuotaExceededError("Failed to deduct credits") - return actual_credits + return credits_required + + @classmethod + def deduct_credits_capped( + cls, + tenant_id: str, + credits_required: int, + pool_type: str = "trial", + ) -> int: + """Deduct up to the available balance and return the actual deducted credits.""" + if credits_required <= 0: + return 0 + + try: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: + pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type) + if not pool: + logger.warning("Credit pool not found, tenant_id=%s, pool_type=%s", tenant_id, pool_type) + return 0 + + deducted_credits = min(credits_required, pool.remaining_credits) + if deducted_credits <= 0: + return 0 + + pool.quota_used += deducted_credits + return deducted_credits + except QuotaExceededError: + raise + except Exception: + logger.exception("Failed to deduct capped credits for tenant %s", tenant_id) + raise QuotaExceededError("Failed to deduct credits") diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index a55448e352..59db147576 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -157,8 +157,8 @@ class DraftVarLoader(VariableLoader): # This approach reduces loading time by querying external systems concurrently. with ThreadPoolExecutor(max_workers=10) as executor: offloaded_variables = executor.map(self._load_offloaded_variable, offloaded_draft_vars) - for selector, variable in offloaded_variables: - variable_by_selector[selector] = variable + for selector, offloaded_variable in offloaded_variables: + variable_by_selector[selector] = offloaded_variable return list(variable_by_selector.values()) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index f97b85dc2b..b8c2ed5e6f 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -1251,7 +1251,7 @@ class WorkflowService: node_data = HumanInputNode.validate_node_data(adapt_human_input_node_data_for_graph(node_config["data"])) node = HumanInputNode( node_id=node_config["id"], - config=node_data, + data=node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, runtime=DifyHumanInputNodeRuntime(run_context), diff --git a/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py b/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py index 2c1e667c58..b9f09ccadd 100644 --- a/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py +++ b/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py @@ -73,7 +73,7 @@ def test_node_integration_minimal_stream(mocker: MockerFixture): node = DatasourceNode( node_id="n", - config=DatasourceNodeData( + data=DatasourceNodeData( type="datasource", version="1", title="Datasource", diff --git a/api/tests/integration_tests/workflow/nodes/__mock/model.py b/api/tests/integration_tests/workflow/nodes/__mock/model.py index a9a2617bae..a77fe5970a 100644 --- a/api/tests/integration_tests/workflow/nodes/__mock/model.py +++ b/api/tests/integration_tests/workflow/nodes/__mock/model.py @@ -4,7 +4,7 @@ from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEnti from core.entities.provider_configuration import ProviderConfiguration, ProviderModelBundle from core.entities.provider_entities import CustomConfiguration, CustomProviderConfiguration, SystemConfiguration from core.model_manager import ModelInstance -from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory +from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from graphon.model_runtime.entities.model_entities import ModelType from models.provider import ProviderType @@ -15,8 +15,9 @@ def get_mocked_fetch_model_config( mode: str, credentials: dict, ): - model_provider_factory = create_plugin_model_provider_factory(tenant_id="9d2074fc-6f86-45a9-b09d-6ecc63b9056b") - model_type_instance = model_provider_factory.get_model_type_instance(provider, ModelType.LLM) + model_assembly = create_plugin_model_assembly(tenant_id="9d2074fc-6f86-45a9-b09d-6ecc63b9056b") + model_provider_factory = model_assembly.model_provider_factory + model_type_instance = model_assembly.create_model_type_instance(provider=provider, model_type=ModelType.LLM) provider_model_bundle = ProviderModelBundle( configuration=ProviderConfiguration( tenant_id="1", diff --git a/api/tests/integration_tests/workflow/nodes/test_code.py b/api/tests/integration_tests/workflow/nodes/test_code.py index aaa6092993..9345113aa3 100644 --- a/api/tests/integration_tests/workflow/nodes/test_code.py +++ b/api/tests/integration_tests/workflow/nodes/test_code.py @@ -45,7 +45,7 @@ def init_code_node(code_config: dict): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -66,7 +66,7 @@ def init_code_node(code_config: dict): node = CodeNode( node_id=str(uuid.uuid4()), - config=CodeNodeData.model_validate(code_config["data"]), + data=CodeNodeData.model_validate(code_config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, code_executor=node_factory._code_executor, diff --git a/api/tests/integration_tests/workflow/nodes/test_http.py b/api/tests/integration_tests/workflow/nodes/test_http.py index b9f7b9575b..7cd7f50b77 100644 --- a/api/tests/integration_tests/workflow/nodes/test_http.py +++ b/api/tests/integration_tests/workflow/nodes/test_http.py @@ -55,7 +55,7 @@ def init_http_node(config: dict): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -76,7 +76,7 @@ def init_http_node(config: dict): node = HttpRequestNode( node_id=str(uuid.uuid4()), - config=HttpRequestNodeData.model_validate(config["data"]), + data=HttpRequestNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, @@ -204,7 +204,7 @@ def test_custom_auth_with_empty_api_key_raises_error(setup_http_mock): from graphon.runtime import VariablePool # Create variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="test", files=[]), user_inputs={}, environment_variables=[], @@ -702,7 +702,7 @@ def test_nested_object_variable_selector(setup_http_mock): ) # Create independent variable pool for this test only - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -724,7 +724,7 @@ def test_nested_object_variable_selector(setup_http_mock): node = HttpRequestNode( node_id=str(uuid.uuid4()), - config=HttpRequestNodeData.model_validate(graph_config["nodes"][1]["data"]), + data=HttpRequestNodeData.model_validate(graph_config["nodes"][1]["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py index 3eead70163..92f3a1926c 100644 --- a/api/tests/integration_tests/workflow/nodes/test_llm.py +++ b/api/tests/integration_tests/workflow/nodes/test_llm.py @@ -53,7 +53,7 @@ def init_llm_node(config: dict) -> LLMNode: ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="aaa", app_id=app_id, @@ -77,7 +77,7 @@ def init_llm_node(config: dict) -> LLMNode: node = LLMNode( node_id=str(uuid.uuid4()), - config=LLMNodeData.model_validate(config["data"]), + data=LLMNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, credentials_provider=MagicMock(spec=CredentialsProvider), diff --git a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py index f2eabb86c3..f11188323a 100644 --- a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py +++ b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py @@ -56,7 +56,7 @@ def init_parameter_extractor_node(config: dict, memory=None): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="aaa", files=[], query="what's the weather in SF", conversation_id="abababa" ), @@ -71,7 +71,7 @@ def init_parameter_extractor_node(config: dict, memory=None): node = ParameterExtractorNode( node_id=str(uuid.uuid4()), - config=ParameterExtractorNodeData.model_validate(config["data"]), + data=ParameterExtractorNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, credentials_provider=MagicMock(spec=CredentialsProvider), diff --git a/api/tests/integration_tests/workflow/nodes/test_template_transform.py b/api/tests/integration_tests/workflow/nodes/test_template_transform.py index e2e0723fb8..80489e6809 100644 --- a/api/tests/integration_tests/workflow/nodes/test_template_transform.py +++ b/api/tests/integration_tests/workflow/nodes/test_template_transform.py @@ -66,7 +66,7 @@ def test_execute_template_transform(): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -88,7 +88,7 @@ def test_execute_template_transform(): node = TemplateTransformNode( node_id=str(uuid.uuid4()), - config=TemplateTransformNodeData.model_validate(config["data"]), + data=TemplateTransformNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, jinja2_template_renderer=_SimpleJinja2Renderer(), diff --git a/api/tests/integration_tests/workflow/nodes/test_tool.py b/api/tests/integration_tests/workflow/nodes/test_tool.py index 493330e02b..78c12e7ea5 100644 --- a/api/tests/integration_tests/workflow/nodes/test_tool.py +++ b/api/tests/integration_tests/workflow/nodes/test_tool.py @@ -43,7 +43,7 @@ def init_tool_node(config: dict): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -64,7 +64,7 @@ def init_tool_node(config: dict): node = ToolNode( node_id=str(uuid.uuid4()), - config=ToolNodeData.model_validate(config["data"]), + data=ToolNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, tool_file_manager_factory=tool_file_manager_factory, diff --git a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py index bd13527e14..66b3392a4b 100644 --- a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py +++ b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py @@ -210,7 +210,9 @@ class TestPauseStatePersistenceLayerTestContainers: execution_id = workflow_run_id or getattr(self, "test_workflow_run_id", None) or str(uuid.uuid4()) # Create variable pool - variable_pool = VariablePool(system_variables=build_system_variables(workflow_execution_id=execution_id)) + variable_pool = VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id=execution_id) + ) if variables: for (node_id, var_key), value in variables.items(): variable_pool.add([node_id, var_key], value) diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py index 5aed230cd4..ad82b8fe2a 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py @@ -66,7 +66,7 @@ def _mock_form_repository_with_submission(action_id: str) -> HumanInputFormRepos def _build_runtime_state(workflow_execution_id: str, app_id: str, workflow_id: str, user_id: str) -> GraphRuntimeState: - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( workflow_execution_id=workflow_execution_id, app_id=app_id, @@ -102,7 +102,7 @@ def _build_graph( start_data = StartNodeData(title="start", variables=[]) start_node = StartNode( node_id="start", - config=start_data, + data=start_data, graph_init_params=params, graph_runtime_state=runtime_state, ) @@ -117,7 +117,7 @@ def _build_graph( ) human_node = HumanInputNode( node_id="human", - config=human_data, + data=human_data, graph_init_params=params, graph_runtime_state=runtime_state, form_repository=form_repository, @@ -131,7 +131,7 @@ def _build_graph( ) end_node = EndNode( node_id="end", - config=end_data, + data=end_data, graph_init_params=params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/test_containers_integration_tests/services/test_credit_pool_service.py b/api/tests/test_containers_integration_tests/services/test_credit_pool_service.py index 09ba041244..07dc3a4e9e 100644 --- a/api/tests/test_containers_integration_tests/services/test_credit_pool_service.py +++ b/api/tests/test_containers_integration_tests/services/test_credit_pool_service.py @@ -90,16 +90,34 @@ class TestCreditPoolService: pool = CreditPoolService.get_pool(tenant_id=tenant_id) assert pool.quota_used == credits_required - def test_check_and_deduct_credits_caps_at_remaining(self, db_session_with_containers: Session): + def test_check_and_deduct_credits_raises_without_deducting_when_insufficient( + self, db_session_with_containers: Session + ): tenant_id = self._create_tenant_id() pool = CreditPoolService.create_default_pool(tenant_id) remaining = 5 pool.quota_used = pool.quota_limit - remaining + quota_used = pool.quota_used db_session_with_containers.commit() - result = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=200) + with pytest.raises(QuotaExceededError, match="Insufficient credits remaining"): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=200) + + db_session_with_containers.expire_all() + updated_pool = CreditPoolService.get_pool(tenant_id=tenant_id) + assert updated_pool.quota_used == quota_used + + def test_deduct_credits_capped_depletes_available_balance(self, db_session_with_containers: Session): + tenant_id = self._create_tenant_id() + pool = CreditPoolService.create_default_pool(tenant_id) + remaining = 5 + pool.quota_used = pool.quota_limit - remaining + quota_limit = pool.quota_limit + db_session_with_containers.commit() + + result = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=200) assert result == remaining db_session_with_containers.expire_all() updated_pool = CreditPoolService.get_pool(tenant_id=tenant_id) - assert updated_pool.quota_used == pool.quota_limit + assert updated_pool.quota_used == quota_limit diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 1d72d7807d..d8f794b483 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -132,7 +132,9 @@ class TestAdvancedChatGenerateTaskPipeline: pipeline._task_state.answer = "partial answer" pipeline._workflow_run_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=build_test_variable_pool( + variables=build_system_variables(workflow_execution_id="run-id"), + ), start_at=0.0, total_tokens=7, node_run_steps=3, @@ -372,7 +374,9 @@ class TestAdvancedChatGenerateTaskPipeline: pipeline = _make_pipeline() pipeline._workflow_run_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._workflow_response_converter.workflow_finish_to_stream_response = lambda **kwargs: "finish" @@ -583,7 +587,9 @@ class TestAdvancedChatGenerateTaskPipeline: self.items = items graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) @@ -617,7 +623,9 @@ class TestAdvancedChatGenerateTaskPipeline: def test_handle_message_end_event_applies_output_moderation(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._base_task_pipeline.handle_output_moderation_when_task_finished = lambda answer: "safe" diff --git a/api/tests/unit_tests/core/app/apps/test_pause_resume.py b/api/tests/unit_tests/core/app/apps/test_pause_resume.py index aa71f4d9c4..1acebfee17 100644 --- a/api/tests/unit_tests/core/app/apps/test_pause_resume.py +++ b/api/tests/unit_tests/core/app/apps/test_pause_resume.py @@ -60,7 +60,7 @@ class _StubToolNode(Node[_StubToolNodeData]): def __init__( self, node_id: str, - config: _StubToolNodeData, + data: _StubToolNodeData, *, graph_init_params, graph_runtime_state, @@ -68,7 +68,7 @@ class _StubToolNode(Node[_StubToolNodeData]): ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) @@ -169,7 +169,7 @@ def _build_graph(runtime_state: GraphRuntimeState, *, pause_on: str | None) -> G def _build_runtime_state(run_id: str) -> GraphRuntimeState: - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="user", app_id="app", workflow_id="workflow"), user_inputs={}, conversation_variables=[], diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index 4a0d4f490e..3949c41eae 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -54,7 +54,7 @@ class TestWorkflowBasedAppRunner: runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -93,7 +93,7 @@ class TestWorkflowBasedAppRunner: def test_get_graph_and_variable_pool_for_single_node_run(self, monkeypatch: pytest.MonkeyPatch): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -164,7 +164,7 @@ class TestWorkflowBasedAppRunner: app_id="app", ) graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -243,7 +243,7 @@ class TestWorkflowBasedAppRunner: runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) graph_runtime_state.register_paused_node("node-1") @@ -286,7 +286,7 @@ class TestWorkflowBasedAppRunner: runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) @@ -425,7 +425,7 @@ class TestWorkflowBasedAppRunner: runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py index 620a153204..248fed5388 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py @@ -16,7 +16,7 @@ from models.workflow import Workflow def _make_graph_state(): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, environment_variables=[], diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index 1311d5e9cb..ea21a1cc1a 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -95,7 +95,9 @@ class TestWorkflowGenerateTaskPipeline: def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=build_test_variable_pool( + variables=build_system_variables(workflow_execution_id="run-id"), + ), start_at=0.0, total_tokens=5, node_run_steps=2, @@ -283,7 +285,9 @@ class TestWorkflowGenerateTaskPipeline: pipeline = _make_pipeline() pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._workflow_response_converter.workflow_finish_to_stream_response = lambda **kwargs: "finish" @@ -725,7 +729,9 @@ class TestWorkflowGenerateTaskPipeline: pipeline = _make_pipeline() pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) @@ -753,7 +759,9 @@ class TestWorkflowGenerateTaskPipeline: pipeline = _make_pipeline() pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._handle_ping_event = lambda event, **kwargs: iter(["ping"]) @@ -769,7 +777,9 @@ class TestWorkflowGenerateTaskPipeline: def test_process_stream_response_main_match_paths_and_cleanup(self): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._base_task_pipeline.queue_manager.listen = lambda: iter( diff --git a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py index d3bd15b6f3..320a3bc42c 100644 --- a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py @@ -21,7 +21,9 @@ class TestTriggerPostLayer: ) runtime_state = SimpleNamespace( outputs={"answer": "ok"}, - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-1")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-1") + ), total_tokens=12, ) @@ -60,7 +62,9 @@ class TestTriggerPostLayer: def test_on_event_handles_missing_trigger_log(self): runtime_state = SimpleNamespace( outputs={}, - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-1")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-1") + ), total_tokens=0, ) @@ -91,7 +95,9 @@ class TestTriggerPostLayer: def test_on_event_ignores_non_status_events(self): runtime_state = SimpleNamespace( outputs={}, - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-1")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-1") + ), total_tokens=0, ) diff --git a/api/tests/unit_tests/core/app/test_llm_quota.py b/api/tests/unit_tests/core/app/test_llm_quota.py new file mode 100644 index 0000000000..d9390a4a8f --- /dev/null +++ b/api/tests/unit_tests/core/app/test_llm_quota.py @@ -0,0 +1,617 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import create_engine, select + +from configs import dify_config +from core.app.llm.quota import ( + deduct_llm_quota, + deduct_llm_quota_for_model, + ensure_llm_quota_available, + ensure_llm_quota_available_for_model, +) +from core.entities.model_entities import ModelStatus +from core.entities.provider_entities import ProviderQuotaType, QuotaUnit +from core.errors.error import QuotaExceededError +from graphon.model_runtime.entities.llm_entities import LLMUsage +from graphon.model_runtime.entities.model_entities import ModelType +from models import TenantCreditPool +from models.enums import ProviderQuotaType as ModelProviderQuotaType +from models.provider import Provider, ProviderType + + +def test_ensure_llm_quota_available_for_model_raises_when_system_model_is_exhausted() -> None: + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + get_provider_model=MagicMock(return_value=SimpleNamespace(status=ModelStatus.QUOTA_EXCEEDED)), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + pytest.raises(QuotaExceededError, match="Model provider openai quota exceeded."), + ): + ensure_llm_quota_available_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) + + provider_configuration.get_provider_model.assert_called_once_with( + model_type=ModelType.LLM, + model="gpt-4o", + ) + + +def test_ensure_llm_quota_available_for_model_raises_when_provider_is_missing() -> None: + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = None + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + pytest.raises(ValueError, match="Provider openai does not exist."), + ): + ensure_llm_quota_available_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) + + +def test_ensure_llm_quota_available_for_model_ignores_custom_provider_configuration() -> None: + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.CUSTOM, + get_provider_model=MagicMock(), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager): + ensure_llm_quota_available_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) + + provider_configuration.get_provider_model.assert_not_called() + + +def test_deduct_llm_quota_for_model_uses_identity_based_trial_billing() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 42 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_called_once_with( + tenant_id="tenant-id", + credits_required=42, + ) + + +def test_deduct_llm_quota_for_model_caps_trial_pool_when_usage_exceeds_remaining() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 3 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + with engine.begin() as connection: + connection.execute( + TenantCreditPool.__table__.insert(), + { + "id": "trial-pool", + "tenant_id": "tenant-id", + "pool_type": ModelProviderQuotaType.TRIAL, + "quota_limit": 10, + "quota_used": 9, + }, + ) + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)), + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + with engine.connect() as connection: + quota_used = connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == "trial-pool")) + + assert quota_used == 10 + + +def test_deduct_llm_quota_for_model_returns_for_unbounded_quota() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 42 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=-1, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_not_called() + + +def test_deduct_llm_quota_for_model_uses_credit_configuration() -> None: + usage = LLMUsage.empty_usage() + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.CREDITS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch.object(type(dify_config), "get_model_credits", return_value=9) as mock_get_model_credits, + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_get_model_credits.assert_called_once_with("gpt-4o") + mock_deduct_credits.assert_called_once_with( + tenant_id="tenant-id", + credits_required=9, + ) + + +def test_deduct_llm_quota_for_model_uses_single_charge_for_times_quota() -> None: + usage = LLMUsage.empty_usage() + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TIMES, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_called_once_with( + tenant_id="tenant-id", + credits_required=1, + ) + + +def test_deduct_llm_quota_for_model_uses_paid_billing_pool() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 5 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.PAID, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.PAID, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_called_once_with( + tenant_id="tenant-id", + credits_required=5, + pool_type="paid", + ) + + +def test_deduct_llm_quota_for_model_updates_free_quota_usage() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 3 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.FREE, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.FREE, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + engine = create_engine("sqlite:///:memory:") + Provider.__table__.create(engine) + with engine.begin() as connection: + connection.execute( + Provider.__table__.insert(), + [ + { + "id": "matching-provider", + "tenant_id": "tenant-id", + "provider_name": "openai", + "provider_type": ProviderType.SYSTEM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 100, + "quota_used": 10, + "is_valid": True, + }, + { + "id": "other-tenant", + "tenant_id": "other-tenant-id", + "provider_name": "openai", + "provider_type": ProviderType.SYSTEM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 100, + "quota_used": 20, + "is_valid": True, + }, + { + "id": "other-provider", + "tenant_id": "tenant-id", + "provider_name": "anthropic", + "provider_type": ProviderType.SYSTEM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 100, + "quota_used": 30, + "is_valid": True, + }, + { + "id": "custom-provider", + "tenant_id": "tenant-id", + "provider_name": "openai", + "provider_type": ProviderType.CUSTOM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 100, + "quota_used": 40, + "is_valid": True, + }, + ], + ) + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("core.app.llm.quota.db", SimpleNamespace(engine=engine)), + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + with engine.connect() as connection: + quota_used_by_id = dict(connection.execute(select(Provider.id, Provider.quota_used)).all()) + + assert quota_used_by_id == { + "matching-provider": 13, + "other-tenant": 20, + "other-provider": 30, + "custom-provider": 40, + } + + with engine.begin() as connection: + connection.execute( + Provider.__table__.update().where(Provider.id == "matching-provider").values(quota_limit=13, quota_used=13) + ) + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("core.app.llm.quota.db", SimpleNamespace(engine=engine)), + pytest.raises(QuotaExceededError, match="Model provider openai quota exceeded."), + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + with engine.connect() as connection: + exhausted_quota_used = connection.scalar(select(Provider.quota_used).where(Provider.id == "matching-provider")) + + assert exhausted_quota_used == 13 + + +def test_deduct_llm_quota_for_model_caps_free_quota_and_raises_when_usage_exceeds_remaining() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 3 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.FREE, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.FREE, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + engine = create_engine("sqlite:///:memory:") + Provider.__table__.create(engine) + with engine.begin() as connection: + connection.execute( + Provider.__table__.insert(), + { + "id": "matching-provider", + "tenant_id": "tenant-id", + "provider_name": "openai", + "provider_type": ProviderType.SYSTEM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 15, + "quota_used": 13, + "is_valid": True, + }, + ) + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("core.app.llm.quota.db", SimpleNamespace(engine=engine)), + pytest.raises(QuotaExceededError, match="Model provider openai quota exceeded."), + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + with engine.connect() as connection: + quota_used = connection.scalar(select(Provider.quota_used).where(Provider.id == "matching-provider")) + + assert quota_used == 15 + + +def test_deduct_llm_quota_for_model_ignores_unknown_quota_type() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 2 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type="unexpected", + quota_configurations=[ + SimpleNamespace( + quota_type="unexpected", + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + patch("core.app.llm.quota.sessionmaker") as mock_sessionmaker, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_not_called() + mock_sessionmaker.assert_not_called() + + +def test_deduct_llm_quota_for_model_ignores_custom_provider_configuration() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 2 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.CUSTOM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + patch("core.app.llm.quota.sessionmaker") as mock_sessionmaker, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_not_called() + mock_sessionmaker.assert_not_called() + + +def test_ensure_llm_quota_available_wrapper_warns_and_delegates() -> None: + model_instance = SimpleNamespace( + provider="openai", + model_name="gpt-4o", + provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace(tenant_id="tenant-id")), + model_type_instance=SimpleNamespace(model_type=ModelType.LLM), + ) + + with ( + pytest.deprecated_call(match="ensure_llm_quota_available\\(model_instance=.*deprecated"), + patch("core.app.llm.quota.ensure_llm_quota_available_for_model") as mock_ensure, + ): + ensure_llm_quota_available(model_instance=model_instance) + + mock_ensure.assert_called_once_with( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) + + +def test_ensure_llm_quota_available_wrapper_rejects_non_llm_model_instances() -> None: + model_instance = SimpleNamespace( + provider="openai", + model_name="gpt-4o", + provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace(tenant_id="tenant-id")), + model_type_instance=SimpleNamespace(model_type=ModelType.TEXT_EMBEDDING), + ) + + with ( + pytest.deprecated_call(match="ensure_llm_quota_available\\(model_instance=.*deprecated"), + pytest.raises(ValueError, match="only support LLM model instances"), + ): + ensure_llm_quota_available(model_instance=model_instance) + + +def test_deduct_llm_quota_wrapper_warns_and_delegates() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 7 + model_instance = SimpleNamespace( + provider="openai", + model_name="gpt-4o", + model_type_instance=SimpleNamespace(model_type=ModelType.LLM), + provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace()), + ) + + with ( + pytest.deprecated_call(match="deduct_llm_quota\\(tenant_id=.*deprecated"), + patch("core.app.llm.quota.deduct_llm_quota_for_model") as mock_deduct, + ): + deduct_llm_quota( + tenant_id="tenant-id", + model_instance=model_instance, + usage=usage, + ) + + mock_deduct.assert_called_once_with( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + +def test_deduct_llm_quota_wrapper_rejects_non_llm_model_instances() -> None: + usage = LLMUsage.empty_usage() + model_instance = SimpleNamespace( + provider="openai", + model_name="gpt-4o", + model_type_instance=SimpleNamespace(model_type=ModelType.TEXT_EMBEDDING), + provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace()), + ) + + with ( + pytest.deprecated_call(match="deduct_llm_quota\\(tenant_id=.*deprecated"), + pytest.raises(ValueError, match="only support LLM model instances"), + ): + deduct_llm_quota( + tenant_id="tenant-id", + model_instance=model_instance, + usage=usage, + ) diff --git a/api/tests/unit_tests/core/app/workflow/test_node_factory.py b/api/tests/unit_tests/core/app/workflow/test_node_factory.py index 7c9f174129..addce649d5 100644 --- a/api/tests/unit_tests/core/app/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/app/workflow/test_node_factory.py @@ -8,9 +8,9 @@ from graphon.enums import BuiltinNodeTypes class DummyNode: - def __init__(self, *, node_id, config, graph_init_params, graph_runtime_state, **kwargs): + def __init__(self, *, node_id, data, graph_init_params, graph_runtime_state, **kwargs): self.id = node_id - self.config = config + self.data = data self.graph_init_params = graph_init_params self.graph_runtime_state = graph_runtime_state self.kwargs = kwargs diff --git a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py index 23fe682017..7e87c088ce 100644 --- a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py +++ b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py @@ -60,7 +60,10 @@ def _make_layer( workflow_execution_id="run-id", conversation_id="conv-id", ) - runtime_state = GraphRuntimeState(variable_pool=VariablePool(system_variables=system_variables), start_at=0.0) + runtime_state = GraphRuntimeState( + variable_pool=VariablePool.from_bootstrap(system_variables=system_variables), + start_at=0.0, + ) read_only_state = ReadOnlyGraphRuntimeStateWrapper(runtime_state) application_generate_entity = WorkflowAppGenerateEntity.model_construct( diff --git a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py index a28143026f..1b714d6830 100644 --- a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py +++ b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py @@ -354,7 +354,8 @@ def test_validate_provider_credentials_handles_hidden_secret_value() -> None: with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.decrypt_token", return_value="restored-key"): with patch( @@ -379,7 +380,10 @@ def test_validate_provider_credentials_without_credential_id() -> None: mock_factory = Mock() mock_factory.provider_credentials_validate.return_value = {"region": "us"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): + with patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), + ): validated = configuration.validate_provider_credentials(credentials={"region": "us"}) assert validated == {"region": "us"} @@ -426,23 +430,37 @@ def test_switch_preferred_provider_type_creates_record_when_missing() -> None: def test_get_model_type_instance_and_schema_delegate_to_factory() -> None: configuration = _build_provider_configuration() - mock_factory = Mock() mock_model_type_instance = Mock() mock_schema = _build_ai_model("gpt-4o") - mock_factory.get_model_type_instance.return_value = mock_model_type_instance + mock_factory = Mock() + mock_factory.get_provider_schema.return_value = configuration.provider mock_factory.get_model_schema.return_value = mock_schema + mock_assembly = Mock() + mock_assembly.model_runtime = Mock() + mock_assembly.model_provider_factory = mock_factory - with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", - return_value=mock_factory, - ) as mock_factory_builder: + with ( + patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=mock_assembly, + ) as mock_assembly_builder, + patch( + "core.entities.provider_configuration.create_model_type_instance", + return_value=mock_model_type_instance, + ) as mock_model_builder, + ): model_type_instance = configuration.get_model_type_instance(ModelType.LLM) model_schema = configuration.get_model_schema(ModelType.LLM, "gpt-4o", {"api_key": "x"}) assert model_type_instance is mock_model_type_instance assert model_schema is mock_schema - assert mock_factory_builder.call_count == 2 - mock_factory.get_model_type_instance.assert_called_once_with(provider="openai", model_type=ModelType.LLM) + assert mock_assembly_builder.call_count == 2 + mock_factory.get_provider_schema.assert_called_once_with(provider="openai") + mock_model_builder.assert_called_once_with( + runtime=mock_assembly.model_runtime, + provider_schema=configuration.provider, + model_type=ModelType.LLM, + ) mock_factory.get_model_schema.assert_called_once_with( provider="openai", model_type=ModelType.LLM, @@ -456,17 +474,21 @@ def test_get_model_type_instance_and_schema_reuse_bound_runtime_factory() -> Non bound_runtime = Mock() configuration.bind_model_runtime(bound_runtime) - mock_factory = Mock() mock_model_type_instance = Mock() mock_schema = _build_ai_model("gpt-4o") - mock_factory.get_model_type_instance.return_value = mock_model_type_instance + mock_factory = Mock() + mock_factory.get_provider_schema.return_value = configuration.provider mock_factory.get_model_schema.return_value = mock_schema with ( patch( "core.entities.provider_configuration.ModelProviderFactory", return_value=mock_factory ) as mock_factory_cls, - patch("core.entities.provider_configuration.create_plugin_model_provider_factory") as mock_factory_builder, + patch("core.entities.provider_configuration.create_plugin_model_assembly") as mock_assembly_builder, + patch( + "core.entities.provider_configuration.create_model_type_instance", + return_value=mock_model_type_instance, + ) as mock_model_builder, ): model_type_instance = configuration.get_model_type_instance(ModelType.LLM) model_schema = configuration.get_model_schema(ModelType.LLM, "gpt-4o", {"api_key": "x"}) @@ -474,8 +496,14 @@ def test_get_model_type_instance_and_schema_reuse_bound_runtime_factory() -> Non assert model_type_instance is mock_model_type_instance assert model_schema is mock_schema assert mock_factory_cls.call_count == 2 - mock_factory_cls.assert_called_with(model_runtime=bound_runtime) - mock_factory_builder.assert_not_called() + mock_factory_cls.assert_called_with(runtime=bound_runtime) + mock_assembly_builder.assert_not_called() + mock_factory.get_provider_schema.assert_called_once_with(provider="openai") + mock_model_builder.assert_called_once_with( + runtime=bound_runtime, + provider_schema=configuration.provider, + model_type=ModelType.LLM, + ) def test_get_provider_model_returns_none_when_model_not_found() -> None: @@ -504,7 +532,10 @@ def test_get_provider_models_system_deduplicates_sorts_and_filters_active() -> N mock_factory = Mock() mock_factory.get_provider_schema.return_value = provider_schema - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): + with patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), + ): all_models = configuration.get_provider_models(model_type=ModelType.LLM, only_active=False) active_models = configuration.get_provider_models(model_type=ModelType.LLM, only_active=True) @@ -722,7 +753,8 @@ def test_validate_provider_credentials_handles_invalid_original_json() -> None: with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-key"): validated = configuration.validate_provider_credentials( @@ -1069,7 +1101,8 @@ def test_validate_custom_model_credentials_supports_hidden_reuse_and_sessionless with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.decrypt_token", return_value="raw"): with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): @@ -1083,7 +1116,10 @@ def test_validate_custom_model_credentials_supports_hidden_reuse_and_sessionless mock_factory2 = Mock() mock_factory2.model_credentials_validate.return_value = {"region": "us"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory2): + with patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory2), + ): validated = configuration.validate_custom_model_credentials( model_type=ModelType.LLM, model="gpt-4o", @@ -1575,7 +1611,8 @@ def test_validate_provider_credentials_uses_empty_original_when_record_missing() with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): validated = configuration.validate_provider_credentials( @@ -1701,7 +1738,8 @@ def test_validate_custom_model_credentials_handles_invalid_original_json() -> No with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): validated = configuration.validate_custom_model_credentials( diff --git a/api/tests/unit_tests/core/helper/test_moderation.py b/api/tests/unit_tests/core/helper/test_moderation.py index a0dfa86d20..c33002329b 100644 --- a/api/tests/unit_tests/core/helper/test_moderation.py +++ b/api/tests/unit_tests/core/helper/test_moderation.py @@ -68,8 +68,8 @@ def test_check_moderation_returns_true_when_model_accepts_text(mocker: MockerFix mocker.patch("core.helper.moderation.secrets.choice", return_value="chunk") moderation_model = SimpleNamespace(invoke=lambda **invoke_kwargs: invoke_kwargs["text"] == "chunk") - factory = SimpleNamespace(get_model_type_instance=lambda **_factory_kwargs: moderation_model) - mocker.patch("core.helper.moderation.create_plugin_model_provider_factory", return_value=factory) + assembly = SimpleNamespace(create_model_type_instance=lambda **_factory_kwargs: moderation_model) + mocker.patch("core.helper.moderation.create_plugin_model_assembly", return_value=assembly) assert ( check_moderation( @@ -91,7 +91,7 @@ def test_check_moderation_returns_true_when_text_is_empty(mocker: MockerFixture) provider_map={openai_provider: hosting_openai}, ), ) - factory_mock = mocker.patch("core.helper.moderation.create_plugin_model_provider_factory") + factory_mock = mocker.patch("core.helper.moderation.create_plugin_model_assembly") choice_mock = mocker.patch("core.helper.moderation.secrets.choice") assert ( @@ -119,8 +119,8 @@ def test_check_moderation_returns_false_when_model_rejects_text(mocker: MockerFi mocker.patch("core.helper.moderation.secrets.choice", return_value="chunk") moderation_model = SimpleNamespace(invoke=lambda **_invoke_kwargs: False) - factory = SimpleNamespace(get_model_type_instance=lambda **_factory_kwargs: moderation_model) - mocker.patch("core.helper.moderation.create_plugin_model_provider_factory", return_value=factory) + assembly = SimpleNamespace(create_model_type_instance=lambda **_factory_kwargs: moderation_model) + mocker.patch("core.helper.moderation.create_plugin_model_assembly", return_value=assembly) assert ( check_moderation( @@ -147,8 +147,8 @@ def test_check_moderation_raises_bad_request_when_provider_call_fails(mocker: Mo failing_model = SimpleNamespace( invoke=lambda **_invoke_kwargs: (_ for _ in ()).throw(RuntimeError("boom")), ) - factory = SimpleNamespace(get_model_type_instance=lambda **_factory_kwargs: failing_model) - mocker.patch("core.helper.moderation.create_plugin_model_provider_factory", return_value=factory) + assembly = SimpleNamespace(create_model_type_instance=lambda **_factory_kwargs: failing_model) + mocker.patch("core.helper.moderation.create_plugin_model_assembly", return_value=assembly) with pytest.raises(InvokeBadRequestError, match="Rate limit exceeded, please try again later."): check_moderation( diff --git a/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py b/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py index c4fd970562..2b51dc8182 100644 --- a/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py +++ b/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py @@ -2,6 +2,7 @@ from unittest.mock import Mock import pytest +from core.plugin.impl.model_runtime_factory import create_model_type_instance from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType from graphon.model_runtime.entities.provider_entities import ( @@ -73,7 +74,7 @@ def test_model_provider_factory_resolves_runtime_provider_name() -> None: supported_model_types=[ModelType.LLM], configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], ) - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime([provider])) + factory = ModelProviderFactory(runtime=_FakeModelRuntime([provider])) provider_schema = factory.get_model_provider("openai") @@ -98,7 +99,7 @@ def test_model_provider_factory_resolves_canonical_short_name_independent_of_pro configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], ), ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) provider_schema = factory.get_model_provider("openai") @@ -107,8 +108,8 @@ def test_model_provider_factory_resolves_canonical_short_name_independent_of_pro def test_model_provider_factory_requires_runtime() -> None: - with pytest.raises(ValueError, match="model_runtime is required"): - ModelProviderFactory(model_runtime=None) # type: ignore[arg-type] + with pytest.raises(ValueError, match="runtime is required"): + ModelProviderFactory(runtime=None) # type: ignore[arg-type] def test_model_provider_factory_get_providers_returns_runtime_providers() -> None: @@ -119,7 +120,7 @@ def test_model_provider_factory_get_providers_returns_runtime_providers() -> Non supported_model_types=[ModelType.LLM], ) ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) result = factory.get_providers() @@ -133,7 +134,7 @@ def test_model_provider_factory_get_provider_schema_delegates_to_provider_lookup provider_name="openai", supported_model_types=[ModelType.LLM], ) - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime([provider])) + factory = ModelProviderFactory(runtime=_FakeModelRuntime([provider])) result = factory.get_provider_schema("openai") @@ -142,7 +143,7 @@ def test_model_provider_factory_get_provider_schema_delegates_to_provider_lookup def test_model_provider_factory_raises_for_unknown_provider() -> None: factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( + runtime=_FakeModelRuntime( [ _build_provider( provider="langgenius/openai/openai", @@ -172,7 +173,7 @@ def test_model_provider_factory_get_models_filters_provider_and_model_type() -> models=[_build_model("rerank-v3", ModelType.RERANK)], ), ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) results = factory.get_models(provider="openai", model_type=ModelType.LLM) @@ -196,7 +197,7 @@ def test_model_provider_factory_get_models_skips_providers_without_requested_mod models=[_build_model("eleven_multilingual_v2", ModelType.TTS)], ), ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) results = factory.get_models(model_type=ModelType.TTS) @@ -214,7 +215,7 @@ def test_model_provider_factory_get_models_without_model_type_keeps_all_provider models=[_build_model("gpt-4o-mini", ModelType.LLM), _build_model("tts-1", ModelType.TTS)], ) ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) results = factory.get_models(provider="openai") @@ -242,7 +243,7 @@ def test_model_provider_factory_validates_provider_credentials() -> None: ) ] ) - factory = ModelProviderFactory(model_runtime=runtime) + factory = ModelProviderFactory(runtime=runtime) filtered = factory.provider_credentials_validate( provider="openai", @@ -258,7 +259,7 @@ def test_model_provider_factory_validates_provider_credentials() -> None: def test_model_provider_factory_provider_credentials_validate_requires_schema() -> None: factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( + runtime=_FakeModelRuntime( [ _build_provider( provider="langgenius/openai/openai", @@ -294,7 +295,7 @@ def test_model_provider_factory_validates_model_credentials() -> None: ) ] ) - factory = ModelProviderFactory(model_runtime=runtime) + factory = ModelProviderFactory(runtime=runtime) filtered = factory.model_credentials_validate( provider="openai", @@ -314,7 +315,7 @@ def test_model_provider_factory_validates_model_credentials() -> None: def test_model_provider_factory_model_credentials_validate_requires_schema() -> None: factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( + runtime=_FakeModelRuntime( [ _build_provider( provider="langgenius/openai/openai", @@ -346,7 +347,7 @@ def test_model_provider_factory_get_model_schema_and_icon_use_canonical_provider ) runtime.get_model_schema.return_value = "schema" runtime.get_provider_icon.return_value = (b"icon", "image/png") - factory = ModelProviderFactory(model_runtime=runtime) + factory = ModelProviderFactory(runtime=runtime) assert ( factory.get_model_schema( @@ -382,39 +383,43 @@ def test_model_provider_factory_get_model_schema_and_icon_use_canonical_provider (ModelType.TTS, TTSModel), ], ) -def test_model_provider_factory_builds_model_type_instances( +def test_create_model_type_instance_builds_model_wrappers( model_type: ModelType, expected_type: type[object], ) -> None: - factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( - [ - _build_provider( - provider="langgenius/openai/openai", - provider_name="openai", - supported_model_types=[model_type], - ) - ] - ) + runtime = _FakeModelRuntime( + [ + _build_provider( + provider="langgenius/openai/openai", + provider_name="openai", + supported_model_types=[model_type], + ) + ] ) - instance = factory.get_model_type_instance("openai", model_type) + instance = create_model_type_instance( + runtime=runtime, + provider_schema=runtime.fetch_model_providers()[0], + model_type=model_type, + ) assert isinstance(instance, expected_type) -def test_model_provider_factory_rejects_unsupported_model_type() -> None: - factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( - [ - _build_provider( - provider="langgenius/openai/openai", - provider_name="openai", - supported_model_types=[ModelType.LLM], - ) - ] - ) +def test_create_model_type_instance_rejects_unsupported_model_type() -> None: + runtime = _FakeModelRuntime( + [ + _build_provider( + provider="langgenius/openai/openai", + provider_name="openai", + supported_model_types=[ModelType.LLM], + ) + ] ) with pytest.raises(ValueError, match="Unsupported model type: unsupported"): - factory.get_model_type_instance("openai", "unsupported") # type: ignore[arg-type] + create_model_type_instance( + runtime=runtime, + provider_schema=runtime.fetch_model_providers()[0], + model_type="unsupported", # type: ignore[arg-type] + ) diff --git a/api/tests/unit_tests/core/plugin/impl/test_model_runtime_factory.py b/api/tests/unit_tests/core/plugin/impl/test_model_runtime_factory.py index 7491e79f30..52da674f06 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_model_runtime_factory.py +++ b/api/tests/unit_tests/core/plugin/impl/test_model_runtime_factory.py @@ -31,6 +31,6 @@ def test_plugin_model_assembly_reuses_single_runtime_across_views(): assert assembly.model_manager is model_manager mock_runtime_factory.assert_called_once_with(tenant_id="tenant-1", user_id="user-1") - mock_provider_factory_cls.assert_called_once_with(model_runtime=runtime) + mock_provider_factory_cls.assert_called_once_with(runtime=runtime) mock_provider_manager_cls.assert_called_once_with(model_runtime=runtime) mock_model_manager_cls.assert_called_once_with(provider_manager=provider_manager) diff --git a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py index 88bf555594..b1ecaa4ead 100644 --- a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py +++ b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py @@ -3,7 +3,7 @@ import datetime import uuid from types import SimpleNamespace -from unittest.mock import Mock, sentinel +from unittest.mock import Mock, patch, sentinel import pytest @@ -13,6 +13,8 @@ from core.plugin.impl.model import PluginModelClient from core.plugin.impl.model_runtime import TENANT_SCOPE_SCHEMA_CACHE_USER_ID, PluginModelRuntime from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime from graphon.model_runtime.entities.common_entities import I18nObject +from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta, LLMUsage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity @@ -146,7 +148,31 @@ class TestPluginModelRuntime: def test_invoke_llm_resolves_plugin_fields(self) -> None: client = Mock(spec=PluginModelClient) - client.invoke_llm.return_value = sentinel.result + usage = LLMUsage.empty_usage() + client.invoke_llm.return_value = iter( + [ + LLMResultChunk( + model="gpt-4o-mini", + prompt_messages=[], + system_fingerprint="fp-plugin", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content="plugin "), + ), + ), + LLMResultChunk( + model="gpt-4o-mini", + prompt_messages=[], + system_fingerprint="fp-plugin", + delta=LLMResultChunkDelta( + index=1, + message=AssistantPromptMessage(content="response"), + usage=usage, + finish_reason="stop", + ), + ), + ] + ) runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client) result = runtime.invoke_llm( @@ -160,7 +186,11 @@ class TestPluginModelRuntime: stream=False, ) - assert result is sentinel.result + assert result.model == "gpt-4o-mini" + assert result.prompt_messages == [] + assert result.message.content == "plugin response" + assert result.usage == usage + assert result.system_fingerprint == "fp-plugin" client.invoke_llm.assert_called_once_with( tenant_id="tenant", user_id="user", @@ -175,6 +205,38 @@ class TestPluginModelRuntime: stream=False, ) + def test_invoke_llm_returns_plugin_stream_directly(self) -> None: + client = Mock(spec=PluginModelClient) + stream_result = iter([]) + client.invoke_llm.return_value = stream_result + runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client) + + result = runtime.invoke_llm( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={"temperature": 0.3}, + prompt_messages=[], + tools=None, + stop=("END",), + stream=True, + ) + + assert result is stream_result + client.invoke_llm.assert_called_once_with( + tenant_id="tenant", + user_id="user", + plugin_id="langgenius/openai", + provider="openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={"temperature": 0.3}, + prompt_messages=[], + tools=None, + stop=["END"], + stream=True, + ) + def test_invoke_llm_rejects_per_call_user_override(self) -> None: client = Mock(spec=PluginModelClient) client.invoke_llm.return_value = sentinel.result @@ -267,6 +329,129 @@ def test_get_model_schema_uses_cached_schema_without_hitting_client(monkeypatch: client.get_model_schema.assert_not_called() +def test_structured_output_adapter_invokes_bound_runtime_streaming() -> None: + runtime = Mock() + runtime.invoke_llm.return_value = sentinel.stream_result + adapter = model_runtime_module._PluginStructuredOutputModelInstance( + runtime=runtime, + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + ) + tool = Mock() + + result = adapter.invoke_llm( + prompt_messages=[], + model_parameters=None, + tools=[tool], + stop=["END"], + stream=True, + callbacks=sentinel.callbacks, + ) + + assert result is sentinel.stream_result + runtime.invoke_llm.assert_called_once_with( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={}, + prompt_messages=[], + tools=[tool], + stop=["END"], + stream=True, + ) + + +def test_structured_output_adapter_invokes_bound_runtime_non_streaming() -> None: + runtime = Mock() + runtime.invoke_llm.return_value = sentinel.result + adapter = model_runtime_module._PluginStructuredOutputModelInstance( + runtime=runtime, + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + ) + + result = adapter.invoke_llm( + prompt_messages=[], + model_parameters={"temperature": 0}, + tools=None, + stop=None, + stream=False, + ) + + assert result is sentinel.result + runtime.invoke_llm.assert_called_once_with( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={"temperature": 0}, + prompt_messages=[], + tools=None, + stop=None, + stream=False, + ) + + +def test_invoke_llm_with_structured_output_delegates_with_bound_adapter() -> None: + client = Mock(spec=PluginModelClient) + runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client) + schema = _build_model_schema() + runtime.get_model_schema = Mock(return_value=schema) # type: ignore[method-assign] + + with patch.object( + model_runtime_module, + "invoke_llm_with_structured_output_helper", + return_value=sentinel.structured_result, + ) as mock_helper: + result = runtime.invoke_llm_with_structured_output( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + json_schema={"type": "object"}, + model_parameters={"temperature": 0}, + prompt_messages=[], + stop=("END",), + stream=False, + ) + + assert result is sentinel.structured_result + runtime.get_model_schema.assert_called_once_with( + provider="langgenius/openai/openai", + model_type=ModelType.LLM, + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + ) + helper_kwargs = mock_helper.call_args.kwargs + assert helper_kwargs["provider"] == "langgenius/openai/openai" + assert helper_kwargs["model_schema"] == schema + assert helper_kwargs["json_schema"] == {"type": "object"} + assert helper_kwargs["model_parameters"] == {"temperature": 0} + assert helper_kwargs["prompt_messages"] == [] + assert helper_kwargs["tools"] is None + assert helper_kwargs["stop"] == ["END"] + assert helper_kwargs["stream"] is False + assert isinstance(helper_kwargs["model_instance"], model_runtime_module._PluginStructuredOutputModelInstance) + + +def test_invoke_llm_with_structured_output_raises_when_model_schema_is_missing() -> None: + client = Mock(spec=PluginModelClient) + runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client) + runtime.get_model_schema = Mock(return_value=None) # type: ignore[method-assign] + + with pytest.raises(ValueError, match="Model schema not found for gpt-4o-mini"): + runtime.invoke_llm_with_structured_output( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + json_schema={"type": "object"}, + model_parameters={}, + prompt_messages=[], + stop=None, + stream=False, + ) + + def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytest.MonkeyPatch) -> None: client = Mock(spec=PluginModelClient) schema = _build_model_schema() diff --git a/api/tests/unit_tests/core/test_provider_manager.py b/api/tests/unit_tests/core/test_provider_manager.py index 02f12fb3b4..e84fcba3d9 100644 --- a/api/tests/unit_tests/core/test_provider_manager.py +++ b/api/tests/unit_tests/core/test_provider_manager.py @@ -289,7 +289,7 @@ def test_get_default_model_uses_injected_runtime_for_existing_default_record(moc result = manager.get_default_model("tenant-id", ModelType.LLM) - mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime) + mock_factory_cls.assert_called_once_with(runtime=manager._model_runtime) assert result is not None assert result.model == "gpt-4" assert result.provider.provider == "openai" @@ -316,7 +316,7 @@ def test_get_configurations_uses_injected_runtime_and_adds_provider_aliases(mock result = manager.get_configurations("tenant-id") expected_alias = str(ModelProviderID("openai")) - mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime) + mock_factory_cls.assert_called_once_with(runtime=manager._model_runtime) assert result.tenant_id == "tenant-id" assert expected_alias in provider_records assert expected_alias in provider_model_records @@ -402,7 +402,7 @@ def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerF assert first is second mock_get_all_providers.assert_called_once_with("tenant-id") - mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime) + mock_factory_cls.assert_called_once_with(runtime=manager._model_runtime) mock_provider_configuration.assert_called_once() provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime) diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_llm_quota.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_llm_quota.py index 5d6667257f..12c7f8113c 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_llm_quota.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_llm_quota.py @@ -1,12 +1,11 @@ +import logging import threading from datetime import datetime from types import SimpleNamespace from unittest.mock import MagicMock, patch -from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from core.app.workflow.layers.llm_quota import LLMQuotaLayer from core.errors.error import QuotaExceededError -from core.model_manager import ModelInstance from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.graph_engine.entities.commands import CommandType from graphon.graph_events import NodeRunSucceededEvent @@ -14,17 +13,7 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import NodeRunResult -def _build_dify_context() -> DifyRunContext: - return DifyRunContext( - tenant_id="tenant-id", - app_id="app-id", - user_id="user-id", - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ) - - -def _build_succeeded_event() -> NodeRunSucceededEvent: +def _build_succeeded_event(*, provider: str = "openai", model_name: str = "gpt-4o") -> NodeRunSucceededEvent: return NodeRunSucceededEvent( id="execution-id", node_id="llm-node-id", @@ -32,113 +21,162 @@ def _build_succeeded_event() -> NodeRunSucceededEvent: start_at=datetime.now(), node_run_result=NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, - inputs={"question": "hello"}, + inputs={ + "question": "hello", + "model_provider": provider, + "model_name": model_name, + }, llm_usage=LLMUsage.empty_usage(), ), ) -def _build_wrapped_model_instance() -> tuple[SimpleNamespace, ModelInstance]: - raw_model_instance = ModelInstance.__new__(ModelInstance) - return SimpleNamespace(_model_instance=raw_model_instance), raw_model_instance +def _build_public_model_identity(*, provider: str = "openai", model_name: str = "gpt-4o") -> SimpleNamespace: + return SimpleNamespace(provider=provider, name=model_name) + + +def _build_node_data(*, model: SimpleNamespace | None = None) -> SimpleNamespace: + return SimpleNamespace( + error_strategy=None, + retry_config=SimpleNamespace(retry_enabled=False), + model=model, + ) + + +def _build_node(*, node_type: BuiltinNodeTypes = BuiltinNodeTypes.LLM) -> MagicMock: + node = MagicMock() + node.id = "node-id" + node.execution_id = "execution-id" + node.node_type = node_type + node.node_data = _build_node_data(model=_build_public_model_identity()) + node.model_instance = SimpleNamespace(provider="stale-provider", model_name="stale-model") + return node + + +class _RunnableQuotaNode: + id = "node-id" + execution_id = "execution-id" + node_type = BuiltinNodeTypes.LLM + title = "LLM node" + + def __init__(self, *, stop_event: threading.Event, node_data: SimpleNamespace | None = None) -> None: + self.node_data = node_data or _build_node_data(model=_build_public_model_identity()) + self.graph_runtime_state = SimpleNamespace(stop_event=stop_event) + self.original_run_called = False + + def _run(self) -> NodeRunResult: + self.original_run_called = True + return NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED) def test_deduct_quota_called_for_successful_llm_node() -> None: - layer = LLMQuotaLayer() - node = MagicMock() - node.id = "llm-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.LLM - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node.model_instance, raw_model_instance = _build_wrapped_model_instance() - + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = _build_node(node_type=BuiltinNodeTypes.LLM) result_event = _build_succeeded_event() - with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota", autospec=True) as mock_deduct: + + with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True) as mock_deduct: layer.on_node_run_end(node=node, error=None, result_event=result_event) mock_deduct.assert_called_once_with( tenant_id="tenant-id", - model_instance=raw_model_instance, + provider="openai", + model="gpt-4o", usage=result_event.node_run_result.llm_usage, ) def test_deduct_quota_called_for_question_classifier_node() -> None: - layer = LLMQuotaLayer() - node = MagicMock() - node.id = "question-classifier-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.QUESTION_CLASSIFIER - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node.model_instance, raw_model_instance = _build_wrapped_model_instance() + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = _build_node(node_type=BuiltinNodeTypes.QUESTION_CLASSIFIER) + result_event = _build_succeeded_event(provider="anthropic", model_name="claude-3-7-sonnet") - result_event = _build_succeeded_event() - with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota", autospec=True) as mock_deduct: + with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True) as mock_deduct: layer.on_node_run_end(node=node, error=None, result_event=result_event) mock_deduct.assert_called_once_with( tenant_id="tenant-id", - model_instance=raw_model_instance, + provider="anthropic", + model="claude-3-7-sonnet", usage=result_event.node_run_result.llm_usage, ) def test_non_llm_node_is_ignored() -> None: - layer = LLMQuotaLayer() - node = MagicMock() - node.id = "start-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.START - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node._model_instance = object() - + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = _build_node(node_type=BuiltinNodeTypes.START) result_event = _build_succeeded_event() - with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota", autospec=True) as mock_deduct: + + with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True) as mock_deduct: layer.on_node_run_end(node=node, error=None, result_event=result_event) mock_deduct.assert_not_called() -def test_quota_error_is_handled_in_layer() -> None: - layer = LLMQuotaLayer() - node = MagicMock() - node.id = "llm-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.LLM - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node.model_instance = object() +def test_precheck_ignores_non_quota_node() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = _build_node(node_type=BuiltinNodeTypes.START) - result_event = _build_succeeded_event() - with patch( - "core.app.workflow.layers.llm_quota.deduct_llm_quota", - autospec=True, - side_effect=ValueError("quota exceeded"), - ): - layer.on_node_run_end(node=node, error=None, result_event=result_event) + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + mock_check.assert_not_called() -def test_quota_deduction_exceeded_aborts_workflow_immediately() -> None: - layer = LLMQuotaLayer() +def test_quota_error_is_handled_in_layer(caplog) -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") stop_event = threading.Event() layer.command_channel = MagicMock() - node = MagicMock() - node.id = "llm-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.LLM - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node.model_instance, _ = _build_wrapped_model_instance() + node = _build_node(node_type=BuiltinNodeTypes.LLM) + node.graph_runtime_state = MagicMock() + node.graph_runtime_state.stop_event = stop_event + result_event = _build_succeeded_event() + + with ( + caplog.at_level(logging.ERROR, logger="core.app.workflow.layers.llm_quota"), + patch( + "core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", + autospec=True, + side_effect=ValueError("quota exceeded"), + ) as mock_deduct, + ): + layer.on_node_run_end(node=node, error=None, result_event=result_event) + + mock_deduct.assert_called_once_with( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=result_event.node_run_result.llm_usage, + ) + assert "LLM quota deduction failed, node_id=node-id" in caplog.text + assert not stop_event.is_set() + layer.command_channel.send_command.assert_not_called() + + +def test_send_abort_command_is_noop_without_channel_or_after_abort() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + + layer._send_abort_command(reason="no channel") + + layer.command_channel = MagicMock() + layer._abort_sent = True + layer._send_abort_command(reason="already aborted") + + layer.command_channel.send_command.assert_not_called() + + +def test_quota_deduction_exceeded_aborts_workflow_immediately() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) node.graph_runtime_state = MagicMock() node.graph_runtime_state.stop_event = stop_event result_event = _build_succeeded_event() with patch( - "core.app.workflow.layers.llm_quota.deduct_llm_quota", + "core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True, side_effect=QuotaExceededError("No credits remaining"), ): @@ -152,19 +190,16 @@ def test_quota_deduction_exceeded_aborts_workflow_immediately() -> None: def test_quota_precheck_failure_aborts_workflow_immediately() -> None: - layer = LLMQuotaLayer() + layer = LLMQuotaLayer(tenant_id="tenant-id") stop_event = threading.Event() layer.command_channel = MagicMock() - node = MagicMock() - node.id = "llm-node-id" - node.node_type = BuiltinNodeTypes.LLM - node.model_instance, _ = _build_wrapped_model_instance() + node = _build_node(node_type=BuiltinNodeTypes.LLM) node.graph_runtime_state = MagicMock() node.graph_runtime_state.stop_event = stop_event with patch( - "core.app.workflow.layers.llm_quota.ensure_llm_quota_available", + "core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True, side_effect=QuotaExceededError("Model provider openai quota exceeded."), ): @@ -177,21 +212,140 @@ def test_quota_precheck_failure_aborts_workflow_immediately() -> None: assert abort_command.reason == "Model provider openai quota exceeded." -def test_quota_precheck_passes_without_abort() -> None: - layer = LLMQuotaLayer() +def test_quota_precheck_failure_blocks_current_node_run() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") stop_event = threading.Event() layer.command_channel = MagicMock() - node = MagicMock() - node.id = "llm-node-id" - node.node_type = BuiltinNodeTypes.LLM - node.model_instance, raw_model_instance = _build_wrapped_model_instance() + node = _RunnableQuotaNode(stop_event=stop_event) + + with patch( + "core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", + autospec=True, + side_effect=QuotaExceededError("Model provider openai quota exceeded."), + ): + layer.on_node_run_start(node) + + result = node._run() + assert not node.original_run_called + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error == "Model provider openai quota exceeded." + assert result.error_type == QuotaExceededError.__name__ + + +def test_missing_model_identity_blocks_current_node_run() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _RunnableQuotaNode(stop_event=stop_event, node_data=_build_node_data()) + + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + result = node._run() + assert not node.original_run_called + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error == "LLM quota check requires public node model identity before execution." + assert result.error_type == "LLMQuotaIdentityError" + mock_check.assert_not_called() + + +def test_quota_precheck_passes_without_abort() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) node.graph_runtime_state = MagicMock() node.graph_runtime_state.stop_event = stop_event - with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available", autospec=True) as mock_check: + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: layer.on_node_run_start(node) assert not stop_event.is_set() - mock_check.assert_called_once_with(model_instance=raw_model_instance) + mock_check.assert_called_once_with( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) layer.command_channel.send_command.assert_not_called() + + +def test_precheck_reads_model_identity_from_data_when_node_data_is_absent() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = SimpleNamespace( + id="node-id", + node_type=BuiltinNodeTypes.LLM, + data=_build_node_data(model=_build_public_model_identity(provider="anthropic", model_name="claude")), + ) + + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + mock_check.assert_called_once_with( + tenant_id="tenant-id", + provider="anthropic", + model="claude", + ) + + +def test_precheck_rejects_invalid_public_model_identity() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) + node.node_data = _build_node_data(model=_build_public_model_identity(provider="", model_name="gpt-4o")) + node.graph_runtime_state = MagicMock() + node.graph_runtime_state.stop_event = stop_event + + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + assert stop_event.is_set() + mock_check.assert_not_called() + layer.command_channel.send_command.assert_called_once() + + +def test_precheck_requires_public_node_model_config() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) + node.node_data = _build_node_data() + node.graph_runtime_state = MagicMock() + node.graph_runtime_state.stop_event = stop_event + + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + assert stop_event.is_set() + mock_check.assert_not_called() + layer.command_channel.send_command.assert_called_once() + abort_command = layer.command_channel.send_command.call_args.args[0] + assert abort_command.command_type == CommandType.ABORT + assert abort_command.reason == "LLM quota check requires public node model identity before execution." + + +def test_deduction_requires_public_event_model_identity() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) + node.graph_runtime_state = MagicMock() + node.graph_runtime_state.stop_event = stop_event + result_event = _build_succeeded_event() + result_event.node_run_result.inputs = {"question": "hello"} + + with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True) as mock_deduct: + layer.on_node_run_end(node=node, error=None, result_event=result_event) + + assert stop_event.is_set() + mock_deduct.assert_not_called() + layer.command_channel.send_command.assert_called_once() + abort_command = layer.command_channel.send_command.call_args.args[0] + assert abort_command.command_type == CommandType.ABORT + assert abort_command.reason == "LLM quota deduction requires model identity in the node result event." diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py index 9f3e3b00b9..c721c7b0eb 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py @@ -96,7 +96,7 @@ class MockNodeFactory(DifyNodeFactory): if node_type == BuiltinNodeTypes.CODE: mock_instance = mock_class( node_id=node_id, - config=resolved_node_data, + data=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -106,7 +106,7 @@ class MockNodeFactory(DifyNodeFactory): elif node_type == BuiltinNodeTypes.HTTP_REQUEST: mock_instance = mock_class( node_id=node_id, - config=resolved_node_data, + data=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -122,7 +122,7 @@ class MockNodeFactory(DifyNodeFactory): }: mock_instance = mock_class( node_id=node_id, - config=resolved_node_data, + data=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -132,7 +132,7 @@ class MockNodeFactory(DifyNodeFactory): else: mock_instance = mock_class( node_id=node_id, - config=resolved_node_data, + data=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py index f9819c47ec..e0eb4e7361 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py @@ -56,7 +56,7 @@ class MockNodeMixin: def __init__( self, node_id: str, - config: Any, + data: Any, *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", @@ -98,7 +98,7 @@ class MockNodeMixin: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, **kwargs, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index 75bc6d05f7..6156f7b576 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -111,7 +111,7 @@ class StaticRepo(HumanInputFormRepository): def _build_runtime_state() -> GraphRuntimeState: - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -140,7 +140,7 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()} start_node = StartNode( node_id=start_config["id"], - config=StartNodeData(title="Start", variables=[]), + data=StartNodeData(title="Start", variables=[]), graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) @@ -155,7 +155,7 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_a_config = {"id": "human_a", "data": human_data.model_dump()} human_a = HumanInputNode( node_id=human_a_config["id"], - config=human_data, + data=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, form_repository=repo, @@ -165,7 +165,7 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_b_config = {"id": "human_b", "data": human_data.model_dump()} human_b = HumanInputNode( node_id=human_b_config["id"], - config=human_data, + data=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, form_repository=repo, @@ -183,7 +183,7 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor end_config = {"id": "end", "data": end_data.model_dump()} end_node = EndNode( node_id=end_config["id"], - config=end_data, + data=end_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py index ae9dae0646..2603e29be6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py +++ b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py @@ -1,41 +1,36 @@ import time import uuid -from unittest.mock import MagicMock from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables -from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus -from graphon.graph import Graph from graphon.nodes.answer.answer_node import AnswerNode from graphon.nodes.answer.entities import AnswerNodeData from graphon.runtime import GraphRuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params -def test_execute_answer(): +def _build_variable_pool() -> VariablePool: + return VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="aaa", files=[]), + user_inputs={}, + ) + + +def _build_answer_node(*, answer: str, variable_pool: VariablePool) -> AnswerNode: graph_config = { - "edges": [ - { - "id": "start-source-answer-target", - "source": "start", - "target": "answer", - }, - ], + "edges": [], "nodes": [ - {"data": {"type": "start", "title": "Start"}, "id": "start"}, { "data": { - "title": "123", + "title": "Answer", "type": "answer", - "answer": "Today's weather is {{#start.weather#}}\n{{#llm.text#}}\n{{img}}\nFin.", + "answer": answer, }, "id": "answer", - }, + } ], } - init_params = build_test_graph_init_params( workflow_id="1", graph_config=graph_config, @@ -46,42 +41,31 @@ def test_execute_answer(): invoke_from=InvokeFrom.DEBUGGER, call_depth=0, ) - - # construct variable pool - variable_pool = VariablePool( - system_variables=build_system_variables(user_id="aaa", files=[]), - user_inputs={}, - environment_variables=[], - conversation_variables=[], + graph_runtime_state = GraphRuntimeState( + variable_pool=variable_pool, + start_at=time.perf_counter(), ) - variable_pool.add(["start", "weather"], "sunny") - variable_pool.add(["llm", "text"], "You are a helpful AI.") - - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) - - # create node factory - node_factory = DifyNodeFactory( - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, - ) - - graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") - - node = AnswerNode( + return AnswerNode( node_id=str(uuid.uuid4()), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, - config=AnswerNodeData( - title="123", + data=AnswerNodeData( + title="Answer", type="answer", - answer="Today's weather is {{#start.weather#}}\n{{#llm.text#}}\n{{img}}\nFin.", + answer=answer, ), ) - # Mock db.session.close() - db.session.close = MagicMock() - # execute node +def test_execute_answer_renders_variable_selectors() -> None: + variable_pool = _build_variable_pool() + variable_pool.add(["start", "weather"], "sunny") + variable_pool.add(["llm", "text"], "You are a helpful AI.") + node = _build_answer_node( + answer="Today's weather is {{#start.weather#}}\n{{#llm.text#}}\n{{img}}\nFin.", + variable_pool=variable_pool, + ) + result = node._run() assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED @@ -89,36 +73,11 @@ def test_execute_answer(): def test_execute_answer_renders_structured_output_object_as_json() -> None: - init_params = build_test_graph_init_params( - workflow_id="1", - graph_config={"nodes": [], "edges": []}, - tenant_id="1", - app_id="1", - user_id="1", - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - call_depth=0, - ) - - variable_pool = VariablePool( - system_variables=build_system_variables(user_id="aaa", files=[]), - user_inputs={}, - environment_variables=[], - conversation_variables=[], - ) + variable_pool = _build_variable_pool() variable_pool.add(["1777539038857", "structured_output"], {"type": "greeting"}) - - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) - - node = AnswerNode( - node_id=str(uuid.uuid4()), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, - config=AnswerNodeData( - title="123", - type="answer", - answer="{{#1777539038857.structured_output#}}", - ), + node = _build_answer_node( + answer="{{#1777539038857.structured_output#}}", + variable_pool=variable_pool, ) result = node._run() @@ -128,35 +87,9 @@ def test_execute_answer_renders_structured_output_object_as_json() -> None: def test_execute_answer_falls_back_to_plain_selector_text_when_structured_output_missing() -> None: - init_params = build_test_graph_init_params( - workflow_id="1", - graph_config={"nodes": [], "edges": []}, - tenant_id="1", - app_id="1", - user_id="1", - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - call_depth=0, - ) - - variable_pool = VariablePool( - system_variables=build_system_variables(user_id="aaa", files=[]), - user_inputs={}, - environment_variables=[], - conversation_variables=[], - ) - - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) - - node = AnswerNode( - node_id=str(uuid.uuid4()), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, - config=AnswerNodeData( - title="123", - type="answer", - answer="{{#1777539038857.structured_output#}}", - ), + node = _build_answer_node( + answer="{{#1777539038857.structured_output#}}", + variable_pool=_build_variable_pool(), ) result = node._run() diff --git a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py index a18a36a099..235d56e989 100644 --- a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py @@ -81,7 +81,7 @@ def test_datasource_node_delegates_to_manager_stream(mocker: MockerFixture): node = DatasourceNode( node_id="n", - config=DatasourceNodeData( + data=DatasourceNodeData( type="datasource", version="1", title="Datasource", diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py index be7cc073db..796fc7719d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py @@ -29,7 +29,7 @@ HTTP_REQUEST_CONFIG = HttpRequestNodeConfig( def test_executor_with_json_body_and_number_variable(): # Prepare the variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -85,7 +85,7 @@ def test_executor_with_json_body_and_number_variable(): def test_executor_with_json_body_and_object_variable(): # Prepare the variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -143,7 +143,7 @@ def test_executor_with_json_body_and_object_variable(): def test_executor_with_json_body_and_nested_object_variable(): # Prepare the variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -201,7 +201,7 @@ def test_executor_with_json_body_and_nested_object_variable(): def test_extract_selectors_from_template_with_newline(): - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) variable_pool.add(("node_id", "custom_query"), "line1\nline2") node_data = HttpRequestNodeData( title="Test JSON Body with Nested Object Variable", @@ -230,7 +230,7 @@ def test_extract_selectors_from_template_with_newline(): def test_executor_with_form_data(): # Prepare the variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -320,7 +320,7 @@ def test_init_headers(): node_data=node_data, timeout=timeout, http_request_config=HTTP_REQUEST_CONFIG, - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), http_client=ssrf_proxy, file_manager=file_manager, ) @@ -357,7 +357,7 @@ def test_init_params(): node_data=node_data, timeout=timeout, http_request_config=HTTP_REQUEST_CONFIG, - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), http_client=ssrf_proxy, file_manager=file_manager, ) @@ -390,7 +390,7 @@ def test_init_params(): def test_empty_api_key_raises_error_bearer(): """Test that empty API key raises AuthorizationConfigError for bearer auth.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -417,7 +417,7 @@ def test_empty_api_key_raises_error_bearer(): def test_empty_api_key_raises_error_basic(): """Test that empty API key raises AuthorizationConfigError for basic auth.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -444,7 +444,7 @@ def test_empty_api_key_raises_error_basic(): def test_empty_api_key_raises_error_custom(): """Test that empty API key raises AuthorizationConfigError for custom auth.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -471,7 +471,7 @@ def test_empty_api_key_raises_error_custom(): def test_whitespace_only_api_key_raises_error(): """Test that whitespace-only API key raises AuthorizationConfigError.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -498,7 +498,7 @@ def test_whitespace_only_api_key_raises_error(): def test_valid_api_key_works(): """Test that valid API key works correctly for bearer auth.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -536,7 +536,7 @@ def test_executor_with_json_body_and_unquoted_uuid_variable(): # UUID that triggers the json_repair truncation bug test_uuid = "57eeeeb1-450b-482c-81b9-4be77e95dee2" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -583,7 +583,7 @@ def test_executor_with_json_body_and_unquoted_uuid_with_newlines(): """ test_uuid = "57eeeeb1-450b-482c-81b9-4be77e95dee2" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -624,7 +624,7 @@ def test_executor_with_json_body_and_unquoted_uuid_with_newlines(): def test_executor_with_json_body_preserves_numbers_and_strings(): """Test that numbers are preserved and string values are properly quoted.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py index 2e89a2da3c..afde541beb 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py @@ -110,12 +110,15 @@ def _build_http_node( call_depth=0, ) graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="user", files=[]), + user_inputs={}, + ), start_at=time.perf_counter(), ) return HttpRequestNode( node_id="http-node", - config=HttpRequestNodeData.model_validate(node_data), + data=HttpRequestNodeData.model_validate(node_data), graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index 0659984c76..715292b85c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -149,7 +149,7 @@ def _build_human_input_node( ) return HumanInputNode( node_id=node_id, - config=typed_node_data, + data=typed_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, runtime=runtime, @@ -241,16 +241,16 @@ class TestUserAction: def test_user_action_length_boundaries(self): """Test user action id and title length boundaries.""" - action = UserAction(id="a" * 20, title="b" * 20) + action = UserAction(id="a" * 20, title="b" * 100) assert action.id == "a" * 20 - assert action.title == "b" * 20 + assert action.title == "b" * 100 @pytest.mark.parametrize( ("field_name", "value"), [ ("id", "a" * 21), - ("title", "b" * 21), + ("title", "b" * 101), ], ) def test_user_action_length_limits(self, field_name: str, value: str): @@ -427,7 +427,7 @@ class TestHumanInputNodeVariableResolution: """Tests for resolving variable-based defaults in HumanInputNode.""" def test_resolves_variable_defaults(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -504,7 +504,7 @@ class TestHumanInputNodeVariableResolution: assert params.resolved_default_values == expected_values def test_debugger_falls_back_to_recipient_token_when_webapp_disabled(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -565,7 +565,7 @@ class TestHumanInputNodeVariableResolution: assert not hasattr(pause_event.reason, "form_token") def test_webapp_runtime_keeps_form_visible_in_ui_when_webapp_delivery_is_enabled(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -631,7 +631,7 @@ class TestHumanInputNodeVariableResolution: assert params.display_in_ui is True def test_debugger_debug_mode_overrides_email_recipients(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user-123", app_id="app", @@ -748,7 +748,7 @@ class TestHumanInputNodeRenderedContent: """Tests for rendering submitted content.""" def test_replaces_outputs_placeholders_after_submission(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index 4a9438b14f..741b104393 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -40,7 +40,7 @@ def _create_human_input_node( ) return HumanInputNode( node_id=config["id"], - config=node_data, + data=node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, form_repository=repo, @@ -51,7 +51,11 @@ def _create_human_input_node( def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#}}") -> HumanInputNode: system_variables = default_system_variables() graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=system_variables, user_inputs={}, environment_variables=[]), + variable_pool=VariablePool.from_bootstrap( + system_variables=system_variables, + user_inputs={}, + environment_variables=[], + ), start_at=0.0, ) graph_init_params = GraphInitParams( @@ -114,7 +118,11 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name# def _build_timeout_node() -> HumanInputNode: system_variables = default_system_variables() graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=system_variables, user_inputs={}, environment_variables=[]), + variable_pool=VariablePool.from_bootstrap( + system_variables=system_variables, + user_inputs={}, + environment_variables=[], + ), start_at=0.0, ) graph_init_params = GraphInitParams( diff --git a/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py b/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py index 8ffce39cd6..18ed7a0b1d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py +++ b/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py @@ -32,7 +32,7 @@ class _MissingGraphBuilder: def _build_runtime_state() -> GraphRuntimeState: return GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables(), user_inputs={}), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables(), user_inputs={}), start_at=0.0, ) @@ -46,7 +46,7 @@ def _build_iteration_node( init_params = build_test_graph_init_params(graph_config=graph_config) return IterationNode( node_id="iteration-node", - config=IterationNodeData( + data=IterationNodeData( type="iteration", title="Iteration", iterator_selector=["start", "items"], diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index 89433b34e6..0d760a2db7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -41,7 +41,7 @@ def mock_graph_init_params(): @pytest.fixture def mock_graph_runtime_state(): """Create mock GraphRuntimeState.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id=str(uuid.uuid4()), files=[]), user_inputs={}, environment_variables=[], @@ -103,7 +103,7 @@ def _build_node( ) -> KnowledgeIndexNode: return KnowledgeIndexNode( node_id=node_id, - config=( + data=( node_data if isinstance(node_data, KnowledgeIndexNodeData) else KnowledgeIndexNodeData.model_validate(node_data) diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py index d77a2ce363..3c821e75ba 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py @@ -47,7 +47,7 @@ def mock_graph_init_params(): @pytest.fixture def mock_graph_runtime_state(): """Create mock GraphRuntimeState.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id=str(uuid.uuid4()), files=[]), user_inputs={}, environment_variables=[], @@ -118,7 +118,7 @@ class TestKnowledgeRetrievalNode: # Act node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -147,7 +147,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -206,7 +206,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -250,7 +250,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -286,7 +286,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -321,7 +321,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -362,7 +362,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -401,7 +401,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -482,7 +482,7 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -519,7 +519,7 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -574,7 +574,7 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -622,7 +622,7 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -683,7 +683,7 @@ class TestFetchDatasetRetriever: config = {"id": node_id, "data": node_data.model_dump()} node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py b/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py index 388654f279..20b94d5d50 100644 --- a/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py @@ -16,10 +16,10 @@ class TestListOperatorNode: """Comprehensive tests for ListOperatorNode.""" @staticmethod - def _build_node(*, config, graph_init_params, graph_runtime_state): + def _build_node(*, data, graph_init_params, graph_runtime_state): return ListOperatorNode( node_id="test", - config=config if isinstance(config, ListOperatorNodeData) else ListOperatorNodeData.model_validate(config), + data=data if isinstance(data, ListOperatorNodeData) else ListOperatorNodeData.model_validate(data), graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) @@ -65,7 +65,7 @@ class TestListOperatorNode: def _create_node(config, mock_variable): mock_graph_runtime_state.variable_pool.get.return_value = mock_variable return self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -83,7 +83,7 @@ class TestListOperatorNode: } node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -127,7 +127,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -153,7 +153,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -177,7 +177,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -201,7 +201,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -228,7 +228,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -255,7 +255,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -282,7 +282,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -312,7 +312,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -335,7 +335,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = None node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -359,7 +359,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -384,7 +384,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -408,7 +408,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -432,7 +432,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -456,7 +456,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -483,7 +483,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index c09f2d3fb6..fb50723402 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -15,7 +15,7 @@ from core.app.llm.model_access import ( ) from core.entities.provider_configuration import ProviderConfiguration, ProviderModelBundle from core.entities.provider_entities import CustomConfiguration, SystemConfiguration -from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime +from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from core.prompt.entities.advanced_prompt_entities import MemoryConfig from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams @@ -187,7 +187,7 @@ def graph_init_params() -> GraphInitParams: @pytest.fixture def graph_runtime_state() -> GraphRuntimeState: - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -208,7 +208,7 @@ def llm_node( http_client = mock.MagicMock() node = LLMNode( node_id="1", - config=llm_node_data, + data=llm_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, credentials_provider=mock_credentials_provider, @@ -241,9 +241,10 @@ def model_config(monkeypatch: pytest.MonkeyPatch): ) # Create actual provider and model type instances - model_provider_factory = ModelProviderFactory(model_runtime=create_plugin_model_runtime(tenant_id="test")) + model_assembly = create_plugin_model_assembly(tenant_id="test") + model_provider_factory = model_assembly.model_provider_factory provider_instance = model_provider_factory.get_model_provider("openai") - model_type_instance = model_provider_factory.get_model_type_instance("openai", ModelType.LLM) + model_type_instance = model_assembly.create_model_type_instance(provider="openai", model_type=ModelType.LLM) # Create a ProviderModelBundle provider_model_bundle = ProviderModelBundle( @@ -1173,7 +1174,7 @@ def llm_node_for_multimodal(llm_node_data, graph_init_params, graph_runtime_stat http_client = mock.MagicMock() node = LLMNode( node_id="1", - config=llm_node_data, + data=llm_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, credentials_provider=mock_credentials_provider, diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py index 892f6cc586..dd57dde1fe 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py @@ -28,7 +28,7 @@ def _build_template_transform_node( ) return TemplateTransformNode( node_id=node_id, - config=typed_node_data, + data=typed_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, **kwargs, diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py index a846efbb43..c25ac7da0f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py @@ -39,7 +39,7 @@ def mock_graph_runtime_state(): def test_node_uses_default_max_output_length_when_not_overridden(graph_init_params, mock_graph_runtime_state): node = TemplateTransformNode( node_id="test_node", - config=TemplateTransformNodeData( + data=TemplateTransformNodeData( title="Template Transform", type="template-transform", variables=[], diff --git a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py index 364408ead6..a05151f79b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py @@ -35,7 +35,10 @@ def _build_context(graph_config: Mapping[str, object]) -> tuple[GraphInitParams, invoke_from="debugger", ) runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="user", files=[]), + user_inputs={}, + ), start_at=0.0, ) return init_params, runtime_state @@ -62,7 +65,7 @@ def test_node_hydrates_data_during_initialization(): node = _SampleNode( node_id="node-1", - config=_build_node_data(), + data=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) @@ -82,13 +85,16 @@ def test_node_accepts_invoke_from_enum(): invoke_from=InvokeFrom.DEBUGGER, ) runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="user", files=[]), + user_inputs={}, + ), start_at=0.0, ) node = _SampleNode( node_id="node-1", - config=_build_node_data(), + data=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) @@ -140,7 +146,7 @@ def test_node_hydration_preserves_compatibility_extra_fields(): node = _SampleNode( node_id="node-1", - config=node_config["data"], + data=node_config["data"], graph_init_params=init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py index dd75b32593..4c67f3fb02 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py @@ -49,7 +49,7 @@ def document_extractor_node(graph_init_params): http_client = Mock() node = DocumentExtractorNode( node_id="test_node_id", - config=node_data, + data=node_data, graph_init_params=graph_init_params, graph_runtime_state=Mock(), http_client=http_client, @@ -186,12 +186,13 @@ def test_run_extract_text( monkeypatch.setattr("graphon.file.file_manager.download", mock_download) + dispatch_mock = None if mime_type == "application/pdf": - mock_pdf_extract = Mock(return_value=expected_text[0]) - monkeypatch.setattr("graphon.nodes.document_extractor.node._extract_text_from_pdf", mock_pdf_extract) + dispatch_mock = Mock(return_value=expected_text[0]) + monkeypatch.setattr("graphon.nodes.document_extractor.node._extract_text_by_file_extension", dispatch_mock) elif mime_type.startswith("application/vnd.openxmlformats"): - mock_docx_extract = Mock(return_value=expected_text[0]) - monkeypatch.setattr("graphon.nodes.document_extractor.node._extract_text_from_docx", mock_docx_extract) + dispatch_mock = Mock(return_value=expected_text[0]) + monkeypatch.setattr("graphon.nodes.document_extractor.node._extract_text_by_mime_type", dispatch_mock) result = document_extractor_node._run() @@ -200,6 +201,19 @@ def test_run_extract_text( assert result.outputs is not None assert result.outputs["text"] == ArrayStringSegment(value=expected_text) + if mime_type == "application/pdf": + dispatch_mock.assert_called_once_with( + file_content=file_content, + file_extension=extension, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + elif mime_type.startswith("application/vnd.openxmlformats"): + dispatch_mock.assert_called_once_with( + file_content=file_content, + mime_type=mime_type, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + if transfer_method == FileTransferMethod.REMOTE_URL: document_extractor_node._http_client.get.assert_called_once_with("https://example.com/file.txt") elif transfer_method == FileTransferMethod.LOCAL_FILE: @@ -439,24 +453,42 @@ def test_extract_text_from_file_routes_excel_inputs(document_extractor_node, ext file.extension = extension file.mime_type = mime_type - with ( - patch( - "graphon.nodes.document_extractor.node._download_file_content", - return_value=b"excel", - ), - patch( - "graphon.nodes.document_extractor.node._extract_text_from_excel", - return_value="excel text", - ) as mock_extract, + with patch( + "graphon.nodes.document_extractor.node._download_file_content", + return_value=b"excel", ): - result = _extract_text_from_file( - document_extractor_node.http_client, - file, - unstructured_api_config=document_extractor_node._unstructured_api_config, - ) + if extension: + with patch( + "graphon.nodes.document_extractor.node._extract_text_by_file_extension", + return_value="excel text", + ) as mock_extract: + result = _extract_text_from_file( + document_extractor_node.http_client, + file, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + mock_extract.assert_called_once_with( + file_content=b"excel", + file_extension=extension, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + else: + with patch( + "graphon.nodes.document_extractor.node._extract_text_by_mime_type", + return_value="excel text", + ) as mock_extract: + result = _extract_text_from_file( + document_extractor_node.http_client, + file, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + mock_extract.assert_called_once_with( + file_content=b"excel", + mime_type=mime_type, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) assert result == "excel text" - mock_extract.assert_called_once_with(b"excel") def test_extract_text_from_file_rejects_missing_extension_and_mime_type(document_extractor_node): diff --git a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py index aa9a1360b0..5965645c4f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py @@ -29,7 +29,7 @@ def _build_if_else_node( node_id=str(uuid.uuid4()), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, - config=node_data if isinstance(node_data, IfElseNodeData) else IfElseNodeData.model_validate(node_data), + data=node_data if isinstance(node_data, IfElseNodeData) else IfElseNodeData.model_validate(node_data), ) @@ -48,7 +48,10 @@ def test_execute_if_else_result_true(): ) # construct variable pool - pool = VariablePool(system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}) + pool = VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="aaa", files=[]), + user_inputs={}, + ) pool.add(["start", "array_contains"], ["ab", "def"]) pool.add(["start", "array_not_contains"], ["ac", "def"]) pool.add(["start", "contains"], "cabcde") @@ -148,7 +151,7 @@ def test_execute_if_else_result_false(): ) # construct variable pool - pool = VariablePool( + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -305,7 +308,7 @@ def test_execute_if_else_boolean_conditions(condition: Condition): ) # construct variable pool with boolean values - pool = VariablePool( + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) @@ -359,7 +362,7 @@ def test_execute_if_else_boolean_false_conditions(): ) # construct variable pool with boolean values - pool = VariablePool( + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) @@ -424,7 +427,7 @@ def test_execute_if_else_boolean_cases_structure(): ) # construct variable pool with boolean values - pool = VariablePool( + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py index 465a4c0ff4..1b4cecc757 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py @@ -22,7 +22,7 @@ from graphon.variables import ArrayFileSegment def _build_list_operator_node(node_data: ListOperatorNodeData, graph_init_params) -> ListOperatorNode: return ListOperatorNode( node_id="test_node_id", - config=node_data, + data=node_data, graph_init_params=graph_init_params, graph_runtime_state=MagicMock(), ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py index 5655f80737..f890f79511 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py @@ -31,7 +31,7 @@ def make_start_node(user_inputs, variables): return StartNode( node_id="start", - config=node_data, + data=node_data, graph_init_params=build_test_graph_init_params( workflow_id="wf", graph_config={}, @@ -260,7 +260,7 @@ def test_start_node_outputs_full_variable_pool_snapshot(): graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) node = StartNode( node_id="start", - config=node_data, + data=node_data, graph_init_params=build_test_graph_init_params( workflow_id="wf", graph_config={}, diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py index 284af68319..4aa5803ac7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -99,7 +99,7 @@ def tool_node(monkeypatch) -> ToolNode: call_depth=0, ) - variable_pool = VariablePool(system_variables=build_system_variables(user_id="user-id")) + variable_pool = VariablePool.from_bootstrap(system_variables=build_system_variables(user_id="user-id")) graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) config = graph_config["nodes"][0] @@ -110,7 +110,7 @@ def tool_node(monkeypatch) -> ToolNode: node = ToolNode( node_id="node-instance", - config=ToolNodeData.model_validate(config["data"]), + data=ToolNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, tool_file_manager_factory=tool_file_manager_factory, diff --git a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py index e3b5e3b591..c5ac8d2ce2 100644 --- a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py @@ -44,7 +44,7 @@ def test_trigger_event_node_run_populates_trigger_info_metadata() -> None: init_params, runtime_state = _build_context(graph_config={}) node = TriggerEventNode( node_id="node-1", - config=_build_node_data(), + data=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py index 07d03bec05..fccb5ab1c3 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py @@ -52,7 +52,7 @@ def create_webhook_node( node = TriggerWebhookNode( node_id="webhook-node-1", - config=webhook_data, + data=webhook_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py index b839490d3c..c5ae542d8b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py @@ -44,7 +44,7 @@ def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) ) node = TriggerWebhookNode( node_id="1", - config=webhook_data, + data=webhook_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index e93a7c7ccd..d6159e84d4 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from types import SimpleNamespace from unittest.mock import MagicMock, patch, sentinel @@ -11,19 +12,20 @@ from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes, NodeType from graphon.nodes.code.entities import CodeLanguage from graphon.nodes.llm.entities import LLMNodeData +from graphon.nodes.llm.node import LLMNode from graphon.variables.segments import StringSegment -def _assert_typed_node_config(config, *, node_id: str, node_type: NodeType, version: str = "1") -> None: +def _assert_constructor_node_data(data, *, node_id: str, node_type: NodeType, version: str = "1") -> None: _ = node_id - if isinstance(config, BaseNodeData): - assert config.type == node_type - assert config.version == version + if isinstance(data, BaseNodeData): + assert data.type == node_type + assert data.version == version return - assert isinstance(config, dict) - assert config["type"] == node_type - assert config["version"] == version + assert isinstance(data, Mapping) + assert data["type"] == node_type + assert data.get("version", "1") == version def _node_constructor(*, return_value): @@ -470,7 +472,7 @@ class TestDifyNodeFactoryCreateNode: matched_node_class.assert_called_once() kwargs = matched_node_class.call_args.kwargs assert kwargs["node_id"] == "node-id" - _assert_typed_node_config(kwargs["config"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") + _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") assert kwargs["graph_init_params"] is sentinel.graph_init_params assert kwargs["graph_runtime_state"] is factory.graph_runtime_state latest_node_class.assert_not_called() @@ -492,7 +494,7 @@ class TestDifyNodeFactoryCreateNode: latest_node_class.assert_called_once() kwargs = latest_node_class.call_args.kwargs assert kwargs["node_id"] == "node-id" - _assert_typed_node_config(kwargs["config"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") + _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") assert kwargs["graph_init_params"] is sentinel.graph_init_params assert kwargs["graph_runtime_state"] is factory.graph_runtime_state @@ -530,7 +532,7 @@ class TestDifyNodeFactoryCreateNode: assert result is created_node kwargs = constructor.call_args.kwargs assert kwargs["node_id"] == "node-id" - _assert_typed_node_config(kwargs["config"], node_id="node-id", node_type=node_type) + _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=node_type) assert kwargs["graph_init_params"] is sentinel.graph_init_params assert kwargs["graph_runtime_state"] is factory.graph_runtime_state @@ -599,11 +601,12 @@ class TestDifyNodeFactoryCreateNode: prepared_llm.assert_called_once_with(sentinel.model_instance) assert kwargs["model_instance"] is wrapped_model_instance - def test_create_node_passes_alias_preserving_llm_config_to_constructor( - self, monkeypatch: pytest.MonkeyPatch, factory - ): + def test_create_node_passes_alias_preserving_llm_data_to_constructor(self, monkeypatch, factory): created_node = object() constructor = _node_constructor(return_value=created_node) + constructor.validate_node_data.side_effect = lambda node_data: LLMNodeData.model_validate( + node_data.model_dump(mode="python") if isinstance(node_data, BaseNodeData) else node_data + ) monkeypatch.setattr(factory, "_resolve_node_class", MagicMock(return_value=constructor)) monkeypatch.setattr(factory, "_build_llm_compatible_node_init_kwargs", MagicMock(return_value={})) @@ -629,10 +632,56 @@ class TestDifyNodeFactoryCreateNode: factory.create_node(node_config) - config = constructor.call_args.kwargs["config"] - assert isinstance(config, dict) - assert config["structured_output_enabled"] is True - assert "structured_output_switch_on" not in config + data = constructor.call_args.kwargs["data"] + assert isinstance(data, Mapping) + assert data["structured_output_enabled"] is True + assert "structured_output_switch_on" not in data + assert LLMNodeData.model_validate(data).structured_output_enabled is True + + def test_create_node_preserves_structured_output_switch_after_graphon_constructor(self, monkeypatch, factory): + factory.graph_init_params = SimpleNamespace( + workflow_id="workflow-id", + graph_config={}, + run_context={}, + call_depth=0, + ) + monkeypatch.setattr(factory, "_resolve_node_class", MagicMock(return_value=LLMNode)) + monkeypatch.setattr( + factory, + "_build_llm_compatible_node_init_kwargs", + MagicMock( + return_value={ + "model_instance": sentinel.model_instance, + "llm_file_saver": sentinel.llm_file_saver, + "prompt_message_serializer": sentinel.prompt_message_serializer, + } + ), + ) + + node_config = { + "id": "llm-node-id", + "data": { + "type": BuiltinNodeTypes.LLM, + "title": "LLM", + "model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}}, + "prompt_template": [{"role": "system", "text": "x"}], + "context": {"enabled": False, "variable_selector": []}, + "vision": {"enabled": False}, + "structured_output_enabled": True, + "structured_output": { + "schema": { + "type": "object", + "properties": {"type": {"type": "string"}}, + "required": ["type"], + } + }, + }, + } + + node = factory.create_node(node_config) + + assert node.node_data.structured_output_switch_on is True + assert node.node_data.structured_output_enabled is True @pytest.mark.parametrize( ("node_type", "constructor_name", "expected_extra_kwargs"), @@ -711,7 +760,7 @@ class TestDifyNodeFactoryCreateNode: constructor_kwargs = constructor.call_args.kwargs assert constructor_kwargs["node_id"] == "node-id" - _assert_typed_node_config(constructor_kwargs["config"], node_id="node-id", node_type=node_type) + _assert_constructor_node_data(constructor_kwargs["data"], node_id="node-id", node_type=node_type) assert constructor_kwargs["graph_init_params"] is sentinel.graph_init_params assert constructor_kwargs["graph_runtime_state"] is factory.graph_runtime_state assert constructor_kwargs["credentials_provider"] is sentinel.credentials_provider diff --git a/api/tests/unit_tests/core/workflow/test_variable_pool.py b/api/tests/unit_tests/core/workflow/test_variable_pool.py index 9dab38ed8e..0017cd8d3f 100644 --- a/api/tests/unit_tests/core/workflow/test_variable_pool.py +++ b/api/tests/unit_tests/core/workflow/test_variable_pool.py @@ -109,8 +109,8 @@ class TestVariablePool: assert pool.get([ENVIRONMENT_VARIABLE_NODE_ID, "env_var_1"]) is not None assert pool.get([CONVERSATION_VARIABLE_NODE_ID, "conv_var_1"]) is not None - def test_constructor_loads_legacy_bootstrap_kwargs(self): - pool = VariablePool( + def test_from_bootstrap_loads_legacy_bootstrap_kwargs(self): + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="test_user_id"), environment_variables=[StringVariable(name="env_var", value="env-value")], conversation_variables=[StringVariable(name="conv_var", value="conv-value")], diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry.py b/api/tests/unit_tests/core/workflow/test_workflow_entry.py index 2e9e3468fd..661882f013 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry.py @@ -55,7 +55,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_to_variable_pool_with_system_variables(self): """Test mapping system variables from user inputs to variable pool.""" # Initialize variable pool with system variables - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="test_user_id", app_id="test_app_id", @@ -128,7 +128,7 @@ class TestWorkflowEntry: return NodeConfigDictAdapter.validate_python(node_config) workflow = StubWorkflow() - variable_pool = VariablePool(system_variables=default_system_variables(), user_inputs={}) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables(), user_inputs={}) expected_limits = CodeNodeLimits( max_string_length=dify_config.CODE_MAX_STRING_LENGTH, max_number=dify_config.CODE_MAX_NUMBER, @@ -157,7 +157,7 @@ class TestWorkflowEntry: """Test mapping environment variables from user inputs to variable pool.""" # Initialize variable pool with environment variables env_var = StringVariable(name="API_KEY", value="existing_key") - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), environment_variables=[env_var], user_inputs={}, @@ -198,7 +198,7 @@ class TestWorkflowEntry: """Test mapping conversation variables from user inputs to variable pool.""" # Initialize variable pool with conversation variables conv_var = StringVariable(name="last_message", value="Hello") - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), conversation_variables=[conv_var], user_inputs={}, @@ -239,7 +239,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_to_variable_pool_with_regular_variables(self): """Test mapping regular node variables from user inputs to variable pool.""" # Initialize empty variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -281,7 +281,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_with_file_handling(self): """Test mapping file inputs from user inputs to variable pool.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -340,7 +340,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_missing_variable_error(self): """Test that mapping raises error when required variable is missing.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -366,7 +366,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_with_alternative_key_format(self): """Test mapping with alternative key format (without node prefix).""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -396,7 +396,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_with_complex_selectors(self): """Test mapping with complex node variable keys.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -432,7 +432,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_invalid_node_variable(self): """Test that mapping handles invalid node variable format.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -463,7 +463,7 @@ class TestWorkflowEntry: env_var = StringVariable(name="API_KEY", value="existing_key") conv_var = StringVariable(name="session_id", value="session123") - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="test_user", app_id="test_app", diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index 3978cbb1a0..a57cdd1337 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -7,7 +7,6 @@ import pytest from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.model_manager import ModelInstance from core.workflow import workflow_entry from core.workflow.system_variables import default_system_variables from graphon.entities.base_node_data import BaseNodeData @@ -16,10 +15,12 @@ from graphon.errors import WorkflowNodeRunFailedError from graphon.file import File, FileTransferMethod, FileType from graphon.graph import Graph from graphon.graph_events import GraphRunFailedEvent -from graphon.model_runtime.entities.llm_entities import LLMUsage +from graphon.model_runtime.entities.llm_entities import LLMMode, LLMUsage from graphon.node_events import NodeRunResult from graphon.nodes import BuiltinNodeTypes from graphon.nodes.base.node import Node +from graphon.nodes.llm.entities import ContextConfig, LLMNodeData, ModelConfig +from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData from graphon.runtime import ChildGraphNotFoundError, VariablePool from graphon.variables.variables import StringVariable from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool @@ -29,9 +30,30 @@ def _build_typed_node_config(node_type: NodeType): return {"id": "node-id", "data": BaseNodeData(type=node_type)} -def _build_wrapped_model_instance() -> tuple[SimpleNamespace, ModelInstance]: - raw_model_instance = ModelInstance.__new__(ModelInstance) - return SimpleNamespace(_model_instance=raw_model_instance), raw_model_instance +def _build_model_config(*, provider: str = "openai", model_name: str = "gpt-4o") -> ModelConfig: + return ModelConfig(provider=provider, name=model_name, mode=LLMMode.CHAT) + + +def _build_llm_node_data(*, provider: str = "openai", model_name: str = "gpt-4o") -> LLMNodeData: + return LLMNodeData( + type=BuiltinNodeTypes.LLM, + title="Child Model", + model=_build_model_config(provider=provider, model_name=model_name), + prompt_template=[], + context=ContextConfig(enabled=False), + ) + + +def _build_question_classifier_node_data( + *, provider: str = "openai", model_name: str = "gpt-4o" +) -> QuestionClassifierNodeData: + return QuestionClassifierNodeData( + type=BuiltinNodeTypes.QUESTION_CLASSIFIER, + title="Child Model", + query_variable_selector=["sys", "query"], + model=_build_model_config(provider=provider, model_name=model_name), + classes=[], + ) class _FakeModelNodeMixin: @@ -40,22 +62,26 @@ class _FakeModelNodeMixin: return "1" def post_init(self) -> None: - self.model_instance, self.raw_model_instance = _build_wrapped_model_instance() + self.model_instance = SimpleNamespace(provider="stale-provider", model_name="stale-model") self.usage_snapshot = LLMUsage.empty_usage() self.usage_snapshot.total_tokens = 1 def _run(self) -> NodeRunResult: return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs={ + "model_provider": self.node_data.model.provider, + "model_name": self.node_data.model.name, + }, llm_usage=self.usage_snapshot, ) -class _FakeLLMNode(_FakeModelNodeMixin, Node[BaseNodeData]): +class _FakeLLMNode(_FakeModelNodeMixin, Node[LLMNodeData]): node_type = BuiltinNodeTypes.LLM -class _FakeQuestionClassifierNode(_FakeModelNodeMixin, Node[BaseNodeData]): +class _FakeQuestionClassifierNode(_FakeModelNodeMixin, Node[QuestionClassifierNodeData]): node_type = BuiltinNodeTypes.QUESTION_CLASSIFIER @@ -75,7 +101,7 @@ class TestWorkflowChildEngineBuilder: assert result is expected def test_build_child_engine_raises_when_root_node_is_missing(self): - builder = workflow_entry._WorkflowChildEngineBuilder() + builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id") graph_init_params = SimpleNamespace(graph_config={"nodes": []}) parent_graph_runtime_state = SimpleNamespace( execution_context=sentinel.execution_context, @@ -92,7 +118,7 @@ class TestWorkflowChildEngineBuilder: ) def test_build_child_engine_constructs_graph_engine_with_quota_layer_only(self): - builder = workflow_entry._WorkflowChildEngineBuilder() + builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id") graph_init_params = SimpleNamespace(graph_config={"nodes": [{"id": "root"}]}) parent_graph_runtime_state = SimpleNamespace( execution_context=sentinel.execution_context, @@ -114,7 +140,7 @@ class TestWorkflowChildEngineBuilder: patch.object(workflow_entry, "GraphEngine", return_value=child_engine) as graph_engine_cls, patch.object(workflow_entry, "GraphEngineConfig", return_value=sentinel.graph_engine_config), patch.object(workflow_entry, "InMemoryChannel", return_value=sentinel.command_channel), - patch.object(workflow_entry, "LLMQuotaLayer", return_value=sentinel.llm_quota_layer), + patch.object(workflow_entry, "LLMQuotaLayer", return_value=sentinel.llm_quota_layer) as llm_quota_layer_cls, ): result = builder.build_child_engine( workflow_id="workflow-id", @@ -147,11 +173,12 @@ class TestWorkflowChildEngineBuilder: config=sentinel.graph_engine_config, child_engine_builder=builder, ) + llm_quota_layer_cls.assert_called_once_with(tenant_id="tenant-id") assert child_engine.layer.call_args_list == [((sentinel.llm_quota_layer,), {})] @pytest.mark.parametrize("node_cls", [_FakeLLMNode, _FakeQuestionClassifierNode]) def test_build_child_engine_runs_llm_quota_layer_for_child_model_nodes(self, node_cls): - builder = workflow_entry._WorkflowChildEngineBuilder() + builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id") graph_init_params = build_test_graph_init_params( graph_config={"nodes": [{"id": "root"}], "edges": []}, ) @@ -163,12 +190,10 @@ class TestWorkflowChildEngineBuilder: def build_graph(*, graph_config, node_factory, root_node_id): _ = graph_config + node_data = _build_llm_node_data() if node_cls is _FakeLLMNode else _build_question_classifier_node_data() node = node_cls( node_id=root_node_id, - config=BaseNodeData( - type=node_cls.node_type, - title="Child Model", - ), + data=node_data, graph_init_params=node_factory.graph_init_params, graph_runtime_state=node_factory.graph_runtime_state, ) @@ -191,8 +216,8 @@ class TestWorkflowChildEngineBuilder: ), ), patch.object(workflow_entry.Graph, "init", side_effect=build_graph), - patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available") as ensure_quota, - patch("core.app.workflow.layers.llm_quota.deduct_llm_quota") as deduct_quota, + patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model") as ensure_quota, + patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model") as deduct_quota, ): child_engine = builder.build_child_engine( workflow_id="workflow-id", @@ -203,10 +228,15 @@ class TestWorkflowChildEngineBuilder: list(child_engine.run()) node = created_node["node"] - ensure_quota.assert_called_once_with(model_instance=node.raw_model_instance) + ensure_quota.assert_called_once_with( + tenant_id="tenant-id", + provider=node.node_data.model.provider, + model=node.node_data.model.name, + ) deduct_quota.assert_called_once_with( - tenant_id="tenant", - model_instance=node.raw_model_instance, + tenant_id="tenant-id", + provider=node.node_data.model.provider, + model=node.node_data.model.name, usage=node.usage_snapshot, ) @@ -252,7 +282,7 @@ class TestWorkflowEntryInit: "ExecutionLimitsLayer", return_value=execution_limits_layer, ) as execution_limits_layer_cls, - patch.object(workflow_entry, "LLMQuotaLayer", return_value=llm_quota_layer), + patch.object(workflow_entry, "LLMQuotaLayer", return_value=llm_quota_layer) as llm_quota_layer_cls, patch.object(workflow_entry, "ObservabilityLayer", return_value=observability_layer), ): entry = workflow_entry.WorkflowEntry( @@ -291,6 +321,7 @@ class TestWorkflowEntryInit: max_steps=workflow_entry.dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=workflow_entry.dify_config.WORKFLOW_MAX_EXECUTION_TIME, ) + llm_quota_layer_cls.assert_called_once_with(tenant_id="tenant-id") assert graph_engine.layer.call_args_list == [ ((debug_layer,), {}), ((execution_limits_layer,), {}), @@ -334,7 +365,7 @@ class TestWorkflowEntrySingleStepRun: def extract_variable_selector_to_variable_mapping(**_kwargs): return {} - variable_pool = VariablePool(system_variables=default_system_variables(), user_inputs={}) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables(), user_inputs={}) variable_loader = MagicMock() variable_loader.load_variables.return_value = [ StringVariable( diff --git a/api/tests/unit_tests/events/test_update_provider_when_message_created.py b/api/tests/unit_tests/events/test_update_provider_when_message_created.py new file mode 100644 index 0000000000..9cb8ca7854 --- /dev/null +++ b/api/tests/unit_tests/events/test_update_provider_when_message_created.py @@ -0,0 +1,130 @@ +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from sqlalchemy import create_engine, select + +from core.app.entities.app_invoke_entities import ChatAppGenerateEntity +from core.entities.provider_entities import ProviderQuotaType, QuotaUnit +from events.event_handlers import update_provider_when_message_created +from models import TenantCreditPool +from models.provider import ProviderType + + +def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_insufficient() -> None: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + tenant_id = str(uuid4()) + pool_id = str(uuid4()) + with engine.begin() as connection: + connection.execute( + TenantCreditPool.__table__.insert(), + { + "id": pool_id, + "tenant_id": tenant_id, + "pool_type": ProviderQuotaType.TRIAL, + "quota_limit": 10, + "quota_used": 9, + }, + ) + + system_configuration = SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=10, + ) + ], + ) + application_generate_entity = ChatAppGenerateEntity.model_construct( + app_config=SimpleNamespace(tenant_id=tenant_id), + model_conf=SimpleNamespace( + provider="openai", + model="gpt-4o", + provider_model_bundle=SimpleNamespace( + configuration=SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=system_configuration, + ) + ), + ), + ) + message = SimpleNamespace(message_tokens=2, answer_tokens=1) + + with ( + patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)), + patch.object(update_provider_when_message_created, "_execute_provider_updates"), + ): + update_provider_when_message_created.handle( + sender=message, + application_generate_entity=application_generate_entity, + ) + + with engine.connect() as connection: + quota_used = connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id)) + + assert quota_used == 10 + + +def test_message_created_paid_credit_accounting_uses_paid_pool() -> None: + tenant_id = str(uuid4()) + system_configuration = SimpleNamespace( + current_quota_type=ProviderQuotaType.PAID, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.PAID, + quota_unit=QuotaUnit.TOKENS, + quota_limit=10, + ) + ], + ) + application_generate_entity = ChatAppGenerateEntity.model_construct( + app_config=SimpleNamespace(tenant_id=tenant_id), + model_conf=SimpleNamespace( + provider="openai", + model="gpt-4o", + provider_model_bundle=SimpleNamespace( + configuration=SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=system_configuration, + ) + ), + ), + ) + message = SimpleNamespace(message_tokens=2, answer_tokens=1) + + with ( + patch.object(update_provider_when_message_created, "_deduct_credit_pool_quota_capped") as mock_deduct, + patch.object(update_provider_when_message_created, "_execute_provider_updates"), + ): + update_provider_when_message_created.handle( + sender=message, + application_generate_entity=application_generate_entity, + ) + + mock_deduct.assert_called_once_with( + tenant_id=tenant_id, + credits_required=3, + pool_type="paid", + ) + + +def test_capped_credit_pool_accounting_skips_exhaustion_warning_when_full_amount_is_deducted(caplog) -> None: + with patch( + "services.credit_pool_service.CreditPoolService.deduct_credits_capped", + return_value=3, + ) as mock_deduct: + update_provider_when_message_created._deduct_credit_pool_quota_capped( + tenant_id="tenant-id", + credits_required=3, + pool_type="trial", + ) + + mock_deduct.assert_called_once_with( + tenant_id="tenant-id", + credits_required=3, + pool_type="trial", + ) + assert "Credit pool exhausted during message-created accounting" not in caplog.text diff --git a/api/tests/unit_tests/services/test_credit_pool_service.py b/api/tests/unit_tests/services/test_credit_pool_service.py new file mode 100644 index 0000000000..e77ef894e7 --- /dev/null +++ b/api/tests/unit_tests/services/test_credit_pool_service.py @@ -0,0 +1,158 @@ +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.engine import Engine + +from core.errors.error import QuotaExceededError +from models import TenantCreditPool +from models.enums import ProviderQuotaType +from services.credit_pool_service import CreditPoolService + + +def _create_engine_with_pool(*, quota_limit: int, quota_used: int) -> tuple[Engine, str, str]: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + tenant_id = str(uuid4()) + pool_id = str(uuid4()) + with engine.begin() as connection: + connection.execute( + TenantCreditPool.__table__.insert(), + { + "id": pool_id, + "tenant_id": tenant_id, + "pool_type": ProviderQuotaType.TRIAL, + "quota_limit": quota_limit, + "quota_used": quota_used, + }, + ) + return engine, tenant_id, pool_id + + +def _get_quota_used(*, engine: Engine, pool_id: str) -> int | None: + with engine.connect() as connection: + return connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id)) + + +def test_check_and_deduct_credits_deducts_exact_amount_when_sufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)): + deducted_credits = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3) + + assert deducted_credits == 3 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 5 + + +def test_check_and_deduct_credits_returns_zero_for_non_positive_request() -> None: + assert CreditPoolService.check_and_deduct_credits(tenant_id=str(uuid4()), credits_required=0) == 0 + + +def test_check_and_deduct_credits_raises_when_pool_is_missing() -> None: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + + with ( + patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)), + pytest.raises(QuotaExceededError, match="Credit pool not found"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=str(uuid4()), credits_required=1) + + +def test_check_and_deduct_credits_raises_when_pool_is_empty() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10) + + with ( + patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)), + pytest.raises(QuotaExceededError, match="No credits remaining"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_check_and_deduct_credits_raises_without_partial_deduction_when_insufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9) + + with ( + patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)), + pytest.raises(QuotaExceededError, match="Insufficient credits remaining"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 9 + + +def test_check_and_deduct_credits_wraps_unexpected_deduction_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")), + pytest.raises(QuotaExceededError, match="Failed to deduct credits"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 + + +def test_deduct_credits_capped_returns_zero_for_non_positive_request() -> None: + assert CreditPoolService.deduct_credits_capped(tenant_id=str(uuid4()), credits_required=0) == 0 + + +def test_deduct_credits_capped_returns_zero_when_pool_is_missing() -> None: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + + with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=str(uuid4()), credits_required=1) + + assert deducted_credits == 0 + + +def test_deduct_credits_capped_returns_zero_when_pool_is_empty() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10) + + with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert deducted_credits == 0 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_deduct_credits_capped_deducts_only_remaining_balance_when_insufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9) + + with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=3) + + assert deducted_credits == 1 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_deduct_credits_capped_wraps_unexpected_deduction_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")), + pytest.raises(QuotaExceededError, match="Failed to deduct credits"), + ): + CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 + + +def test_deduct_credits_capped_reraises_quota_exceeded_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=QuotaExceededError("quota unavailable")), + pytest.raises(QuotaExceededError, match="quota unavailable"), + ): + CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 08c6ec76e2..1711e66b23 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -2845,7 +2845,7 @@ class TestWorkflowServiceFreeNodeExecution: mock_node_cls.validate_node_data.assert_called_once_with(sentinel.adapted_node_data) mock_node_cls.assert_called_once_with( node_id="n-1", - config=sentinel.node_data, + data=sentinel.node_data, graph_init_params=mock_graph_init_context_cls.return_value.to_graph_init_params.return_value, graph_runtime_state=ANY, runtime=mock_runtime_cls.return_value, diff --git a/api/uv.lock b/api/uv.lock index 10487f6bac..ad9ce2c4a4 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1597,7 +1597,7 @@ requires-dist = [ { name = "gmpy2", specifier = ">=2.3.0" }, { name = "google-api-python-client", specifier = ">=2.195.0" }, { name = "google-cloud-aiplatform", specifier = ">=1.149.0,<2.0.0" }, - { name = "graphon", specifier = "~=0.2.2" }, + { name = "graphon", specifier = "~=0.3.0" }, { name = "gunicorn", specifier = ">=25.3.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, @@ -2940,7 +2940,7 @@ httpx = [ [[package]] name = "graphon" -version = "0.2.2" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -2961,9 +2961,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/50/e745a79c5f742f88f6011a1f7c9ba2c2f9cc1beedd982f0b192f1ab8c748/graphon-0.2.2.tar.gz", hash = "sha256:141f0de536171850f1af6f738dc66f0285aadd3c097f1dad2a038636789e0aa5", size = 236360, upload-time = "2026-04-17T08:52:28.047Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/62/83593d6e7a139ff124711ea05882cadca7065c11a38763aa9360d7e76804/graphon-0.3.0.tar.gz", hash = "sha256:cd38f842ae3dcfa956428b952efbe2a3ea9c1581446647142accbbdeb638b876", size = 241176, upload-time = "2026-04-21T15:18:48.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/89/a6340afdaf5169d17a318e00fc685fb67ed99baa602c2cbbbf6af6a76096/graphon-0.2.2-py3-none-any.whl", hash = "sha256:754e544d08779138f99eac6547ab08559463680e2c76488b05e1c978210392b4", size = 340808, upload-time = "2026-04-17T08:52:26.5Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f7/81ee8f0368aa6a2d47f97fecc5d4a12865c987906798cbddd0e3b8387f33/graphon-0.3.0-py3-none-any.whl", hash = "sha256:9cca45ebab2a79fd4d04432f55b5b962e9e4f34fa037cc20fee7f18ec80eaa5d", size = 348486, upload-time = "2026-04-21T15:18:46.737Z" }, ] [[package]] From 65c36a51eff15b398638a881caebfea09dd6a5f8 Mon Sep 17 00:00:00 2001 From: Asuka Minato <i@asukaminato.eu.org> Date: Sat, 9 May 2026 16:53:42 +0900 Subject: [PATCH 08/53] ci: update comment (#35968) --- .github/workflows/pyrefly-diff-comment.yml | 22 ++++++++++++++++++++-- .github/workflows/pyrefly-diff.yml | 21 +++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pyrefly-diff-comment.yml b/.github/workflows/pyrefly-diff-comment.yml index 7f82942e7e..8e16baf933 100644 --- a/.github/workflows/pyrefly-diff-comment.yml +++ b/.github/workflows/pyrefly-diff-comment.yml @@ -77,10 +77,28 @@ jobs: } if (diff.trim()) { - await github.rest.issues.createComment({ + const body = '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>'; + const marker = '### Pyrefly Diff'; + const { data: comments } = await github.rest.issues.listComments({ issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, - body: '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>', }); + const existing = comments.find((comment) => comment.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } } diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index 0cf54e3585..386bd25751 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -103,9 +103,26 @@ jobs: ].join('\n') : '### Pyrefly Diff\nNo changes detected.'; - await github.rest.issues.createComment({ + const marker = '### Pyrefly Diff'; + const { data: comments } = await github.rest.issues.listComments({ issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, - body, }); + const existing = comments.find((comment) => comment.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } From 1efd365b62d6edc17436937b308c5c8e3a0bd664 Mon Sep 17 00:00:00 2001 From: chariri <w@chariri.moe> Date: Sat, 9 May 2026 17:21:26 +0900 Subject: [PATCH 09/53] fix(swagger): Apply the inline-nested-dicts patch to HTTP Swagger endpoints (#35952) --- api/dev/generate_swagger_markdown_docs.py | 45 ++++-- api/dev/generate_swagger_specs.py | 54 +------ api/libs/external_api.py | 2 + api/libs/flask_restx_compat.py | 149 ++++++++++++++++++ .../unit_tests/controllers/test_swagger.py | 72 +++++++++ 5 files changed, 260 insertions(+), 62 deletions(-) create mode 100644 api/libs/flask_restx_compat.py create mode 100644 api/tests/unit_tests/controllers/test_swagger.py diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 0900d08331..e0028c63f6 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -29,18 +29,39 @@ STALE_COMBINED_MARKDOWN_FILENAME = "api-reference.md" def _convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None: - subprocess.run( - [ - "npx", - "--yes", - SWAGGER_MARKDOWN_PACKAGE, - "-i", - str(spec_path), - "-o", - str(markdown_path), - ], - check=True, - ) + markdown_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f"{markdown_path.stem}-", dir=markdown_path.parent) as temp_dir: + temp_markdown_path = Path(temp_dir) / markdown_path.name + result = subprocess.run( + [ + "npx", + "--yes", + SWAGGER_MARKDOWN_PACKAGE, + "-i", + str(spec_path), + "-o", + str(temp_markdown_path), + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + result.args, + output=result.stdout, + stderr=result.stderr, + ) + if not temp_markdown_path.exists(): + converter_output = "\n".join(item for item in (result.stdout, result.stderr) if item).strip() + raise RuntimeError(f"swagger-markdown did not write {markdown_path}: {converter_output}") + + converted_markdown = temp_markdown_path.read_text(encoding="utf-8") + if not converted_markdown.strip(): + raise RuntimeError(f"swagger-markdown wrote an empty document for {markdown_path}") + + markdown_path.write_text(converted_markdown, encoding="utf-8") def _demote_markdown_headings(markdown: str, *, levels: int = 1) -> str: diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py index 9122f3ab24..254310cd2a 100644 --- a/api/dev/generate_swagger_specs.py +++ b/api/dev/generate_swagger_specs.py @@ -20,7 +20,6 @@ from pathlib import Path from typing import Protocol, TypeGuard from flask import Flask -from flask_restx.swagger import Swagger logger = logging.getLogger(__name__) @@ -48,9 +47,6 @@ SPEC_TARGETS: tuple[SpecTarget, ...] = ( SpecTarget(route="/v1/swagger.json", filename="service-swagger.json", namespace="service"), ) -_ORIGINAL_REGISTER_MODEL = Swagger.register_model -_ORIGINAL_REGISTER_FIELD = Swagger.register_field - def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]: """Return whether a nested field map is an anonymous inline mapping.""" @@ -152,56 +148,14 @@ def apply_runtime_defaults() -> None: dify_config.SWAGGER_UI_ENABLED = os.environ["SWAGGER_UI_ENABLED"].lower() == "true" -def _patch_swagger_for_inline_nested_dicts() -> None: - """Teach Flask-RESTX Swagger generation to tolerate inline nested field maps. - - Some existing controllers use `fields.Nested({...})` with a raw field mapping - instead of a named `api.model(...)`. Flask-RESTX crashes on those anonymous - dicts during schema registration, so this helper upgrades them into temporary - named models at export time. - """ - - if getattr(Swagger, "_dify_inline_nested_dict_patch", False): - return - - def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: - anonymous_models = getattr(self, "_anonymous_inline_models", None) - if anonymous_models is None: - anonymous_models = {} - self.__dict__["_anonymous_inline_models"] = anonymous_models - - anonymous_name = anonymous_models.get(id(nested_fields)) - if anonymous_name is None: - anonymous_name = _inline_model_name(nested_fields) - anonymous_models[id(nested_fields)] = anonymous_name - if anonymous_name not in self.api.models: - self.api.model(anonymous_name, nested_fields) - - return self.api.models[anonymous_name] - - def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]: - if _is_inline_field_map(model): - model = get_or_create_inline_model(self, model) - - return _ORIGINAL_REGISTER_MODEL(self, model) - - def register_field_with_inline_dict_support(self: Swagger, field: object) -> None: - nested = getattr(field, "nested", None) - if _is_inline_field_map(nested): - field.model = get_or_create_inline_model(self, nested) # type: ignore - - _ORIGINAL_REGISTER_FIELD(self, field) - - Swagger.register_model = register_model_with_inline_dict_support - Swagger.register_field = register_field_with_inline_dict_support - Swagger._dify_inline_nested_dict_patch = True - - def create_spec_app() -> Flask: """Build a minimal Flask app that only mounts the Swagger-producing blueprints.""" apply_runtime_defaults() - _patch_swagger_for_inline_nested_dicts() + + from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts + + patch_swagger_for_inline_nested_dicts() app = Flask(__name__) diff --git a/api/libs/external_api.py b/api/libs/external_api.py index f907d17750..64eb99a42b 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -9,6 +9,7 @@ from werkzeug.http import HTTP_STATUS_CODES from configs import dify_config from core.errors.error import AppInvokeQuotaExceededError +from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts from libs.token import build_force_logout_cookie_headers @@ -120,6 +121,7 @@ class ExternalApi(Api): } def __init__(self, app: Blueprint | Flask, *args, **kwargs): + patch_swagger_for_inline_nested_dicts() kwargs.setdefault("authorizations", self._authorizations) kwargs.setdefault("security", "Bearer") kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED diff --git a/api/libs/flask_restx_compat.py b/api/libs/flask_restx_compat.py new file mode 100644 index 0000000000..34e0d586a0 --- /dev/null +++ b/api/libs/flask_restx_compat.py @@ -0,0 +1,149 @@ +"""Compatibility helpers for Dify's Flask-RESTX Swagger integration. + +These helpers are temporary bridges for legacy Flask-RESTX field contracts +while controllers migrate their request and response documentation to Pydantic +models. Keep the behavior centralized so live Swagger endpoints and offline +spec export fail or succeed in the same way. +""" + +import hashlib +import json +from typing import TypeGuard + +from flask import current_app +from flask_restx import fields +from flask_restx.model import Model, OrderedModel, instance +from flask_restx.swagger import Swagger + + +def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]: + """Return whether a nested field map is an anonymous inline mapping.""" + + return isinstance(value, dict) and not isinstance(value, (Model, OrderedModel)) + + +def _jsonable_schema_value(value: object) -> object: + """Return a deterministic JSON-serializable representation for schema fingerprints.""" + + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, list | tuple): + return [_jsonable_schema_value(item) for item in value] + if isinstance(value, dict): + return {str(key): _jsonable_schema_value(item) for key, item in value.items()} + value_type = type(value) + return f"<{value_type.__module__}.{value_type.__qualname__}>" + + +def _field_signature(field: object) -> object: + """Build a stable signature for a Flask-RESTX field object.""" + + field_instance = instance(field) + signature: dict[str, object] = { + "class": f"{field_instance.__class__.__module__}.{field_instance.__class__.__qualname__}" + } + + if isinstance(field_instance, fields.Nested): + nested = getattr(field_instance, "nested", None) + if _is_inline_field_map(nested): + signature["nested"] = _inline_model_signature(nested) + else: + signature["nested"] = getattr( + nested, + "name", + f"<{type(nested).__module__}.{type(nested).__qualname__}>", + ) + elif hasattr(field_instance, "container"): + signature["container"] = _field_signature(field_instance.container) + else: + schema = getattr(field_instance, "__schema__", None) + if isinstance(schema, dict): + signature["schema"] = _jsonable_schema_value(schema) + + for attr_name in ( + "attribute", + "default", + "description", + "example", + "max", + "max_items", + "min", + "min_items", + "nullable", + "readonly", + "required", + "title", + "unique", + ): + if hasattr(field_instance, attr_name): + signature[attr_name] = _jsonable_schema_value(getattr(field_instance, attr_name)) + + return signature + + +def _inline_model_signature(nested_fields: dict[object, object]) -> object: + """Build a stable signature for an anonymous inline model.""" + + return [ + (str(field_name), _field_signature(field)) + for field_name, field in sorted(nested_fields.items(), key=lambda item: str(item[0])) + ] + + +def _inline_model_name(nested_fields: dict[object, object]) -> str: + """Return a stable Swagger model name for an anonymous inline field map.""" + + signature = json.dumps(_inline_model_signature(nested_fields), sort_keys=True, separators=(",", ":")) + digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:12] + return f"_AnonymousInlineModel_{digest}" + + +def patch_swagger_for_inline_nested_dicts() -> None: + """Allow Swagger generation to handle legacy inline Flask-RESTX field dicts. + + Some existing controllers use raw field mappings in `fields.Nested({...})` + or directly in `@namespace.response(...)`. Runtime marshalling accepts that, + but Flask-RESTX Swagger registration expects a named model. Convert those + anonymous mappings into temporary named models during docs generation. + """ + + if getattr(Swagger, "_dify_inline_nested_dict_patch", False): + return + + original_register_model = Swagger.register_model + original_register_field = Swagger.register_field + original_as_dict = Swagger.as_dict + + def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: + anonymous_name = _inline_model_name(nested_fields) + if anonymous_name not in self.api.models: + self.api.model(anonymous_name, nested_fields) + + return self.api.models[anonymous_name] + + def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]: + if _is_inline_field_map(model): + model = get_or_create_inline_model(self, model) + + return original_register_model(self, model) + + def register_field_with_inline_dict_support(self: Swagger, field: object) -> None: + nested = getattr(field, "nested", None) + if _is_inline_field_map(nested): + field.model = get_or_create_inline_model(self, nested) # type: ignore[attr-defined] + + original_register_field(self, field) + + def as_dict_with_inline_dict_support(self: Swagger): + # Temporary set RESTX_INCLUDE_ALL_MODELS = false to prevent "length changed while iterating" error + include_all_models = current_app.config.get("RESTX_INCLUDE_ALL_MODELS", False) + current_app.config["RESTX_INCLUDE_ALL_MODELS"] = False + try: + return original_as_dict(self) + finally: + current_app.config["RESTX_INCLUDE_ALL_MODELS"] = include_all_models + + Swagger.register_model = register_model_with_inline_dict_support + Swagger.register_field = register_field_with_inline_dict_support + Swagger.as_dict = as_dict_with_inline_dict_support + Swagger._dify_inline_nested_dict_patch = True diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py new file mode 100644 index 0000000000..999f1ae78d --- /dev/null +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -0,0 +1,72 @@ +"""Swagger JSON rendering tests for Flask-RESTX API blueprints.""" + +import pytest +from flask import Flask + + +def _definition_refs(value: object) -> set[str]: + refs: set[str] = set() + if isinstance(value, dict): + ref = value.get("$ref") + if isinstance(ref, str) and ref.startswith("#/definitions/"): + refs.add(ref.removeprefix("#/definitions/")) + for item in value.values(): + refs.update(_definition_refs(item)) + elif isinstance(value, list): + for item in value: + refs.update(_definition_refs(item)) + return refs + + +@pytest.mark.parametrize( + ("first_kwargs", "second_kwargs"), + [ + ({"min_items": 1}, {"min_items": 2}), + ({"max_items": 1}, {"max_items": 2}), + ({"unique": True}, {"unique": False}), + ], +) +def test_inline_model_name_includes_list_constraints( + first_kwargs: dict[str, object], + second_kwargs: dict[str, object], +): + from flask_restx import fields + + from libs.flask_restx_compat import _inline_model_name + + first_inline_model: dict[object, object] = {"items": fields.List(fields.String, **first_kwargs)} + second_inline_model: dict[object, object] = {"items": fields.List(fields.String, **second_kwargs)} + + assert _inline_model_name(first_inline_model) != _inline_model_name(second_inline_model) + + +def test_swagger_json_endpoints_render(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.console import bp as console_bp + from controllers.service_api import bp as service_api_bp + from controllers.web import bp as web_bp + + monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) + + app = Flask(__name__) + app.config["TESTING"] = True + app.config["RESTX_INCLUDE_ALL_MODELS"] = True + app.register_blueprint(console_bp) + app.register_blueprint(web_bp) + app.register_blueprint(service_api_bp) + + client = app.test_client() + + for route in ("/console/api/swagger.json", "/api/swagger.json", "/v1/swagger.json"): + response = client.get(route) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["swagger"] == "2.0" + assert "paths" in payload + assert "definitions" in payload + assert isinstance(payload["definitions"], dict) + missing_refs = _definition_refs(payload) - set(payload["definitions"]) + assert not sorted(ref for ref in missing_refs if ref.startswith("_AnonymousInlineModel")) + + assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True From 861f73267ca41027d15c3f27ff06a9f8a5d20f82 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 16:23:50 +0800 Subject: [PATCH 10/53] feat(dify-ui): add Tabs/ToggleGroup (#35965) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 13 +- packages/dify-ui/package.json | 8 + .../dify-ui/src/tabs/__tests__/index.spec.tsx | 80 ++++++++ packages/dify-ui/src/tabs/index.stories.tsx | 51 +++++ packages/dify-ui/src/tabs/index.tsx | 59 ++++++ .../src/toggle-group/__tests__/index.spec.tsx | 74 ++++++++ .../src/toggle-group/index.stories.tsx | 177 ++++++++++++++++++ packages/dify-ui/src/toggle-group/index.tsx | 58 ++++++ .../variable-or-constant-input.spec.tsx | 59 ------ .../field/variable-or-constant-input.tsx | 86 --------- .../__tests__/index.spec.tsx | 114 ----------- .../base/segmented-control/index.css | 109 ----------- .../base/segmented-control/index.stories.tsx | 94 ---------- .../base/segmented-control/index.tsx | 151 --------------- .../develop/__tests__/code.spec.tsx | 4 +- web/app/components/develop/code.tsx | 151 +++++++++------ .../__tests__/panel-output-section.spec.tsx | 2 +- .../json-schema-config-modal/index.tsx | 10 +- .../json-schema-config.tsx | 116 +++++++----- .../llm/components/panel-output-section.tsx | 2 +- .../nodes/llm/components/structure-output.tsx | 26 +-- .../components/__tests__/integration.spec.tsx | 11 -- .../__tests__/mode-switcher.spec.tsx | 16 -- .../components/mode-switcher.tsx | 37 ---- .../__tests__/display-content.spec.tsx | 2 +- .../variable-inspect/display-content.tsx | 47 ++--- .../value-content-sections.tsx | 2 +- web/app/styles/tailwind-core.css | 1 - 28 files changed, 718 insertions(+), 842 deletions(-) create mode 100644 packages/dify-ui/src/tabs/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/tabs/index.stories.tsx create mode 100644 packages/dify-ui/src/tabs/index.tsx create mode 100644 packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/toggle-group/index.stories.tsx create mode 100644 packages/dify-ui/src/toggle-group/index.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/variable-or-constant-input.spec.tsx delete mode 100644 web/app/components/base/form/components/field/variable-or-constant-input.tsx delete mode 100644 web/app/components/base/segmented-control/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/segmented-control/index.css delete mode 100644 web/app/components/base/segmented-control/index.stories.tsx delete mode 100644 web/app/components/base/segmented-control/index.tsx delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/__tests__/mode-switcher.spec.tsx delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2326e92d2f..683e6b09fe 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1051,14 +1051,6 @@ "count": 1 } }, - "web/app/components/base/form/components/field/variable-or-constant-input.tsx": { - "no-console": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/base/form/components/field/variable-selector.tsx": { "no-console": { "count": 1 @@ -2307,11 +2299,8 @@ } }, "web/app/components/develop/code.tsx": { - "ts/no-empty-object-type": { - "count": 1 - }, "ts/no-explicit-any": { - "count": 9 + "count": 7 } }, "web/app/components/develop/md.tsx": { diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 894e92bfd6..96c512f89c 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -77,6 +77,14 @@ "types": "./src/switch/index.tsx", "import": "./src/switch/index.tsx" }, + "./tabs": { + "types": "./src/tabs/index.tsx", + "import": "./src/tabs/index.tsx" + }, + "./toggle-group": { + "types": "./src/toggle-group/index.tsx", + "import": "./src/toggle-group/index.tsx" + }, "./toast": { "types": "./src/toast/index.tsx", "import": "./src/toast/index.tsx" diff --git a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx new file mode 100644 index 0000000000..6673e35bf5 --- /dev/null +++ b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx @@ -0,0 +1,80 @@ +import { render } from 'vitest-browser-react' +import { + Tabs, + TabsList, + TabsPanel, + TabsTab, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Tabs wrappers', () => { + it('renders Base UI tabs with accessible roles', async () => { + const screen = await render( + <Tabs defaultValue="js"> + <TabsList> + <TabsTab value="js">JavaScript</TabsTab> + <TabsTab value="py">Python</TabsTab> + </TabsList> + <TabsPanel value="js">JS panel</TabsPanel> + <TabsPanel value="py">Python panel</TabsPanel> + </Tabs>, + ) + + await expect.element(screen.getByRole('tablist')).toBeInTheDocument() + await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true') + await expect.element(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'false') + await expect.element(screen.getByText('JS panel')).toBeInTheDocument() + }) + + it('keeps tabs styling minimal by default', async () => { + const screen = await render( + <Tabs defaultValue="first"> + <TabsList> + <TabsTab value="first">First</TabsTab> + <TabsTab value="second">Second</TabsTab> + </TabsList> + </Tabs>, + ) + + await expect.element(screen.getByRole('tablist')).toHaveClass( + 'flex', + ) + await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass( + 'touch-manipulation', + 'focus-visible:outline-hidden', + ) + }) + + it('calls onValueChange while leaving controlled value to the caller', async () => { + const onValueChange = vi.fn() + const screen = await render( + <Tabs value="js" onValueChange={onValueChange}> + <TabsList> + <TabsTab value="js">JavaScript</TabsTab> + <TabsTab value="py">Python</TabsTab> + </TabsList> + </Tabs>, + ) + + asHTMLElement(screen.getByRole('tab', { name: 'Python' }).element()).click() + + expect(onValueChange).toHaveBeenCalledWith('py', expect.anything()) + await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true') + }) + + it('forwards className to composable parts', async () => { + const screen = await render( + <Tabs defaultValue="first"> + <TabsList className="custom-list"> + <TabsTab value="first" className="custom-tab">First</TabsTab> + </TabsList> + <TabsPanel value="first" className="custom-panel">Panel</TabsPanel> + </Tabs>, + ) + + await expect.element(screen.getByRole('tablist')).toHaveClass('custom-list') + await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass('custom-tab') + expect(screen.getByText('Panel').element()).toHaveClass('custom-panel') + }) +}) diff --git a/packages/dify-ui/src/tabs/index.stories.tsx b/packages/dify-ui/src/tabs/index.stories.tsx new file mode 100644 index 0000000000..dd1e79a1ce --- /dev/null +++ b/packages/dify-ui/src/tabs/index.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { + Tabs, + TabsList, + TabsPanel, + TabsTab, +} from '.' + +const meta = { + title: 'Base/UI/Tabs', + component: Tabs, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Composable tabs built on Base UI. Use this when a tab controls a corresponding tab panel.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta<typeof Tabs> + +export default meta +type Story = StoryObj<typeof meta> + +export const Basic: Story = { + render: () => ( + <Tabs defaultValue="overview" className="w-96"> + <TabsList className="gap-4 border-b border-divider-subtle"> + <TabsTab + value="overview" + className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary" + > + Overview + </TabsTab> + <TabsTab + value="activity" + className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary" + > + Activity + </TabsTab> + </TabsList> + <TabsPanel value="overview" className="py-3 system-sm-regular text-text-secondary"> + Overview panel + </TabsPanel> + <TabsPanel value="activity" className="py-3 system-sm-regular text-text-secondary"> + Activity panel + </TabsPanel> + </Tabs> + ), +} diff --git a/packages/dify-ui/src/tabs/index.tsx b/packages/dify-ui/src/tabs/index.tsx new file mode 100644 index 0000000000..ddc5891b89 --- /dev/null +++ b/packages/dify-ui/src/tabs/index.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { Tabs as BaseTabsNS } from '@base-ui/react/tabs' +import { Tabs as BaseTabs } from '@base-ui/react/tabs' +import { cn } from '../cn' + +export type TabsProps = BaseTabsNS.Root.Props + +export const Tabs = BaseTabs.Root + +export type TabsListProps = Omit<BaseTabsNS.List.Props, 'className'> & { + className?: string +} + +export function TabsList({ + className, + ...props +}: TabsListProps) { + return ( + <BaseTabs.List + className={cn('flex', className)} + {...props} + /> + ) +} + +export type TabsTabProps = Omit<BaseTabsNS.Tab.Props, 'className'> & { + className?: string +} + +export function TabsTab({ + className, + ...props +}: TabsTabProps) { + return ( + <BaseTabs.Tab + className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover data-disabled:cursor-not-allowed data-disabled:text-text-disabled', className)} + {...props} + /> + ) +} + +export type TabsPanelProps = Omit<BaseTabsNS.Panel.Props, 'className'> & { + className?: string +} + +export function TabsPanel({ + className, + ...props +}: TabsPanelProps) { + return ( + <BaseTabs.Panel + className={className} + {...props} + /> + ) +} + +export const TabsIndicator = BaseTabs.Indicator diff --git a/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx b/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ec6e7351e2 --- /dev/null +++ b/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx @@ -0,0 +1,74 @@ +import { render } from 'vitest-browser-react' +import { + ToggleGroup, + ToggleGroupDivider, + ToggleGroupItem, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('ToggleGroup wrappers', () => { + it('renders a segmented control with Base UI pressed state', async () => { + const screen = await render( + <ToggleGroup defaultValue={['one']} aria-label="View"> + <ToggleGroupItem value="one">One</ToggleGroupItem> + <ToggleGroupItem value="two">Two</ToggleGroupItem> + </ToggleGroup>, + ) + + await expect.element(screen.getByRole('group')).toHaveClass( + 'bg-components-segmented-control-bg-normal', + 'p-0.5', + 'rounded-[10px]', + ) + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass( + 'data-pressed:bg-components-segmented-control-item-active-bg', + 'data-pressed:text-text-accent-light-mode-only', + ) + }) + + it('uses single selection by default', async () => { + const screen = await render( + <ToggleGroup defaultValue={['one']} aria-label="View"> + <ToggleGroupItem value="one">One</ToggleGroupItem> + <ToggleGroupItem value="two">Two</ToggleGroupItem> + </ToggleGroup>, + ) + + asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click() + + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'false') + await expect.element(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('calls onValueChange while leaving controlled value to the caller', async () => { + const onValueChange = vi.fn() + const screen = await render( + <ToggleGroup value={['one']} onValueChange={onValueChange} aria-label="View"> + <ToggleGroupItem value="one">One</ToggleGroupItem> + <ToggleGroupItem value="two">Two</ToggleGroupItem> + </ToggleGroup>, + ) + + asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click() + + expect(onValueChange).toHaveBeenCalledWith(['two'], expect.anything()) + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('forwards disabled and className to composable parts', async () => { + const screen = await render( + <ToggleGroup defaultValue={['one']} aria-label="View" className="custom-group"> + <ToggleGroupItem value="one" className="custom-item">One</ToggleGroupItem> + <ToggleGroupDivider className="custom-divider" data-testid="divider" /> + <ToggleGroupItem value="two" disabled>Two</ToggleGroupItem> + </ToggleGroup>, + ) + + await expect.element(screen.getByRole('group')).toHaveClass('custom-group') + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass('custom-item') + await expect.element(screen.getByRole('button', { name: 'Two' })).toBeDisabled() + await expect.element(screen.getByTestId('divider')).toHaveClass('custom-divider') + }) +}) diff --git a/packages/dify-ui/src/toggle-group/index.stories.tsx b/packages/dify-ui/src/toggle-group/index.stories.tsx new file mode 100644 index 0000000000..960957b7ab --- /dev/null +++ b/packages/dify-ui/src/toggle-group/index.stories.tsx @@ -0,0 +1,177 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ReactNode } from 'react' +import { + ToggleGroup, + ToggleGroupDivider, + ToggleGroupItem, +} from '.' + +const meta = { + title: 'Base/UI/ToggleGroup', + component: ToggleGroup, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Segmented control built on Base UI ToggleGroup and Toggle. Use this for mode, filter, and view selection that does not need tabpanel semantics.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta<typeof ToggleGroup> + +export default meta +type Story = StoryObj<typeof meta> + +type SegmentedControlProps = { + defaultValue: string + values: string[] + iconOnly?: boolean + noPadding?: boolean +} + +const Icon = () => ( + <i className="i-ri-information-line size-4 shrink-0" aria-hidden="true" /> +) + +const Item = () => ( + <> + <Icon /> + <span className="px-0.5">Item</span> + </> +) + +function SegmentedControl({ + defaultValue, + values, + iconOnly = false, + noPadding = false, +}: SegmentedControlProps) { + return ( + <ToggleGroup + defaultValue={[defaultValue]} + aria-label="Segmented control" + className={noPadding ? 'rounded-lg border-[0.5px] border-divider-subtle p-0' : undefined} + > + {values.map((itemValue, index) => ( + <span key={itemValue} className="relative flex items-center"> + <ToggleGroupItem + value={itemValue} + aria-label={iconOnly ? `Item ${index + 1}` : undefined} + > + <Icon /> + {!iconOnly && ( + <span className="px-0.5">Item</span> + )} + </ToggleGroupItem> + {index === 1 && ( + <span className="pointer-events-none absolute top-0 -right-px flex h-full items-center" aria-hidden="true"> + <ToggleGroupDivider /> + </span> + )} + </span> + ))} + </ToggleGroup> + ) +} + +function SpecColumn() { + const values = ['one', 'two', 'three'] + + return ( + <div className="flex flex-col items-center gap-6"> + <SegmentedControl defaultValue="one" values={values} /> + <SegmentedControl defaultValue="one" values={values} iconOnly /> + <SegmentedControl defaultValue="one" values={values} noPadding /> + <SegmentedControl defaultValue="one" values={values} iconOnly noPadding /> + </div> + ) +} + +function SpecPanel({ + className, + children, +}: { + className?: string + children: ReactNode +}) { + return ( + <div className={className}> + <div className="flex min-h-105 items-center justify-center"> + {children} + </div> + </div> + ) +} + +export const DesignSpec: Story = { + render: () => ( + <div className="overflow-hidden rounded-3xl bg-components-panel-bg-alt p-4"> + <SpecPanel className="w-120 overflow-hidden rounded-2xl bg-components-chart-bg"> + <SpecColumn /> + </SpecPanel> + </div> + ), + parameters: { + docs: { + description: { + story: 'Figma node 2473:9851: segmented control examples with text+icon and icon-only rows, with and without outer padding.', + }, + }, + }, +} + +export const DataAttributeStates: Story = { + render: () => ( + <div className="flex flex-col gap-5"> + <ToggleGroup defaultValue={['active']} aria-label="Basic states"> + <ToggleGroupItem value="default"> + <Item /> + </ToggleGroupItem> + <ToggleGroupItem value="active"> + <Item /> + </ToggleGroupItem> + <ToggleGroupItem value="disabled" disabled> + <Item /> + </ToggleGroupItem> + </ToggleGroup> + + <ToggleGroup defaultValue={['accent-light']} aria-label="Active states"> + <ToggleGroupItem value="accent-light"> + <Item /> + </ToggleGroupItem> + <ToggleGroupItem + value="neutral" + className="data-pressed:text-text-primary" + > + <Item /> + </ToggleGroupItem> + <ToggleGroupItem + value="accent" + className="data-pressed:border-components-segmented-control-item-active-accent-border data-pressed:bg-components-segmented-control-item-active-accent-bg data-pressed:text-text-accent" + > + <Item /> + </ToggleGroupItem> + </ToggleGroup> + + <ToggleGroup defaultValue={['one', 'three']} multiple aria-label="Multiple selection"> + <ToggleGroupItem value="one"> + <Item /> + </ToggleGroupItem> + <ToggleGroupItem value="two"> + <Item /> + </ToggleGroupItem> + <ToggleGroupItem value="three"> + <Item /> + </ToggleGroupItem> + </ToggleGroup> + </div> + ), + parameters: { + docs: { + description: { + story: '`ToggleGroupItem` gets `data-pressed` and `data-disabled` from Base UI. Accent, neutral, and multiple-selection examples are composed through props and className.', + }, + }, + }, +} diff --git a/packages/dify-ui/src/toggle-group/index.tsx b/packages/dify-ui/src/toggle-group/index.tsx new file mode 100644 index 0000000000..661385a132 --- /dev/null +++ b/packages/dify-ui/src/toggle-group/index.tsx @@ -0,0 +1,58 @@ +'use client' + +import type { Toggle as BaseToggleNS } from '@base-ui/react/toggle' +import type { ToggleGroup as BaseToggleGroupNS } from '@base-ui/react/toggle-group' +import type { HTMLAttributes } from 'react' +import { Toggle as BaseToggle } from '@base-ui/react/toggle' +import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group' +import { cn } from '../cn' + +export type ToggleGroupProps<Value extends string = string> = Omit<BaseToggleGroupNS.Props<Value>, 'className'> & { + className?: string +} + +export function ToggleGroup<Value extends string = string>({ + className, + ...props +}: ToggleGroupProps<Value>) { + return ( + <BaseToggleGroup + className={cn('inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5', className)} + {...props} + /> + ) +} + +export type ToggleGroupItemProps<Value extends string = string> = Omit<BaseToggleNS.Props<Value>, 'className'> & { + className?: string +} + +export function ToggleGroupItem<Value extends string = string>({ + className, + ...props +}: ToggleGroupItemProps<Value>) { + return ( + <BaseToggle + className={cn('relative flex h-7 min-w-0 touch-manipulation items-center justify-center gap-0.5 overflow-hidden whitespace-nowrap rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover data-pressed:border-components-segmented-control-item-active-border data-pressed:bg-components-segmented-control-item-active-bg data-pressed:text-text-accent-light-mode-only data-pressed:shadow-xs data-pressed:shadow-shadow-shadow-3 data-disabled:cursor-not-allowed data-disabled:bg-transparent data-disabled:text-text-disabled data-disabled:shadow-none data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled motion-reduce:transition-none', className)} + {...props} + /> + ) +} + +export type ToggleGroupDividerProps = Omit<HTMLAttributes<HTMLSpanElement>, 'className'> & { + className?: string +} + +export function ToggleGroupDivider({ + className, + ...props +}: ToggleGroupDividerProps) { + return ( + <span + role="presentation" + aria-hidden="true" + className={cn('h-3.5 w-px shrink-0 bg-divider-regular', className)} + {...props} + /> + ) +} diff --git a/web/app/components/base/form/components/field/__tests__/variable-or-constant-input.spec.tsx b/web/app/components/base/form/components/field/__tests__/variable-or-constant-input.spec.tsx deleted file mode 100644 index b4aa8bd24e..0000000000 --- a/web/app/components/base/form/components/field/__tests__/variable-or-constant-input.spec.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import VariableOrConstantInputField from '../variable-or-constant-input' - -vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ - default: ({ onChange }: { onChange?: () => void }) => ( - <button onClick={() => onChange?.()}> - Variable picker - </button> - ), -})) - -describe('VariableOrConstantInputField', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render variable picker by default', () => { - render(<VariableOrConstantInputField label="Input source" />) - expect(screen.getByRole('button', { name: 'Variable picker' }))!.toBeInTheDocument() - }) - - it('should switch to constant input when users choose constant', () => { - render(<VariableOrConstantInputField label="Input source" />) - fireEvent.click(screen.getAllByRole('button')[1]!) - expect(screen.queryByRole('button', { name: 'Variable picker' })).not.toBeInTheDocument() - expect(screen.getByRole('textbox'))!.toBeInTheDocument() - }) - - it('should show typed constant value in the input', () => { - render(<VariableOrConstantInputField label="Input source" />) - fireEvent.click(screen.getAllByRole('button')[1]!) - const textbox = screen.getByRole('textbox') - fireEvent.change(textbox, { target: { value: 'constant-value' } }) - expect(textbox)!.toHaveValue('constant-value') - }) - - it('should switch back to variable mode when users choose variable again', () => { - render(<VariableOrConstantInputField label="Input source" />) - const modeButtons = screen.getAllByRole('button') - - fireEvent.click(modeButtons[1]!) - expect(screen.getByRole('textbox'))!.toBeInTheDocument() - - fireEvent.click(modeButtons[0]!) - expect(screen.getByRole('button', { name: 'Variable picker' }))!.toBeInTheDocument() - }) - - it('should handle variable picker changes', () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) - try { - render(<VariableOrConstantInputField label="Input source" />) - fireEvent.click(screen.getByRole('button', { name: 'Variable picker' })) - expect(logSpy).toHaveBeenCalledWith('Variable value changed') - } - finally { - logSpy.mockRestore() - } - }) -}) diff --git a/web/app/components/base/form/components/field/variable-or-constant-input.tsx b/web/app/components/base/form/components/field/variable-or-constant-input.tsx deleted file mode 100644 index 04ecf99330..0000000000 --- a/web/app/components/base/form/components/field/variable-or-constant-input.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { ChangeEvent } from 'react' -import type { LabelProps } from '../label' -import { cn } from '@langgenius/dify-ui/cn' -import { RiEditLine } from '@remixicon/react' -import { useCallback, useState } from 'react' -import { VariableX } from '@/app/components/base/icons/src/vender/workflow' -import Input from '@/app/components/base/input' -import SegmentedControl from '@/app/components/base/segmented-control' -import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' -import Label from '../label' - -type VariableOrConstantInputFieldProps = { - label: string - labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'> - className?: string -} - -const VariableOrConstantInputField = ({ - className, - label, - labelOptions, -}: VariableOrConstantInputFieldProps) => { - const [variableType, setVariableType] = useState('variable') - - const options = [ - { - Icon: VariableX, - value: 'variable', - }, - { - Icon: RiEditLine, - value: 'constant', - }, - ] - - const handleVariableOrConstantChange = useCallback((value: string) => { - setVariableType(value) - }, [setVariableType]) - - const handleVariableValueChange = () => { - console.log('Variable value changed') - } - - const handleConstantValueChange = (e: ChangeEvent<HTMLInputElement>) => { - console.log('Constant value changed:', e.target.value) - } - - return ( - <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor="variable-or-constant" - label={label} - {...(labelOptions ?? {})} - /> - <div className="flex items-center"> - <SegmentedControl - className="mr-1 shrink-0" - value={variableType} - onChange={handleVariableOrConstantChange as any} - options={options as any} - /> - { - variableType === 'variable' && ( - <VarReferencePicker - className="grow" - nodeId="" - readonly={false} - value={[]} - onChange={handleVariableValueChange} - /> - ) - } - { - variableType === 'constant' && ( - <Input - className="ml-1" - onChange={handleConstantValueChange} - /> - ) - } - </div> - </div> - ) -} - -export default VariableOrConstantInputField diff --git a/web/app/components/base/segmented-control/__tests__/index.spec.tsx b/web/app/components/base/segmented-control/__tests__/index.spec.tsx deleted file mode 100644 index fd01b62764..0000000000 --- a/web/app/components/base/segmented-control/__tests__/index.spec.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import SegmentedControl from '../index' - -describe('SegmentedControl', () => { - const options = [ - { value: 'option1', text: 'Option 1' }, - { value: 'option2', text: 'Option 2' }, - { value: 'option3', text: 'Option 3' }, - ] - - const optionsWithDisabled = [ - { value: 'option1', text: 'Option 1' }, - { value: 'option2', text: 'Option 2', disabled: true }, - { value: 'option3', text: 'Option 3' }, - ] - - const onSelectMock = vi.fn((value: string | number | symbol) => value) - - beforeEach(() => { - onSelectMock.mockClear() - }) - - it('renders all options correctly', () => { - render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />) - - options.forEach((option) => { - expect(screen.getByText(option.text)).toBeInTheDocument() - }) - - const divider = screen.getByTestId('segmented-control-divider-1') - expect(divider).toBeInTheDocument() - }) - - it('renders with custom activeClassName when provided', () => { - render( - <SegmentedControl - options={options} - value="option1" - onChange={onSelectMock} - activeClassName="custom-active-class" - />, - ) - - const selectedOption = screen.getByText('Option 1').closest('button') - expect(selectedOption).toHaveClass('custom-active-class') - }) - - it('highlights the selected option', () => { - render(<SegmentedControl options={options} value="option2" onChange={onSelectMock} />) - - const selectedOption = screen.getByText('Option 2').closest('button') - expect(selectedOption).toHaveClass('sc-active') - }) - - it('calls onChange when an option is clicked', () => { - render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />) - - fireEvent.click(screen.getByText('Option 3')) - expect(onSelectMock).toHaveBeenCalledWith('option3') - }) - - it('does not call onChange when clicking the already selected option', () => { - render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />) - - fireEvent.click(screen.getByText('Option 1')) - expect(onSelectMock).not.toHaveBeenCalled() - }) - - it('handles disabled state correctly', () => { - render(<SegmentedControl options={optionsWithDisabled} value="option1" onChange={onSelectMock} />) - - fireEvent.click(screen.getByText('Option 2')) - expect(onSelectMock).not.toHaveBeenCalled() - - const optionElement = screen.getByText('Option 2').closest('button') - expect(optionElement).toHaveAttribute('disabled') - expect(optionElement).toHaveClass('sc-disabled') - - fireEvent.click(screen.getByText('Option 3')) - expect(onSelectMock).toHaveBeenCalledWith('option3') - }) - - it('renders with custom className when provided', () => { - const customClass = 'my-custom-class' - render( - <SegmentedControl - options={options} - value="option1" - onChange={onSelectMock} - className={customClass} - />, - ) - - const selectedOption = screen.getByText('Option 1').closest('button')?.closest('div') - expect(selectedOption).toHaveClass(customClass) - }) - - it('renders Icon when provided', () => { - const MockIcon = () => <svg data-testid="mock-icon" /> - const optionsWithIcon = [ - { value: 'option1', text: 'Option 1', Icon: MockIcon }, - ] - render(<SegmentedControl options={optionsWithIcon} value="option1" onChange={onSelectMock} />) - expect(screen.getByTestId('mock-icon')).toBeInTheDocument() - }) - - it('renders count when provided and size is large', () => { - const optionsWithCount = [ - { value: 'option1', text: 'Option 1', count: 42 }, - ] - render(<SegmentedControl options={optionsWithCount} value="option1" onChange={onSelectMock} size="large" />) - expect(screen.getByText('42')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/base/segmented-control/index.css b/web/app/components/base/segmented-control/index.css deleted file mode 100644 index c0fbc914b6..0000000000 --- a/web/app/components/base/segmented-control/index.css +++ /dev/null @@ -1,109 +0,0 @@ -@utility segmented-control { - @apply flex items-center bg-components-segmented-control-bg-normal gap-x-px; -} - -@utility segmented-control-regular { - @apply rounded-lg; - - &.sc-padding { - @apply p-0.5; - } -} - -@utility segmented-control-large { - @apply rounded-lg; - - &.sc-padding { - @apply p-0.5; - } -} - -@utility sc-padding { - &.segmented-control-large { - @apply p-0.5; - } - - & .segmented-control-regular { - @apply p-0.5; - } - - &.segmented-control-small { - @apply p-px; - } -} - -@utility segmented-control-small { - @apply rounded-md; - - &.sc-padding { - @apply p-px; - } -} - -@utility sc-no-padding { - @apply border-[0.5px] border-divider-subtle; -} - -@utility segmented-control-item { - @apply flex items-center justify-center relative border-[0.5px] border-transparent; -} - -@utility segmented-control-item-regular { - @apply px-2 h-7 gap-x-0.5 rounded-lg; -} - -@utility segmented-control-item-small { - @apply p-px h-[22px] rounded-md; -} - -@utility segmented-control-item-large { - @apply px-2.5 h-8 gap-x-0.5 rounded-lg; -} - -@utility segmented-control-item-disabled { - @apply cursor-not-allowed text-text-disabled; -} - -@utility sc-default { - @apply hover:bg-state-base-hover text-text-tertiary hover:text-text-secondary; -} - -@utility sc-active { - @apply border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3 text-text-secondary; - - &.sc-accent { - @apply text-text-accent; - } - - &.sc-accent-light { - @apply text-text-accent-light-mode-only; - } -} - -@utility sc-disabled { - @apply cursor-not-allowed text-text-disabled hover:text-text-disabled bg-transparent hover:bg-transparent; -} - -@utility sc-accent { - &.sc-active { - @apply text-text-accent; - } -} - -@utility sc-accent-light { - &.sc-active { - @apply text-text-accent-light-mode-only; - } -} - -@utility sc-item-text-regular { - @apply p-0.5; -} - -@utility sc-item-text-small { - @apply p-0.5 pr-1; -} - -@utility sc-item-text-large { - @apply px-0.5; -} diff --git a/web/app/components/base/segmented-control/index.stories.tsx b/web/app/components/base/segmented-control/index.stories.tsx deleted file mode 100644 index 3f8f9b7c62..0000000000 --- a/web/app/components/base/segmented-control/index.stories.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { RiLineChartLine, RiListCheck2, RiRobot2Line } from '@remixicon/react' -import { useState } from 'react' -import { SegmentedControl } from '.' - -const SEGMENTS = [ - { value: 'overview', text: 'Overview', Icon: RiLineChartLine }, - { value: 'tasks', text: 'Tasks', Icon: RiListCheck2, count: 8 }, - { value: 'agents', text: 'Agents', Icon: RiRobot2Line }, -] - -const SegmentedControlDemo = ({ - initialValue = 'overview', - size = 'regular', - padding = 'with', - activeState = 'default', -}: { - initialValue?: string - size?: 'regular' | 'small' | 'large' - padding?: 'none' | 'with' - activeState?: 'default' | 'accent' | 'accentLight' -}) => { - const [value, setValue] = useState(initialValue) - - return ( - <div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> - <div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase"> - <span>Segmented control</span> - <code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary"> - value=" - {value} - " - </code> - </div> - <SegmentedControl - options={SEGMENTS} - value={value} - onChange={setValue} - size={size} - padding={padding} - activeState={activeState} - /> - </div> - ) -} - -const meta = { - title: 'Base/Data Entry/SegmentedControl', - component: SegmentedControlDemo, - parameters: { - layout: 'centered', - docs: { - description: { - component: 'Multi-tab segmented control with optional icons and badge counts. Adjust sizing and accent states via controls.', - }, - }, - }, - argTypes: { - initialValue: { - control: 'radio', - options: SEGMENTS.map(segment => segment.value), - }, - size: { - control: 'inline-radio', - options: ['small', 'regular', 'large'], - }, - padding: { - control: 'inline-radio', - options: ['none', 'with'], - }, - activeState: { - control: 'inline-radio', - options: ['default', 'accent', 'accentLight'], - }, - }, - args: { - initialValue: 'overview', - size: 'regular', - padding: 'with', - activeState: 'default', - }, - tags: ['autodocs'], -} satisfies Meta<typeof SegmentedControlDemo> - -export default meta -type Story = StoryObj<typeof meta> - -export const Playground: Story = {} - -export const AccentState: Story = { - args: { - activeState: 'accent', - }, -} diff --git a/web/app/components/base/segmented-control/index.tsx b/web/app/components/base/segmented-control/index.tsx deleted file mode 100644 index 1f0d09472f..0000000000 --- a/web/app/components/base/segmented-control/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import type { RemixiconComponentType } from '@remixicon/react' -import type { VariantProps } from 'class-variance-authority' -import { cn } from '@langgenius/dify-ui/cn' -import { cva } from 'class-variance-authority' -import * as React from 'react' -import Divider from '../divider' - -type SegmentedControlOption<T> = { - value: T - text?: string - Icon?: RemixiconComponentType - count?: number - disabled?: boolean -} - -type SegmentedControlProps<T extends string | number | symbol> = { - options: SegmentedControlOption<T>[] - value: T - onChange: (value: T) => void - className?: string - activeClassName?: string - btnClassName?: string -} - -const SegmentedControlVariants = cva( - 'segmented-control', - { - variants: { - size: { - regular: 'segmented-control-regular', - small: 'segmented-control-small', - large: 'segmented-control-large', - }, - padding: { - none: 'sc-no-padding', - with: 'sc-padding', - }, - }, - defaultVariants: { - size: 'regular', - padding: 'with', - }, - }, -) - -const SegmentedControlItemVariants = cva( - 'segmented-control-item disabled:segmented-control-item-disabled', - { - variants: { - size: { - regular: ['segmented-control-item-regular', 'system-sm-medium'], - small: ['segmented-control-item-small', 'system-xs-medium'], - large: ['segmented-control-item-large', 'system-md-semibold'], - }, - activeState: { - default: '', - accent: 'sc-accent', - accentLight: 'sc-accent-light', - }, - }, - defaultVariants: { - size: 'regular', - activeState: 'default', - }, - }, -) - -const ItemTextWrapperVariants = cva( - '', - { - variants: { - size: { - regular: 'sc-item-text-regular', - small: 'sc-item-text-small', - large: 'sc-item-text-large', - }, - }, - defaultVariants: { - size: 'regular', - }, - }, -) - -export const SegmentedControl = <T extends string | number | symbol>({ - options, - value, - onChange, - className, - size, - padding, - activeState, - activeClassName, - btnClassName, -}: SegmentedControlProps<T> - & VariantProps<typeof SegmentedControlVariants> - & VariantProps<typeof SegmentedControlItemVariants> - & VariantProps<typeof ItemTextWrapperVariants>) => { - const selectedOptionIndex = options.findIndex(option => option.value === value) - - return ( - <div className={cn( - SegmentedControlVariants({ size, padding }), - className, - )} - > - {options.map((option, index) => { - const { Icon, text, count, disabled } = option - const isSelected = index === selectedOptionIndex - const isNextSelected = index === selectedOptionIndex - 1 - const isLast = index === options.length - 1 - return ( - <button - type="button" - key={String(option.value)} - className={cn( - isSelected ? 'sc-active' : 'sc-default', - SegmentedControlItemVariants({ size, activeState: isSelected ? activeState : 'default' }), - isSelected && activeClassName, - disabled && 'sc-disabled', - btnClassName, - )} - onClick={() => { - if (!isSelected) - onChange(option.value) - }} - disabled={disabled} - > - {Icon && <Icon className="size-4 shrink-0" />} - {text && ( - <div className={cn('inline-flex items-center gap-x-1', ItemTextWrapperVariants({ size }))}> - <span>{text}</span> - {!!(count && size === 'large') && ( - <div className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] system-2xs-medium-uppercase text-text-tertiary"> - {count} - </div> - )} - </div> - )} - {!isLast && !isSelected && !isNextSelected && ( - <div data-testid={`segmented-control-divider-${index}`} className="absolute top-0 -right-px flex h-full items-center"> - <Divider type="vertical" className="mx-0 h-3.5" /> - </div> - )} - </button> - ) - })} - </div> - ) -} - -export default React.memo(SegmentedControl) diff --git a/web/app/components/develop/__tests__/code.spec.tsx b/web/app/components/develop/__tests__/code.spec.tsx index e5eaebb600..34a1f22380 100644 --- a/web/app/components/develop/__tests__/code.spec.tsx +++ b/web/app/components/develop/__tests__/code.spec.tsx @@ -139,6 +139,7 @@ describe('code.tsx components', () => { await waitFor(() => { expect(screen.getByText('second content')).toBeInTheDocument() }) + expect(tab2).toHaveAttribute('aria-selected', 'true') }) it('should use "Code" as default title when title not provided', () => { @@ -329,7 +330,8 @@ describe('code.tsx components', () => { <pre><code>fallback</code></pre> </CodeGroup>, ) - expect(screen.getByRole('tablist')).toBeInTheDocument() + expect(screen.getByRole('tablist')).toHaveClass('-mb-px', 'gap-4', 'bg-transparent') + expect(screen.getByRole('tab', { name: 'cURL' })).toHaveClass('data-active:text-emerald-400') }) }) diff --git a/web/app/components/develop/code.tsx b/web/app/components/develop/code.tsx index 86d57c806d..2ecd13f4ab 100644 --- a/web/app/components/develop/code.tsx +++ b/web/app/components/develop/code.tsx @@ -1,7 +1,12 @@ 'use client' import type { PropsWithChildren, ReactElement, ReactNode } from 'react' -import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react' import { cn } from '@langgenius/dify-ui/cn' +import { + Tabs, + TabsList, + TabsPanel, + TabsTab, +} from '@langgenius/dify-ui/tabs' import { Children, createContext, @@ -103,6 +108,11 @@ type CodeExample = { code: string } +type CodeTab = { + title: string + value: string +} + type ICodePanelProps = { children?: React.ReactNode tag?: string @@ -142,12 +152,11 @@ function CodePanel({ tag, label, children, targetCode }: ICodePanelProps) { type CodeGroupHeaderProps = { title?: string - tabTitles?: string[] - selectedIndex?: number + tabs?: CodeTab[] } -function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderProps) { - const hasTabs = (tabTitles?.length ?? 0) > 1 +function CodeGroupHeader({ title, tabs }: CodeGroupHeaderProps) { + const hasTabs = (tabs?.length ?? 0) > 1 return ( <div className="flex min-h-[calc(--spacing(12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent"> @@ -157,18 +166,19 @@ function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderPro </h3> )} {hasTabs && ( - <TabList className="-mb-px flex gap-4 text-xs font-medium"> - {tabTitles!.map((tabTitle, tabIndex) => ( - <Tab - key={tabIndex} - className={cn('border-b py-3 transition focus:not-focus-visible:outline-hidden', tabIndex === selectedIndex - ? 'border-emerald-500 text-emerald-400' - : 'border-transparent text-zinc-400 hover:text-zinc-300')} + <TabsList + className="-mb-px flex gap-4 rounded-none bg-transparent p-0 text-xs font-medium" + > + {tabs!.map(tab => ( + <TabsTab + key={tab.value} + value={tab.value} + className="h-auto rounded-none border-0 border-b border-transparent bg-transparent px-0 py-3 text-xs font-medium text-zinc-400 shadow-none transition hover:bg-transparent hover:text-zinc-300 focus:not-focus-visible:outline-hidden focus-visible:ring-0 data-active:border-emerald-500 data-active:bg-transparent data-active:text-emerald-400 data-active:shadow-none" > - {tabTitle} - </Tab> + {tab.title} + </TabsTab> ))} - </TabList> + </TabsList> )} </div> ) @@ -176,19 +186,24 @@ function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderPro type ICodeGroupPanelsProps = PropsWithChildren<{ targetCode?: CodeExample[] + tabs?: CodeTab[] [key: string]: any }> -function CodeGroupPanels({ children, targetCode, ...props }: ICodeGroupPanelsProps) { - if ((targetCode?.length ?? 0) > 1) { +function CodeGroupPanels({ children, targetCode, tabs, ...props }: ICodeGroupPanelsProps) { + if ((targetCode?.length ?? 0) > 1 && tabs) { return ( - <TabPanels> - {targetCode!.map((code, index) => ( - <TabPanel key={code.title || code.tag || index}> - <CodePanel {...props} targetCode={code} /> - </TabPanel> - ))} - </TabPanels> + <> + {targetCode!.map((code, index) => { + const tab = tabs[index] + + return ( + <TabsPanel key={code.title || code.tag || index} value={tab?.value ?? String(index)}> + <CodePanel {...props} targetCode={code} /> + </TabsPanel> + ) + })} + </> ) } @@ -201,18 +216,27 @@ function usePreventLayoutShift() { useEffect(() => { return () => { - window.cancelAnimationFrame(rafRef.current) + if (rafRef.current) + window.cancelAnimationFrame(rafRef.current) } }, []) return { positionRef, - preventLayoutShift(callback: () => {}) { + preventLayoutShift(callback: () => void) { + if (!positionRef.current) { + callback() + return + } + const initialTop = positionRef.current.getBoundingClientRect().top callback() rafRef.current = window.requestAnimationFrame(() => { + if (!positionRef.current) + return + const newTop = positionRef.current.getBoundingClientRect().top window.scrollBy(0, newTop - initialTop) }) @@ -220,27 +244,27 @@ function usePreventLayoutShift() { } } -function useTabGroupProps(availableLanguages: string[]) { - const [preferredLanguages, addPreferredLanguage] = useState<any>([]) - const [selectedIndex, setSelectedIndex] = useState(0) - const activeLanguage = [...(availableLanguages || [])].sort( - (a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a), - )[0] - const languageIndex = availableLanguages?.indexOf(activeLanguage!) || 0 - const newSelectedIndex = languageIndex === -1 ? selectedIndex : languageIndex - if (newSelectedIndex !== selectedIndex) - setSelectedIndex(newSelectedIndex) - +function useTabGroupProps(tabValues: string[]) { + const [selectedValue, setSelectedValue] = useState(tabValues[0] ?? '') const { positionRef, preventLayoutShift } = usePreventLayoutShift() + const value = tabValues.includes(selectedValue) + ? selectedValue + : tabValues[0] ?? '' return { - as: 'div', ref: positionRef, - selectedIndex, - onChange: (newSelectedIndex: number) => { - preventLayoutShift(() => - (addPreferredLanguage(availableLanguages[newSelectedIndex]) as any), - ) + value, + onValueChange: (newValue: string | number | null) => { + if (newValue == null) + return + + const nextValue = String(newValue) + if (!tabValues.includes(nextValue)) + return + + preventLayoutShift(() => { + setSelectedValue(nextValue) + }) }, } } @@ -260,24 +284,35 @@ type CodeGroupProps = PropsWithChildren<{ export function CodeGroup({ children, title, targetCode, ...props }: CodeGroupProps) { const examples = typeof targetCode === 'string' ? [{ code: targetCode }] as CodeExample[] : targetCode - const tabTitles = examples?.map(({ title }) => title || 'Code') || [] - const tabGroupProps = useTabGroupProps(tabTitles) - const hasTabs = tabTitles.length > 1 - const Container = hasTabs ? TabGroup : 'div' - const containerProps = hasTabs ? tabGroupProps : {} - const headerProps = hasTabs - ? { selectedIndex: tabGroupProps.selectedIndex, tabTitles } - : {} + const tabs = examples?.map(({ title }, index) => ({ + title: title || 'Code', + value: String(index), + })) || [] + const tabGroupProps = useTabGroupProps(tabs.map(tab => tab.value)) + const hasTabs = tabs.length > 1 + const content = ( + <> + <CodeGroupHeader title={title} tabs={hasTabs ? tabs : undefined} /> + <CodeGroupPanels {...props} targetCode={examples} tabs={hasTabs ? tabs : undefined}>{children}</CodeGroupPanels> + </> + ) return ( <CodeGroupContext.Provider value={true}> - <Container - {...containerProps} - className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10" - > - <CodeGroupHeader title={title} {...headerProps} /> - <CodeGroupPanels {...props} targetCode={examples}>{children}</CodeGroupPanels> - </Container> + {hasTabs + ? ( + <Tabs + {...tabGroupProps} + className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10" + > + {content} + </Tabs> + ) + : ( + <div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"> + {content} + </div> + )} </CodeGroupContext.Provider> ) } diff --git a/web/app/components/workflow/nodes/llm/components/__tests__/panel-output-section.spec.tsx b/web/app/components/workflow/nodes/llm/components/__tests__/panel-output-section.spec.tsx index 65ff728292..97d5a8eb27 100644 --- a/web/app/components/workflow/nodes/llm/components/__tests__/panel-output-section.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/__tests__/panel-output-section.spec.tsx @@ -33,7 +33,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ vi.mock('../structure-output', () => ({ __esModule: true, - default: (props: { className?: string, value?: StructuredOutput, onChange: (value: StructuredOutput) => void }) => { + StructureOutput: (props: { className?: string, value?: StructuredOutput, onChange: (value: StructuredOutput) => void }) => { mockStructureOutput(props) return <div data-testid="structure-output">structured-output</div> }, diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx index 66ea3bfc59..fa5166a06f 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx @@ -1,8 +1,6 @@ -import type { FC } from 'react' import type { SchemaRoot } from '../../types' -import * as React from 'react' import Modal from '../../../../../base/modal' -import JsonSchemaConfig from './json-schema-config' +import { JsonSchemaConfig } from './json-schema-config' type JsonSchemaConfigModalProps = { isShow: boolean @@ -11,12 +9,12 @@ type JsonSchemaConfigModalProps = { onClose: () => void } -const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({ +export function JsonSchemaConfigModal({ isShow, defaultSchema, onSave, onClose, -}) => { +}: JsonSchemaConfigModalProps) { return ( <Modal isShow={isShow} @@ -31,5 +29,3 @@ const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({ </Modal> ) } - -export default JsonSchemaConfigModal diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx index 96c508b48f..c8dbf3ab90 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx @@ -1,13 +1,12 @@ -import type { FC } from 'react' import type { SchemaRoot } from '../../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { useCallback, useState } from 'react' +import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { JSON_SCHEMA_MAX_DEPTH } from '@/config' -import { SegmentedControl } from '../../../../../base/segmented-control' import { Type } from '../../types' import { checkJsonSchemaDepth, @@ -35,11 +34,15 @@ enum SchemaView { JsonSchema = 'jsonSchema', } -const TimelineViewIcon: FC<{ className?: string }> = ({ className }) => { +type IconProps = { + className?: string +} + +function TimelineViewIcon({ className }: IconProps) { return <span className={cn('i-ri-timeline-view', className)} /> } -const BracesIcon: FC<{ className?: string }> = ({ className }) => { +function BracesIcon({ className }: IconProps) { return <span className={cn('i-ri-braces-line', className)} /> } @@ -55,13 +58,13 @@ const DEFAULT_SCHEMA: SchemaRoot = { additionalProperties: false, } -const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ +function JsonSchemaConfigContent({ defaultSchema, onSave, onClose, -}) => { +}: JsonSchemaConfigProps) { const { t } = useTranslation() - const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor) + const [currentTab, setCurrentTab] = useState<readonly SchemaView[]>([SchemaView.VisualEditor]) const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA) const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2)) const [btnWidth, setBtnWidth] = useState(0) @@ -73,15 +76,16 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField) const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty) const { emit } = useMittContext() + const selectedTab = currentTab[0] ?? SchemaView.VisualEditor - const updateBtnWidth = useCallback((width: number) => { + function updateBtnWidth(width: number) { setBtnWidth(width + 32) - }, []) + } - const handleTabChange = useCallback((value: SchemaView) => { - if (currentTab === value) + function handleTabChange(value: SchemaView) { + if (selectedTab === value) return - if (currentTab === SchemaView.JsonSchema) { + if (selectedTab === SchemaView.JsonSchema) { try { const schema = JSON.parse(json) setParseError(null) @@ -112,41 +116,41 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ return } } - else if (currentTab === SchemaView.VisualEditor) { + else if (selectedTab === SchemaView.VisualEditor) { if (advancedEditing || isAddingNewField) emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) }) else setJson(JSON.stringify(jsonSchema, null, 2)) } - setCurrentTab(value) - }, [currentTab, jsonSchema, json, advancedEditing, isAddingNewField, emit]) + setCurrentTab([value]) + } - const handleApplySchema = useCallback((schema: SchemaRoot) => { - if (currentTab === SchemaView.VisualEditor) + function handleApplySchema(schema: SchemaRoot) { + if (selectedTab === SchemaView.VisualEditor) setJsonSchema(schema) - else if (currentTab === SchemaView.JsonSchema) + else if (selectedTab === SchemaView.JsonSchema) setJson(JSON.stringify(schema, null, 2)) - }, [currentTab]) + } - const handleSubmit = useCallback((schema: Record<string, unknown>) => { + function handleSubmit(schema: Record<string, unknown>) { const jsonSchema = jsonToSchema(schema) as SchemaRoot - if (currentTab === SchemaView.VisualEditor) + if (selectedTab === SchemaView.VisualEditor) setJsonSchema(jsonSchema) - else if (currentTab === SchemaView.JsonSchema) + else if (selectedTab === SchemaView.JsonSchema) setJson(JSON.stringify(jsonSchema, null, 2)) - }, [currentTab]) + } - const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => { + function handleVisualEditorUpdate(schema: SchemaRoot) { setJsonSchema(schema) - }, []) + } - const handleSchemaEditorUpdate = useCallback((schema: string) => { + function handleSchemaEditorUpdate(schema: string) { setJson(schema) - }, []) + } - const handleResetDefaults = useCallback(() => { - if (currentTab === SchemaView.VisualEditor) { + function handleResetDefaults() { + if (selectedTab === SchemaView.VisualEditor) { setHoveringProperty(null) if (advancedEditing) setAdvancedEditing(false) @@ -155,15 +159,15 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ } setJsonSchema(DEFAULT_SCHEMA) setJson(JSON.stringify(DEFAULT_SCHEMA, null, 2)) - }, [currentTab, advancedEditing, isAddingNewField, setAdvancedEditing, setIsAddingNewField, setHoveringProperty]) + } - const handleCancel = useCallback(() => { + function handleCancel() { onClose() - }, [onClose]) + } - const handleSave = useCallback(() => { + function handleSave() { let schema = jsonSchema - if (currentTab === SchemaView.JsonSchema) { + if (selectedTab === SchemaView.JsonSchema) { try { schema = JSON.parse(json) setParseError(null) @@ -194,7 +198,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ return } } - else if (currentTab === SchemaView.VisualEditor) { + else if (selectedTab === SchemaView.VisualEditor) { if (advancedEditing || isAddingNewField) { toast.warning(t('nodes.llm.jsonSchema.warningTips.saveSchema', { ns: 'workflow' })) return @@ -202,7 +206,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ } onSave(schema) onClose() - }, [currentTab, jsonSchema, json, onSave, onClose, advancedEditing, isAddingNewField, t]) + } return ( <div className="flex h-full flex-col"> @@ -211,18 +215,34 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ <div className="grow truncate title-2xl-semi-bold text-text-primary"> {t('nodes.llm.jsonSchema.title', { ns: 'workflow' })} </div> - <div className="absolute top-5 right-5 flex h-8 w-8 items-center justify-center p-1.5" onClick={onClose}> + <button + type="button" + className="absolute top-5 right-5 flex h-8 w-8 items-center justify-center p-1.5" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onClose} + > <span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" /> - </div> + </button> </div> {/* Content */} <div className="flex items-center justify-between px-6 py-2"> {/* Tab */} - <SegmentedControl<SchemaView> - options={VIEW_TABS} + <ToggleGroup<SchemaView> + aria-label={t('nodes.llm.jsonSchema.title', { ns: 'workflow' })} value={currentTab} - onChange={handleTabChange} - /> + onValueChange={(nextTab) => { + const value = nextTab[0] + if (value) + handleTabChange(value) + }} + > + {VIEW_TABS.map(({ Icon, text, value }) => ( + <ToggleGroupItem key={value} value={value}> + <Icon className="size-4 shrink-0" /> + <span className="p-0.5">{text}</span> + </ToggleGroupItem> + ))} + </ToggleGroup> <div className="flex items-center gap-x-0.5"> {/* JSON Schema Generator */} <JsonSchemaGenerator @@ -238,13 +258,13 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ </div> </div> <div className="flex grow flex-col gap-y-1 overflow-hidden px-6"> - {currentTab === SchemaView.VisualEditor && ( + {selectedTab === SchemaView.VisualEditor && ( <VisualEditor schema={jsonSchema} onChange={handleVisualEditorUpdate} /> )} - {currentTab === SchemaView.JsonSchema && ( + {selectedTab === SchemaView.JsonSchema && ( <SchemaEditor schema={json} onUpdate={handleSchemaEditorUpdate} @@ -276,14 +296,12 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ ) } -const JsonSchemaConfigWrapper: FC<JsonSchemaConfigProps> = (props) => { +export function JsonSchemaConfig(props: JsonSchemaConfigProps) { return ( <MittProvider> <VisualEditorContextProvider> - <JsonSchemaConfig {...props} /> + <JsonSchemaConfigContent {...props} /> </VisualEditorContextProvider> </MittProvider> ) } - -export default JsonSchemaConfigWrapper diff --git a/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx b/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx index 53f7161fba..82b0a24aef 100644 --- a/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx +++ b/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import Split from '@/app/components/workflow/nodes/_base/components/split' -import StructureOutput from './structure-output' +import { StructureOutput } from './structure-output' type Props = { readOnly: boolean diff --git a/web/app/components/workflow/nodes/llm/components/structure-output.tsx b/web/app/components/workflow/nodes/llm/components/structure-output.tsx index d8b3e132bb..c7dadab269 100644 --- a/web/app/components/workflow/nodes/llm/components/structure-output.tsx +++ b/web/app/components/workflow/nodes/llm/components/structure-output.tsx @@ -1,16 +1,12 @@ 'use client' -import type { FC } from 'react' import type { SchemaRoot, StructuredOutput } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiEditLine } from '@remixicon/react' import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import ShowPanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show' import { Type } from '../types' -import JsonSchemaConfigModal from './json-schema-config-modal' +import { JsonSchemaConfigModal } from './json-schema-config-modal' type Props = { className?: string @@ -18,22 +14,23 @@ type Props = { onChange: (value: StructuredOutput) => void } -const StructureOutput: FC<Props> = ({ +export function StructureOutput({ className, value, onChange, -}) => { +}: Props) { const { t } = useTranslation() const [showConfig, { setTrue: showConfigModal, setFalse: hideConfigModal, }] = useBoolean(false) - const handleChange = useCallback((value: SchemaRoot) => { + function handleChange(value: SchemaRoot) { onChange({ schema: value, }) - }, [onChange]) + } + return ( <div className={cn(className)}> <div className="flex justify-between"> @@ -47,7 +44,7 @@ const StructureOutput: FC<Props> = ({ className="flex" onClick={showConfigModal} > - <RiEditLine className="mr-1 size-3.5" /> + <i className="mr-1 i-ri-edit-line size-3.5" aria-hidden="true" /> <div className="system-xs-medium text-components-button-secondary-text">{t('structOutput.configure', { ns: 'app' })}</div> </Button> </div> @@ -58,7 +55,13 @@ const StructureOutput: FC<Props> = ({ /> ) : ( - <div className="mt-1.5 flex h-10 cursor-pointer items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary" onClick={showConfigModal}>{t('structOutput.notConfiguredTip', { ns: 'app' })}</div> + <button + type="button" + className="mt-1.5 flex h-10 w-full cursor-pointer items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary" + onClick={showConfigModal} + > + {t('structOutput.notConfiguredTip', { ns: 'app' })} + </button> )} {showConfig && ( @@ -77,4 +80,3 @@ const StructureOutput: FC<Props> = ({ </div> ) } -export default React.memo(StructureOutput) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx index c0a769ffb0..26609885d2 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx @@ -3,7 +3,6 @@ import type { ScheduleTriggerNodeType } from '../../types' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import FrequencySelector from '../frequency-selector' -import ModeSwitcher from '../mode-switcher' import ModeToggle from '../mode-toggle' import MonthlyDaysSelector from '../monthly-days-selector' import NextExecutionTimes from '../next-execution-times' @@ -53,16 +52,6 @@ describe('trigger-schedule components', () => { }) }) - it('should switch between visual and cron modes', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render(<ModeSwitcher mode="visual" onChange={onChange} />) - - await user.click(screen.getByText('workflow.nodes.triggerSchedule.modeCron')) - - expect(onChange).toHaveBeenCalledWith('cron') - }) - it('should toggle the mode from visual to cron', async () => { const user = userEvent.setup() const onChange = vi.fn() diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/mode-switcher.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/mode-switcher.spec.tsx deleted file mode 100644 index c4d5af5b11..0000000000 --- a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/mode-switcher.spec.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import ModeSwitcher from '../mode-switcher' - -describe('trigger-schedule/mode-switcher', () => { - it('switches between visual and cron modes', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render(<ModeSwitcher mode="visual" onChange={onChange} />) - - await user.click(screen.getByText('workflow.nodes.triggerSchedule.modeCron')) - - expect(onChange).toHaveBeenCalledWith('cron') - }) -}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx deleted file mode 100644 index bb914e313f..0000000000 --- a/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { ScheduleMode } from '../types' -import { RiCalendarLine, RiCodeLine } from '@remixicon/react' -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import { SegmentedControl } from '@/app/components/base/segmented-control' - -type ModeSwitcherProps = { - mode: ScheduleMode - onChange: (mode: ScheduleMode) => void -} - -const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => { - const { t } = useTranslation() - - const options = [ - { - Icon: RiCalendarLine, - text: t('nodes.triggerSchedule.modeVisual', { ns: 'workflow' }), - value: 'visual' as const, - }, - { - Icon: RiCodeLine, - text: t('nodes.triggerSchedule.modeCron', { ns: 'workflow' }), - value: 'cron' as const, - }, - ] - - return ( - <SegmentedControl - options={options} - value={mode} - onChange={onChange} - /> - ) -} - -export default ModeSwitcher diff --git a/web/app/components/workflow/variable-inspect/__tests__/display-content.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/display-content.spec.tsx index bbc4714a92..9ba5763576 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/display-content.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/display-content.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import DisplayContent from '../display-content' +import { DisplayContent } from '../display-content' import { PreviewType } from '../types' describe('variable inspect display content', () => { diff --git a/web/app/components/workflow/variable-inspect/display-content.tsx b/web/app/components/workflow/variable-inspect/display-content.tsx index 2edd84d0de..11ee2a7a42 100644 --- a/web/app/components/workflow/variable-inspect/display-content.tsx +++ b/web/app/components/workflow/variable-inspect/display-content.tsx @@ -2,12 +2,10 @@ import type { VarType } from '../types' import type { ChunkInfo } from '@/app/components/rag-pipeline/components/chunk-card-list/types' import type { ParentMode } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import { RiBracesLine, RiEyeLine } from '@remixicon/react' -import * as React from 'react' +import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Markdown } from '@/app/components/base/markdown' -import { SegmentedControl } from '@/app/components/base/segmented-control' import Textarea from '@/app/components/base/textarea' import { ChunkCardList } from '@/app/components/rag-pipeline/components/chunk-card-list' import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor' @@ -26,11 +24,16 @@ type DisplayContentProps = { className?: string } -const DisplayContent = (props: DisplayContentProps) => { +export function DisplayContent(props: DisplayContentProps) { const { previewType, varType, schemaType, mdString, jsonString, readonly, handleTextChange, handleEditorChange, className } = props - const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Code) + const [viewMode, setViewMode] = useState<readonly ViewMode[]>([ViewMode.Code]) const [isFocused, setIsFocused] = useState(false) const { t } = useTranslation() + const viewOptions = [ + { value: ViewMode.Code, label: t('nodes.templateTransform.code', { ns: 'workflow' }), iconClassName: 'i-ri-braces-line' }, + { value: ViewMode.Preview, label: t('common.preview', { ns: 'workflow' }), iconClassName: 'i-ri-eye-line' }, + ] + const selectedViewMode = viewMode[0] ?? ViewMode.Code const chunkType = useMemo(() => { if (previewType !== PreviewType.Chunks || !schemaType) @@ -65,22 +68,26 @@ const DisplayContent = (props: DisplayContentProps) => { {schemaType ? `(${schemaType})` : ''} </div> )} - <SegmentedControl - options={[ - { value: ViewMode.Code, text: t('nodes.templateTransform.code', { ns: 'workflow' }), Icon: RiBracesLine }, - { value: ViewMode.Preview, text: t('common.preview', { ns: 'workflow' }), Icon: RiEyeLine }, - ]} + <ToggleGroup<ViewMode> + aria-label={t('common.preview', { ns: 'workflow' })} value={viewMode} - onChange={setViewMode} - size="small" - padding="with" - activeClassName="text-text-accent-light-mode-only!" - btnClassName="pl-1.5! pr-0.5! gap-[3px]" - className="shrink-0" - /> + onValueChange={setViewMode} + className="shrink-0 rounded-md p-px" + > + {viewOptions.map(({ value, label, iconClassName }) => ( + <ToggleGroupItem + key={value} + value={value} + className="h-[22px] gap-[3px] rounded-md p-px pr-0.5 pl-1.5 text-text-tertiary data-pressed:text-text-accent-light-mode-only" + > + <i className={cn('size-4 shrink-0', iconClassName)} aria-hidden="true" /> + <span className="p-0.5 pr-1">{label}</span> + </ToggleGroupItem> + ))} + </ToggleGroup> </div> <div className="flex flex-1 overflow-auto rounded-b-[10px] pr-1 pl-3"> - {viewMode === ViewMode.Code && ( + {selectedViewMode === ViewMode.Code && ( previewType === PreviewType.Markdown ? ( <Textarea @@ -105,7 +112,7 @@ const DisplayContent = (props: DisplayContentProps) => { /> ) )} - {viewMode === ViewMode.Preview && ( + {selectedViewMode === ViewMode.Preview && ( previewType === PreviewType.Markdown ? <Markdown className="grow overflow-auto rounded-lg px-4 py-3" content={(mdString ?? '') as string} /> : ( @@ -120,5 +127,3 @@ const DisplayContent = (props: DisplayContentProps) => { </div> ) } - -export default React.memo(DisplayContent) diff --git a/web/app/components/workflow/variable-inspect/value-content-sections.tsx b/web/app/components/workflow/variable-inspect/value-content-sections.tsx index 8d892343cd..8fb6c14d2f 100644 --- a/web/app/components/workflow/variable-inspect/value-content-sections.tsx +++ b/web/app/components/workflow/variable-inspect/value-content-sections.tsx @@ -11,7 +11,7 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { PreviewMode } from '../../base/features/types' import BoolValue from '../panel/chat-variable-panel/components/bool-value' -import DisplayContent from './display-content' +import { DisplayContent } from './display-content' import LargeDataAlert from './large-data-alert' import { PreviewType } from './types' diff --git a/web/app/styles/tailwind-core.css b/web/app/styles/tailwind-core.css index 91af9df192..614a46652b 100644 --- a/web/app/styles/tailwind-core.css +++ b/web/app/styles/tailwind-core.css @@ -28,7 +28,6 @@ @import '../components/base/action-button/index.css'; @import '../components/base/badge/index.css'; @import '../components/base/premium-badge/index.css'; -@import '../components/base/segmented-control/index.css'; /* ---------- JS plugins ------------------------------------------------ */ @plugin './plugins/icons.ts'; From 4a56763d2fa0c18114ae304de534e3a8b5be911a Mon Sep 17 00:00:00 2001 From: chariri <w@chariri.moe> Date: Sat, 9 May 2026 17:34:15 +0900 Subject: [PATCH 11/53] refactor(api): migrate console.app.workflow etc. to BaseModel (#35967) --- api/AGENTS.md | 4 + api/controllers/API_SCHEMA_GUIDE.md | 193 +++++++++++ api/controllers/common/schema.py | 30 +- api/controllers/console/app/workflow.py | 71 ++-- api/controllers/console/app/workflow_run.py | 314 +++++++----------- .../rag_pipeline/rag_pipeline_workflow.py | 77 +++-- api/fields/workflow_run_fields.py | 131 +------- api/openapi/markdown/console-swagger.md | 291 ++++++++-------- .../test_rag_pipeline_workflow.py | 41 ++- .../controllers/common/test_schema.py | 18 + .../app/test_workflow_pause_details_api.py | 21 ++ .../console/app/test_workflow_run_api.py | 248 ++++++++++++++ 12 files changed, 941 insertions(+), 498 deletions(-) create mode 100644 api/controllers/API_SCHEMA_GUIDE.md create mode 100644 api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py diff --git a/api/AGENTS.md b/api/AGENTS.md index 8e5d9f600d..eb4404509d 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -193,6 +193,10 @@ Before opening a PR / submitting: - Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic. - Services: coordinate repositories, providers, background tasks; keep side effects explicit. - Document non-obvious behaviour with concise docstrings and comments. +- For Flask-RESTX controller request, query, and response schemas, follow `controllers/API_SCHEMA_GUIDE.md`. + In short: use Pydantic models, document GET query params with `query_params_from_model(...)`, register response + DTOs with `register_response_schema_models(...)`, serialize with `ResponseModel.model_validate(...).model_dump(...)`, + and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns. ### Miscellaneous diff --git a/api/controllers/API_SCHEMA_GUIDE.md b/api/controllers/API_SCHEMA_GUIDE.md new file mode 100644 index 0000000000..5b1b055b09 --- /dev/null +++ b/api/controllers/API_SCHEMA_GUIDE.md @@ -0,0 +1,193 @@ +# API Schema Guide + +This guide describes the expected Flask-RESTX + Pydantic pattern for controller request payloads, query +parameters, response schemas, and Swagger documentation. + +## Principles + +- Use Pydantic `BaseModel` for request bodies and query parameters. +- Use `fields.base.ResponseModel` for response DTOs. +- Keep runtime validation and Swagger documentation wired to the same Pydantic model. +- Prefer explicit validation and serialization in controller methods over Flask-RESTX marshalling. +- Do not add new Flask-RESTX `fields.*` dictionaries, `Namespace.model(...)` exports, or `@marshal_with(...)` for migrated or new endpoints. +- Do not use `@ns.expect(...)` for GET query parameters. Flask-RESTX documents that as a request body. + +## Naming + +- Request body models: use a `Payload` suffix. + - Example: `WorkflowRunPayload`, `DatasourceVariablesPayload`. +- Query parameter models: use a `Query` suffix. + - Example: `WorkflowRunListQuery`, `MessageListQuery`. +- Response models: use a `Response` suffix and inherit from `ResponseModel`. + - Example: `WorkflowRunDetailResponse`, `WorkflowRunNodeExecutionListResponse`. +- Use `ListResponse` or `PaginationResponse` for wrapper responses. + - Example: `WorkflowRunNodeExecutionListResponse`, `WorkflowRunPaginationResponse`. +- Keep these models near the controller when they are endpoint-specific. Move them to `fields/*_fields.py` only when shared by multiple controllers. + +## Registering Models For Swagger + +Use helpers from `controllers.common.schema`. + +```python +from controllers.common.schema import ( + query_params_from_model, + register_response_schema_models, + register_schema_models, +) +``` + +Register request payload and query models with `register_schema_models(...)`: + +```python +register_schema_models( + console_ns, + WorkflowRunPayload, + WorkflowRunListQuery, +) +``` + +Register response models with `register_response_schema_models(...)`: + +```python +register_response_schema_models( + console_ns, + WorkflowRunDetailResponse, + WorkflowRunPaginationResponse, +) +``` + +Response models are registered in Pydantic serialization mode. This matters when a response model uses +`validation_alias` to read internal object attributes but emits public API field names. For example, a response model +can validate from `inputs_dict` while documenting and serializing `inputs`. + +## Request Bodies + +For non-GET request bodies: + +1. Define a Pydantic `Payload` model. +2. Register it with `register_schema_models(...)`. +3. Use `@ns.expect(ns.models[Payload.__name__])` for Swagger documentation. +4. Validate from `ns.payload or {}` inside the controller. + +```python +class DraftWorkflowNodeRunPayload(BaseModel): + inputs: dict[str, Any] + query: str = "" + + +register_schema_models(console_ns, DraftWorkflowNodeRunPayload) + + +@console_ns.expect(console_ns.models[DraftWorkflowNodeRunPayload.__name__]) +def post(self, app_model: App, node_id: str): + payload = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {}) + result = service.run(..., inputs=payload.inputs, query=payload.query) + return WorkflowRunNodeExecutionResponse.model_validate(result, from_attributes=True).model_dump(mode="json") +``` + +## Query Parameters + +For GET query parameters: + +1. Define a Pydantic `Query` model. +2. Register it with `register_schema_models(...)` if it is referenced elsewhere in docs, or only use + `query_params_from_model(...)` if a body schema is not needed. +3. Use `@ns.doc(params=query_params_from_model(QueryModel))`. +4. Validate from `request.args.to_dict(flat=True)` or an explicit dict when type coercion is needed. + +```python +class WorkflowRunListQuery(BaseModel): + last_id: str | None = Field(default=None, description="Last run ID for pagination") + limit: int = Field(default=20, ge=1, le=100, description="Number of items per page (1-100)") + + +@console_ns.doc(params=query_params_from_model(WorkflowRunListQuery)) +def get(self, app_model: App): + query = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) + result = service.list(..., limit=query.limit, last_id=query.last_id) + return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") +``` + +Do not do this for GET query parameters: + +```python +@console_ns.expect(console_ns.models[WorkflowRunListQuery.__name__]) +def get(...): + ... +``` + +That documents a GET request body and is not the expected contract. + +## Responses + +Response models should inherit from `ResponseModel`: + +```python +class WorkflowRunNodeExecutionResponse(ResponseModel): + id: str + inputs: Any = Field(default=None, validation_alias="inputs_dict") + process_data: Any = Field(default=None, validation_alias="process_data_dict") + outputs: Any = Field(default=None, validation_alias="outputs_dict") +``` + +Document response models with `@ns.response(...)`: + +```python +@console_ns.response( + 200, + "Node run started successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], +) +def post(...): + ... +``` + +Serialize explicitly: + +```python +return WorkflowRunNodeExecutionResponse.model_validate( + workflow_node_execution, + from_attributes=True, +).model_dump(mode="json") +``` + +If the service can return `None`, translate that into the expected HTTP error before validation: + +```python +workflow_run = service.get_workflow_run(...) +if workflow_run is None: + raise NotFound("Workflow run not found") + +return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") +``` + +## Legacy Flask-RESTX Patterns + +Avoid adding these patterns to new or migrated endpoints: + +- `ns.model(...)` for new request/response DTOs. +- Module-level exported RESTX model objects such as `workflow_run_detail_model`. +- `fields.Nested({...})` with raw inline dict field maps. +- `@marshal_with(...)` for response serialization. +- `@ns.expect(...)` for GET query params. + +Existing legacy field dictionaries may remain where an endpoint has not yet been migrated. Keep that compatibility local +to the legacy area and avoid importing RESTX model objects from controllers. + +## Verifying Swagger + +For schema and documentation changes, run focused tests and generate Swagger JSON: + +```bash +uv run --project . pytest tests/unit_tests/controllers/common/test_schema.py +uv run --project . pytest tests/unit_tests/commands/test_generate_swagger_specs.py tests/unit_tests/controllers/test_swagger.py +uv run --project . dev/generate_swagger_specs.py --output-dir /tmp/dify-openapi-check +``` + +Inspect affected endpoints with `jq`. Check that: + +- GET parameters are `in: query`. +- Request bodies appear only where the endpoint has a body. +- Responses reference the expected `*Response` schema. +- Response schemas use public serialized names, not internal validation aliases like `inputs_dict`. + diff --git a/api/controllers/common/schema.py b/api/controllers/common/schema.py index 57070f1c80..58140f3ac8 100644 --- a/api/controllers/common/schema.py +++ b/api/controllers/common/schema.py @@ -8,7 +8,7 @@ These helpers keep that translation centralized so models registered through from collections.abc import Mapping from enum import StrEnum -from typing import Any, NotRequired, TypedDict +from typing import Any, Literal, NotRequired, TypedDict from flask_restx import Namespace from pydantic import BaseModel, TypeAdapter @@ -54,16 +54,23 @@ def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None _register_json_schema(namespace, nested_name, nested_schema) -def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: - """Register a BaseModel and its nested schema definitions for Swagger documentation.""" +JsonSchemaMode = Literal["validation", "serialization"] + +def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode: JsonSchemaMode) -> None: _register_json_schema( namespace, model.__name__, - model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), + model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0, mode=mode), ) +def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: + """Register a BaseModel and its nested schema definitions for Swagger documentation.""" + + _register_schema_model(namespace, model, mode="validation") + + def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None: """Register multiple BaseModels with a namespace.""" @@ -71,6 +78,19 @@ def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> No register_schema_model(namespace, model) +def register_response_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: + """Register a BaseModel using its serialized response shape.""" + + _register_schema_model(namespace, model, mode="serialization") + + +def register_response_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None: + """Register multiple response BaseModels using their serialized response shape.""" + + for model in models: + register_response_schema_model(namespace, model) + + def get_or_create_model(model_name: str, field_def): # Import lazily to avoid circular imports between console controllers and schema helpers. from controllers.console import console_ns @@ -190,6 +210,8 @@ __all__ = [ "get_or_create_model", "query_params_from_model", "register_enum_models", + "register_response_schema_model", + "register_response_schema_models", "register_schema_model", "register_schema_models", ] diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 68dd8b7a8d..e18688f069 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -11,9 +11,9 @@ from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotF import services from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload +from controllers.common.schema import register_response_schema_model, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync -from controllers.console.app.workflow_run import workflow_run_node_execution_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError @@ -37,6 +37,7 @@ from factories import file_factory, variable_factory from fields.member_fields import simple_account_fields from fields.online_user_fields import online_user_list_fields from fields.workflow_fields import workflow_fields, workflow_pagination_fields +from fields.workflow_run_fields import WorkflowRunNodeExecutionResponse from graphon.enums import NodeType from graphon.file import File from graphon.file import helpers as file_helpers @@ -56,6 +57,7 @@ from services.errors.llm import InvokeRateLimitError from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService logger = logging.getLogger(__name__) + _file_access_controller = DatabaseFileAccessController() LISTENING_RETRY_IN = 2000 DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" @@ -176,25 +178,25 @@ class DraftWorkflowTriggerRunAllPayload(BaseModel): node_ids: list[str] -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(SyncDraftWorkflowPayload) -reg(AdvancedChatWorkflowRunPayload) -reg(IterationNodeRunPayload) -reg(LoopNodeRunPayload) -reg(DraftWorkflowRunPayload) -reg(DraftWorkflowNodeRunPayload) -reg(PublishWorkflowPayload) -reg(DefaultBlockConfigQuery) -reg(ConvertToWorkflowPayload) -reg(WorkflowListQuery) -reg(WorkflowUpdatePayload) -reg(WorkflowFeaturesPayload) -reg(WorkflowOnlineUsersPayload) -reg(DraftWorkflowTriggerRunPayload) -reg(DraftWorkflowTriggerRunAllPayload) +register_schema_models( + console_ns, + SyncDraftWorkflowPayload, + AdvancedChatWorkflowRunPayload, + IterationNodeRunPayload, + LoopNodeRunPayload, + DraftWorkflowRunPayload, + DraftWorkflowNodeRunPayload, + PublishWorkflowPayload, + DefaultBlockConfigQuery, + ConvertToWorkflowPayload, + WorkflowListQuery, + WorkflowUpdatePayload, + WorkflowFeaturesPayload, + WorkflowOnlineUsersPayload, + DraftWorkflowTriggerRunPayload, + DraftWorkflowTriggerRunAllPayload, +) +register_response_schema_model(console_ns, WorkflowRunNodeExecutionResponse) # TODO(QuantumGhost): Refactor existing node run API to handle file parameter parsing @@ -540,9 +542,12 @@ class HumanInputDeliveryTestPayload(BaseModel): ) -reg(HumanInputFormPreviewPayload) -reg(HumanInputFormSubmitPayload) -reg(HumanInputDeliveryTestPayload) +register_schema_models( + console_ns, + HumanInputFormPreviewPayload, + HumanInputFormSubmitPayload, + HumanInputDeliveryTestPayload, +) @console_ns.route("/apps/<uuid:app_id>/advanced-chat/workflows/draft/human-input/nodes/<string:node_id>/form/preview") @@ -760,14 +765,17 @@ class DraftWorkflowNodeRunApi(Resource): @console_ns.doc(description="Run draft workflow node") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[DraftWorkflowNodeRunPayload.__name__]) - @console_ns.response(200, "Node run started successfully", workflow_run_node_execution_model) + @console_ns.response( + 200, + "Node run started successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_node_execution_model) @edit_permission_required def post(self, app_model: App, node_id: str): """ @@ -799,7 +807,9 @@ class DraftWorkflowNodeRunApi(Resource): files=files, ) - return workflow_node_execution + return WorkflowRunNodeExecutionResponse.model_validate( + workflow_node_execution, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/apps/<uuid:app_id>/workflows/publish") @@ -1143,14 +1153,17 @@ class DraftWorkflowNodeLastRunApi(Resource): @console_ns.doc("get_draft_workflow_node_last_run") @console_ns.doc(description="Get last run result for draft workflow node") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) - @console_ns.response(200, "Node last run retrieved successfully", workflow_run_node_execution_model) + @console_ns.response( + 200, + "Node last run retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @console_ns.response(404, "Node last run not found") @console_ns.response(403, "Permission denied") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_node_execution_model) def get(self, app_model: App, node_id: str): srv = WorkflowService() workflow = srv.get_draft_workflow(app_model) @@ -1163,7 +1176,7 @@ class DraftWorkflowNodeLastRunApi(Resource): ) if node_exec is None: raise NotFound("last run not found") - return node_exec + return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps/<uuid:app_id>/workflows/draft/trigger/run") diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index 6748d95d6b..e42aae6090 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -1,30 +1,28 @@ from datetime import UTC, datetime, timedelta -from typing import Literal, TypedDict, cast +from typing import Literal, cast from flask import request -from flask_restx import Resource, fields, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.orm import sessionmaker from configs import dify_config +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required from controllers.web.error import NotFoundError from core.workflow.human_input_forms import load_form_tokens_by_form_id as _load_form_tokens_by_form_id from extensions.ext_database import db -from fields.end_user_fields import simple_end_user_fields -from fields.member_fields import simple_account_fields +from fields.base import ResponseModel from fields.workflow_run_fields import ( - advanced_chat_workflow_run_for_list_fields, - advanced_chat_workflow_run_pagination_fields, - workflow_run_count_fields, - workflow_run_detail_fields, - workflow_run_for_list_fields, - workflow_run_node_execution_fields, - workflow_run_node_execution_list_fields, - workflow_run_pagination_fields, + AdvancedChatWorkflowRunPaginationResponse, + WorkflowRunCountResponse, + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunPaginationResponse, ) from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus @@ -52,82 +50,6 @@ def _build_backstage_input_url(form_token: str | None) -> str | None: WORKFLOW_RUN_STATUS_CHOICES = ["running", "succeeded", "failed", "stopped", "partial-succeeded"] EXPORT_SIGNED_URL_EXPIRE_SECONDS = 3600 -# Register models for flask_restx to avoid dict type issues in Swagger -# Register in dependency order: base models first, then dependent models - -# Base models -simple_account_model = console_ns.model("SimpleAccount", simple_account_fields) - -simple_end_user_model = console_ns.model("SimpleEndUser", simple_end_user_fields) - -# Models that depend on simple_account_fields -workflow_run_for_list_fields_copy = workflow_run_for_list_fields.copy() -workflow_run_for_list_fields_copy["created_by_account"] = fields.Nested( - simple_account_model, attribute="created_by_account", allow_null=True -) -workflow_run_for_list_model = console_ns.model("WorkflowRunForList", workflow_run_for_list_fields_copy) - -advanced_chat_workflow_run_for_list_fields_copy = advanced_chat_workflow_run_for_list_fields.copy() -advanced_chat_workflow_run_for_list_fields_copy["created_by_account"] = fields.Nested( - simple_account_model, attribute="created_by_account", allow_null=True -) -advanced_chat_workflow_run_for_list_model = console_ns.model( - "AdvancedChatWorkflowRunForList", advanced_chat_workflow_run_for_list_fields_copy -) - -workflow_run_detail_fields_copy = workflow_run_detail_fields.copy() -workflow_run_detail_fields_copy["created_by_account"] = fields.Nested( - simple_account_model, attribute="created_by_account", allow_null=True -) -workflow_run_detail_fields_copy["created_by_end_user"] = fields.Nested( - simple_end_user_model, attribute="created_by_end_user", allow_null=True -) -workflow_run_detail_model = console_ns.model("WorkflowRunDetail", workflow_run_detail_fields_copy) - -workflow_run_node_execution_fields_copy = workflow_run_node_execution_fields.copy() -workflow_run_node_execution_fields_copy["created_by_account"] = fields.Nested( - simple_account_model, attribute="created_by_account", allow_null=True -) -workflow_run_node_execution_fields_copy["created_by_end_user"] = fields.Nested( - simple_end_user_model, attribute="created_by_end_user", allow_null=True -) -workflow_run_node_execution_model = console_ns.model( - "WorkflowRunNodeExecution", workflow_run_node_execution_fields_copy -) - -# Simple models without nested dependencies -workflow_run_count_model = console_ns.model("WorkflowRunCount", workflow_run_count_fields) - -# Pagination models that depend on list models -advanced_chat_workflow_run_pagination_fields_copy = advanced_chat_workflow_run_pagination_fields.copy() -advanced_chat_workflow_run_pagination_fields_copy["data"] = fields.List( - fields.Nested(advanced_chat_workflow_run_for_list_model), attribute="data" -) -advanced_chat_workflow_run_pagination_model = console_ns.model( - "AdvancedChatWorkflowRunPagination", advanced_chat_workflow_run_pagination_fields_copy -) - -workflow_run_pagination_fields_copy = workflow_run_pagination_fields.copy() -workflow_run_pagination_fields_copy["data"] = fields.List(fields.Nested(workflow_run_for_list_model), attribute="data") -workflow_run_pagination_model = console_ns.model("WorkflowRunPagination", workflow_run_pagination_fields_copy) - -workflow_run_node_execution_list_fields_copy = workflow_run_node_execution_list_fields.copy() -workflow_run_node_execution_list_fields_copy["data"] = fields.List(fields.Nested(workflow_run_node_execution_model)) -workflow_run_node_execution_list_model = console_ns.model( - "WorkflowRunNodeExecutionList", workflow_run_node_execution_list_fields_copy -) - -workflow_run_export_fields = console_ns.model( - "WorkflowRunExport", - { - "status": fields.String(description="Export status: success/failed"), - "presigned_url": fields.String(description="Pre-signed URL for download", required=False), - "presigned_url_expires_at": fields.String(description="Pre-signed URL expiration time", required=False), - }, -) - -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class WorkflowRunListQuery(BaseModel): last_id: str | None = Field(default=None, description="Last run ID for pagination") @@ -136,7 +58,7 @@ class WorkflowRunListQuery(BaseModel): default=None, description="Workflow run status filter" ) triggered_from: Literal["debugging", "app-run"] | None = Field( - default=None, description="Filter by trigger source: debugging or app-run" + default=None, description="Filter by trigger source: debugging or app-run. Default: debugging" ) @field_validator("last_id") @@ -151,9 +73,15 @@ class WorkflowRunCountQuery(BaseModel): status: Literal["running", "succeeded", "failed", "stopped", "partial-succeeded"] | None = Field( default=None, description="Workflow run status filter" ) - time_range: str | None = Field(default=None, description="Time range filter (e.g., 7d, 4h, 30m, 30s)") + time_range: str | None = Field( + default=None, + description=( + "Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), " + "30m (30 minutes), 30s (30 seconds). Filters by created_at field." + ), + ) triggered_from: Literal["debugging", "app-run"] | None = Field( - default=None, description="Filter by trigger source: debugging or app-run" + default=None, description="Filter by trigger source: debugging or app-run. Default: debugging" ) @field_validator("time_range") @@ -164,51 +92,64 @@ class WorkflowRunCountQuery(BaseModel): return time_duration(value) -console_ns.schema_model( - WorkflowRunListQuery.__name__, WorkflowRunListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -console_ns.schema_model( - WorkflowRunCountQuery.__name__, - WorkflowRunCountQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +class WorkflowRunExportResponse(ResponseModel): + status: str = Field(description="Export status: success/failed") + presigned_url: str | None = Field(default=None, description="Pre-signed URL for download") + presigned_url_expires_at: str | None = Field(default=None, description="Pre-signed URL expiration time") -class HumanInputPauseTypeResponse(TypedDict): +class HumanInputPauseTypeResponse(ResponseModel): type: Literal["human_input"] form_id: str - backstage_input_url: str | None + backstage_input_url: str | None = None -class PausedNodeResponse(TypedDict): +class PausedNodeResponse(ResponseModel): node_id: str node_title: str pause_type: HumanInputPauseTypeResponse -class WorkflowPauseDetailsResponse(TypedDict): - paused_at: str | None +class WorkflowPauseDetailsResponse(ResponseModel): + paused_at: str | None = None paused_nodes: list[PausedNodeResponse] +register_schema_models( + console_ns, + WorkflowRunListQuery, + WorkflowRunCountQuery, +) +register_response_schema_models( + console_ns, + AdvancedChatWorkflowRunPaginationResponse, + WorkflowRunPaginationResponse, + WorkflowRunCountResponse, + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunExportResponse, + HumanInputPauseTypeResponse, + PausedNodeResponse, + WorkflowPauseDetailsResponse, +) + + @console_ns.route("/apps/<uuid:app_id>/advanced-chat/workflow-runs") class AdvancedChatAppWorkflowRunListApi(Resource): @console_ns.doc("get_advanced_chat_workflow_runs") @console_ns.doc(description="Get advanced chat workflow run list") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params={"last_id": "Last run ID for pagination", "limit": "Number of items per page (1-100)"}) - @console_ns.doc( - params={"status": "Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded"} + @console_ns.doc(params=query_params_from_model(WorkflowRunListQuery)) + @console_ns.response( + 200, + "Workflow runs retrieved successfully", + console_ns.models[AdvancedChatWorkflowRunPaginationResponse.__name__], ) - @console_ns.doc( - params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"} - ) - @console_ns.expect(console_ns.models[WorkflowRunListQuery.__name__]) - @console_ns.response(200, "Workflow runs retrieved successfully", advanced_chat_workflow_run_pagination_model) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT]) - @marshal_with(advanced_chat_workflow_run_pagination_model) def get(self, app_model: App): """ Get advanced chat app workflow run list @@ -232,7 +173,9 @@ class AdvancedChatAppWorkflowRunListApi(Resource): app_model=app_model, args=args, triggered_from=triggered_from ) - return result + return AdvancedChatWorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump( + mode="json" + ) @console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:run_id>/export") @@ -240,7 +183,7 @@ class WorkflowRunExportApi(Resource): @console_ns.doc("get_workflow_run_export_url") @console_ns.doc(description="Generate a download URL for an archived workflow run.") @console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"}) - @console_ns.response(200, "Export URL generated", workflow_run_export_fields) + @console_ns.response(200, "Export URL generated", console_ns.models[WorkflowRunExportResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -278,11 +221,14 @@ class WorkflowRunExportApi(Resource): expires_in=EXPORT_SIGNED_URL_EXPIRE_SECONDS, ) expires_at = datetime.now(UTC) + timedelta(seconds=EXPORT_SIGNED_URL_EXPIRE_SECONDS) - return { - "status": "success", - "presigned_url": presigned_url, - "presigned_url_expires_at": expires_at.isoformat(), - }, 200 + response = WorkflowRunExportResponse.model_validate( + { + "status": "success", + "presigned_url": presigned_url, + "presigned_url_expires_at": expires_at.isoformat(), + } + ) + return response.model_dump(mode="json"), 200 @console_ns.route("/apps/<uuid:app_id>/advanced-chat/workflow-runs/count") @@ -290,27 +236,16 @@ class AdvancedChatAppWorkflowRunCountApi(Resource): @console_ns.doc("get_advanced_chat_workflow_runs_count") @console_ns.doc(description="Get advanced chat workflow runs count statistics") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc( - params={"status": "Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded"} + @console_ns.doc(params=query_params_from_model(WorkflowRunCountQuery)) + @console_ns.response( + 200, + "Workflow runs count retrieved successfully", + console_ns.models[WorkflowRunCountResponse.__name__], ) - @console_ns.doc( - params={ - "time_range": ( - "Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), " - "30m (30 minutes), 30s (30 seconds). Filters by created_at field." - ) - } - ) - @console_ns.doc( - params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"} - ) - @console_ns.response(200, "Workflow runs count retrieved successfully", workflow_run_count_model) - @console_ns.expect(console_ns.models[WorkflowRunCountQuery.__name__]) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT]) - @marshal_with(workflow_run_count_model) def get(self, app_model: App): """ Get advanced chat workflow runs count statistics @@ -333,7 +268,7 @@ class AdvancedChatAppWorkflowRunCountApi(Resource): triggered_from=triggered_from, ) - return result + return WorkflowRunCountResponse.model_validate(result).model_dump(mode="json") @console_ns.route("/apps/<uuid:app_id>/workflow-runs") @@ -341,20 +276,16 @@ class WorkflowRunListApi(Resource): @console_ns.doc("get_workflow_runs") @console_ns.doc(description="Get workflow run list") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params={"last_id": "Last run ID for pagination", "limit": "Number of items per page (1-100)"}) - @console_ns.doc( - params={"status": "Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded"} + @console_ns.doc(params=query_params_from_model(WorkflowRunListQuery)) + @console_ns.response( + 200, + "Workflow runs retrieved successfully", + console_ns.models[WorkflowRunPaginationResponse.__name__], ) - @console_ns.doc( - params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"} - ) - @console_ns.response(200, "Workflow runs retrieved successfully", workflow_run_pagination_model) - @console_ns.expect(console_ns.models[WorkflowRunListQuery.__name__]) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_pagination_model) def get(self, app_model: App): """ Get workflow run list @@ -378,7 +309,7 @@ class WorkflowRunListApi(Resource): app_model=app_model, args=args, triggered_from=triggered_from ) - return result + return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps/<uuid:app_id>/workflow-runs/count") @@ -386,27 +317,16 @@ class WorkflowRunCountApi(Resource): @console_ns.doc("get_workflow_runs_count") @console_ns.doc(description="Get workflow runs count statistics") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc( - params={"status": "Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded"} + @console_ns.doc(params=query_params_from_model(WorkflowRunCountQuery)) + @console_ns.response( + 200, + "Workflow runs count retrieved successfully", + console_ns.models[WorkflowRunCountResponse.__name__], ) - @console_ns.doc( - params={ - "time_range": ( - "Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), " - "30m (30 minutes), 30s (30 seconds). Filters by created_at field." - ) - } - ) - @console_ns.doc( - params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"} - ) - @console_ns.response(200, "Workflow runs count retrieved successfully", workflow_run_count_model) - @console_ns.expect(console_ns.models[WorkflowRunCountQuery.__name__]) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_count_model) def get(self, app_model: App): """ Get workflow runs count statistics @@ -429,7 +349,7 @@ class WorkflowRunCountApi(Resource): triggered_from=triggered_from, ) - return result + return WorkflowRunCountResponse.model_validate(result).model_dump(mode="json") @console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:run_id>") @@ -437,13 +357,16 @@ class WorkflowRunDetailApi(Resource): @console_ns.doc("get_workflow_run_detail") @console_ns.doc(description="Get workflow run detail") @console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"}) - @console_ns.response(200, "Workflow run detail retrieved successfully", workflow_run_detail_model) + @console_ns.response( + 200, + "Workflow run detail retrieved successfully", + console_ns.models[WorkflowRunDetailResponse.__name__], + ) @console_ns.response(404, "Workflow run not found") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_detail_model) def get(self, app_model: App, run_id): """ Get workflow run detail @@ -452,8 +375,10 @@ class WorkflowRunDetailApi(Resource): workflow_run_service = WorkflowRunService() workflow_run = workflow_run_service.get_workflow_run(app_model=app_model, run_id=run_id) + if workflow_run is None: + raise NotFoundError("Workflow run not found") - return workflow_run + return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:run_id>/node-executions") @@ -461,13 +386,16 @@ class WorkflowRunNodeExecutionListApi(Resource): @console_ns.doc("get_workflow_run_node_executions") @console_ns.doc(description="Get workflow run node execution list") @console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"}) - @console_ns.response(200, "Node executions retrieved successfully", workflow_run_node_execution_list_model) + @console_ns.response( + 200, + "Node executions retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionListResponse.__name__], + ) @console_ns.response(404, "Workflow run not found") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_node_execution_list_model) def get(self, app_model: App, run_id): """ Get workflow run node execution list @@ -482,13 +410,24 @@ class WorkflowRunNodeExecutionListApi(Resource): user=user, ) - return {"data": node_executions} + return WorkflowRunNodeExecutionListResponse.model_validate( + {"data": node_executions}, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/workflow/<string:workflow_run_id>/pause-details") class ConsoleWorkflowPauseDetailsApi(Resource): """Console API for getting workflow pause details.""" + @console_ns.doc("get_workflow_pause_details") + @console_ns.doc(description="Get workflow pause details") + @console_ns.doc(params={"workflow_run_id": "Workflow run ID"}) + @console_ns.response( + 200, + "Workflow pause details retrieved successfully", + console_ns.models[WorkflowPauseDetailsResponse.__name__], + ) + @console_ns.response(404, "Workflow run not found") @setup_required @login_required @account_initialization_required @@ -515,11 +454,8 @@ class ConsoleWorkflowPauseDetailsApi(Resource): # Check if workflow is suspended is_paused = workflow_run.status == WorkflowExecutionStatus.PAUSED if not is_paused: - empty_response: WorkflowPauseDetailsResponse = { - "paused_at": None, - "paused_nodes": [], - } - return empty_response, 200 + empty_response = WorkflowPauseDetailsResponse(paused_at=None, paused_nodes=[]) + return empty_response.model_dump(mode="json"), 200 pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id) pause_reasons = pause_entity.get_pause_reasons() if pause_entity else [] @@ -530,27 +466,25 @@ class ConsoleWorkflowPauseDetailsApi(Resource): # Build response paused_at = pause_entity.paused_at if pause_entity else None paused_nodes: list[PausedNodeResponse] = [] - response: WorkflowPauseDetailsResponse = { - "paused_at": paused_at.isoformat() + "Z" if paused_at else None, - "paused_nodes": paused_nodes, - } for reason in pause_reasons: if isinstance(reason, HumanInputRequired): paused_nodes.append( - { - "node_id": reason.node_id, - "node_title": reason.node_title, - "pause_type": { - "type": "human_input", - "form_id": reason.form_id, - "backstage_input_url": _build_backstage_input_url( - form_tokens_by_form_id.get(reason.form_id) - ), - }, - } + PausedNodeResponse( + node_id=reason.node_id, + node_title=reason.node_title, + pause_type=HumanInputPauseTypeResponse( + type="human_input", + form_id=reason.form_id, + backstage_input_url=_build_backstage_input_url(form_tokens_by_form_id.get(reason.form_id)), + ), + ) ) else: raise AssertionError("unimplemented.") - return response, 200 + response = WorkflowPauseDetailsResponse( + paused_at=paused_at.isoformat() + "Z" if paused_at else None, + paused_nodes=paused_nodes, + ) + return response.model_dump(mode="json"), 200 diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index ee146e8287..8eff32c555 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -10,7 +10,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotF import services from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( ConversationCompletedError, @@ -22,12 +22,6 @@ from controllers.console.app.workflow import ( workflow_model, workflow_pagination_model, ) -from controllers.console.app.workflow_run import ( - workflow_run_detail_model, - workflow_run_node_execution_list_model, - workflow_run_node_execution_model, - workflow_run_pagination_model, -) from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import ( account_initialization_required, @@ -40,6 +34,12 @@ from core.app.apps.pipeline.pipeline_generator import PipelineGenerator from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from factories import variable_factory +from fields.workflow_run_fields import ( + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunPaginationResponse, +) from graphon.model_runtime.utils.encoders import jsonable_encoder from libs import helper from libs.helper import TimestampField, UUIDStrOrEmpty @@ -131,6 +131,13 @@ register_schema_models( DatasourceVariablesPayload, RagPipelineRecommendedPluginQuery, ) +register_response_schema_models( + console_ns, + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunPaginationResponse, +) @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft") @@ -415,12 +422,16 @@ class RagPipelineDraftDatasourceNodeRunApi(Resource): @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/nodes/<string:node_id>/run") class RagPipelineDraftNodeRunApi(Resource): @console_ns.expect(console_ns.models[NodeRunRequiredPayload.__name__]) + @console_ns.response( + 200, + "Node run started successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @setup_required @login_required @edit_permission_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_node_execution_model) def post(self, pipeline: Pipeline, node_id: str): """ Run draft workflow node @@ -439,7 +450,9 @@ class RagPipelineDraftNodeRunApi(Resource): if workflow_node_execution is None: raise ValueError("Workflow node execution not found") - return workflow_node_execution + return WorkflowRunNodeExecutionResponse.model_validate( + workflow_node_execution, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflow-runs/tasks/<string:task_id>/stop") @@ -778,11 +791,15 @@ class DraftRagPipelineSecondStepApi(Resource): @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflow-runs") class RagPipelineWorkflowRunListApi(Resource): + @console_ns.response( + 200, + "Workflow runs retrieved successfully", + console_ns.models[WorkflowRunPaginationResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_pagination_model) def get(self, pipeline: Pipeline): """ Get workflow run list @@ -801,16 +818,20 @@ class RagPipelineWorkflowRunListApi(Resource): rag_pipeline_service = RagPipelineService() result = rag_pipeline_service.get_rag_pipeline_paginate_workflow_runs(pipeline=pipeline, args=args) - return result + return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflow-runs/<uuid:run_id>") class RagPipelineWorkflowRunDetailApi(Resource): + @console_ns.response( + 200, + "Workflow run detail retrieved successfully", + console_ns.models[WorkflowRunDetailResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_detail_model) def get(self, pipeline: Pipeline, run_id): """ Get workflow run detail @@ -819,17 +840,23 @@ class RagPipelineWorkflowRunDetailApi(Resource): rag_pipeline_service = RagPipelineService() workflow_run = rag_pipeline_service.get_rag_pipeline_workflow_run(pipeline=pipeline, run_id=run_id) + if workflow_run is None: + raise NotFound("Workflow run not found") - return workflow_run + return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflow-runs/<uuid:run_id>/node-executions") class RagPipelineWorkflowRunNodeExecutionListApi(Resource): + @console_ns.response( + 200, + "Node executions retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_node_execution_list_model) def get(self, pipeline: Pipeline, run_id: str): """ Get workflow run node execution list @@ -844,7 +871,9 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource): user=user, ) - return {"data": node_executions} + return WorkflowRunNodeExecutionListResponse.model_validate( + {"data": node_executions}, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/rag/pipelines/datasource-plugins") @@ -859,11 +888,15 @@ class DatasourceListApi(Resource): @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/nodes/<string:node_id>/last-run") class RagPipelineWorkflowLastRunApi(Resource): + @console_ns.response( + 200, + "Node last run retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_node_execution_model) def get(self, pipeline: Pipeline, node_id: str): rag_pipeline_service = RagPipelineService() workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline) @@ -876,7 +909,7 @@ class RagPipelineWorkflowLastRunApi(Resource): ) if node_exec is None: raise NotFound("last run not found") - return node_exec + return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json") @console_ns.route("/rag/pipelines/transform/datasets/<uuid:dataset_id>") @@ -899,12 +932,16 @@ class RagPipelineTransformApi(Resource): @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/datasource/variables-inspect") class RagPipelineDatasourceVariableApi(Resource): @console_ns.expect(console_ns.models[DatasourceVariablesPayload.__name__]) + @console_ns.response( + 200, + "Datasource variables set successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline @edit_permission_required - @marshal_with(workflow_run_node_execution_model) def post(self, pipeline: Pipeline): """ Set datasource variables @@ -918,7 +955,9 @@ class RagPipelineDatasourceVariableApi(Resource): args=args, current_user=current_user, ) - return workflow_node_execution + return WorkflowRunNodeExecutionResponse.model_validate( + workflow_node_execution, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/rag/pipelines/recommended-plugins") diff --git a/api/fields/workflow_run_fields.py b/api/fields/workflow_run_fields.py index 8c659086ed..a852f21bb2 100644 --- a/api/fields/workflow_run_fields.py +++ b/api/fields/workflow_run_fields.py @@ -1,14 +1,21 @@ +"""Workflow run response schemas for console APIs. + +Most workflow-run endpoints should document and serialize responses with the +Pydantic models in this module. The remaining Flask-RESTX field dictionaries are +kept only for workflow app-log endpoints that still build legacy log models. +""" + from __future__ import annotations from datetime import datetime from typing import Any from flask_restx import Namespace, fields -from pydantic import Field, field_validator +from pydantic import AliasChoices, Field, field_validator from fields.base import ResponseModel -from fields.end_user_fields import SimpleEndUser, simple_end_user_fields -from fields.member_fields import SimpleAccount, simple_account_fields +from fields.end_user_fields import SimpleEndUser +from fields.member_fields import SimpleAccount from libs.helper import TimestampField workflow_run_for_log_fields = { @@ -43,119 +50,6 @@ def build_workflow_run_for_archived_log_model(api_or_ns: Namespace): return api_or_ns.model("WorkflowRunForArchivedLog", workflow_run_for_archived_log_fields) -workflow_run_for_list_fields = { - "id": fields.String, - "version": fields.String, - "status": fields.String, - "elapsed_time": fields.Float, - "total_tokens": fields.Integer, - "total_steps": fields.Integer, - "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_at": TimestampField, - "finished_at": TimestampField, - "exceptions_count": fields.Integer, - "retry_index": fields.Integer, -} - -advanced_chat_workflow_run_for_list_fields = { - "id": fields.String, - "conversation_id": fields.String, - "message_id": fields.String, - "version": fields.String, - "status": fields.String, - "elapsed_time": fields.Float, - "total_tokens": fields.Integer, - "total_steps": fields.Integer, - "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_at": TimestampField, - "finished_at": TimestampField, - "exceptions_count": fields.Integer, - "retry_index": fields.Integer, -} - -advanced_chat_workflow_run_pagination_fields = { - "limit": fields.Integer(attribute="limit"), - "has_more": fields.Boolean(attribute="has_more"), - "data": fields.List(fields.Nested(advanced_chat_workflow_run_for_list_fields), attribute="data"), -} - -workflow_run_pagination_fields = { - "limit": fields.Integer(attribute="limit"), - "has_more": fields.Boolean(attribute="has_more"), - "data": fields.List(fields.Nested(workflow_run_for_list_fields), attribute="data"), -} - -workflow_run_count_fields = { - "total": fields.Integer, - "running": fields.Integer, - "succeeded": fields.Integer, - "failed": fields.Integer, - "stopped": fields.Integer, - "partial_succeeded": fields.Integer(attribute="partial-succeeded"), -} - -workflow_run_detail_fields = { - "id": fields.String, - "version": fields.String, - "graph": fields.Raw(attribute="graph_dict"), - "inputs": fields.Raw(attribute="inputs_dict"), - "status": fields.String, - "outputs": fields.Raw(attribute="outputs_dict"), - "error": fields.String, - "elapsed_time": fields.Float, - "total_tokens": fields.Integer, - "total_steps": fields.Integer, - "created_by_role": fields.String, - "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_by_end_user": fields.Nested(simple_end_user_fields, attribute="created_by_end_user", allow_null=True), - "created_at": TimestampField, - "finished_at": TimestampField, - "exceptions_count": fields.Integer, -} - -retry_event_field = { - "elapsed_time": fields.Float, - "status": fields.String, - "inputs": fields.Raw(attribute="inputs"), - "process_data": fields.Raw(attribute="process_data"), - "outputs": fields.Raw(attribute="outputs"), - "metadata": fields.Raw(attribute="metadata"), - "llm_usage": fields.Raw(attribute="llm_usage"), - "error": fields.String, - "retry_index": fields.Integer, -} - - -workflow_run_node_execution_fields = { - "id": fields.String, - "index": fields.Integer, - "predecessor_node_id": fields.String, - "node_id": fields.String, - "node_type": fields.String, - "title": fields.String, - "inputs": fields.Raw(attribute="inputs_dict"), - "process_data": fields.Raw(attribute="process_data_dict"), - "outputs": fields.Raw(attribute="outputs_dict"), - "status": fields.String, - "error": fields.String, - "elapsed_time": fields.Float, - "execution_metadata": fields.Raw(attribute="execution_metadata_dict"), - "extras": fields.Raw, - "created_at": TimestampField, - "created_by_role": fields.String, - "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_by_end_user": fields.Nested(simple_end_user_fields, attribute="created_by_end_user", allow_null=True), - "finished_at": TimestampField, - "inputs_truncated": fields.Boolean, - "outputs_truncated": fields.Boolean, - "process_data_truncated": fields.Boolean, -} - -workflow_run_node_execution_list_fields = { - "data": fields.List(fields.Nested(workflow_run_node_execution_fields)), -} - - def _to_timestamp(value: datetime | int | None) -> int | None: if isinstance(value, datetime): return int(value.timestamp()) @@ -252,7 +146,10 @@ class WorkflowRunCountResponse(ResponseModel): succeeded: int failed: int stopped: int - partial_succeeded: int = Field(validation_alias="partial-succeeded") + partial_succeeded: int = Field( + alias="partial_succeeded", + validation_alias=AliasChoices("partial_succeeded", "partial-succeeded"), + ) class WorkflowRunDetailResponse(ResponseModel): diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index f4897e93c5..f3c188fc06 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -805,18 +805,17 @@ Get advanced chat workflow run list | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowRunListQuery](#workflowrunlistquery) | | app_id | path | Application ID | Yes | string | | last_id | query | Last run ID for pagination | No | string | -| limit | query | Number of items per page (1-100) | No | string | -| status | query | Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded | No | string | -| triggered_from | query | Filter by trigger source (optional): debugging or app-run. Default: debugging | No | string | +| limit | query | Number of items per page (1-100) | No | integer | +| status | query | Workflow run status filter | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | ##### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs retrieved successfully | [AdvancedChatWorkflowRunPagination](#advancedchatworkflowrunpagination) | +| 200 | Workflow runs retrieved successfully | [AdvancedChatWorkflowRunPaginationResponse](#advancedchatworkflowrunpaginationresponse) | ### /apps/{app_id}/advanced-chat/workflow-runs/count @@ -833,17 +832,16 @@ Get advanced chat workflow runs count statistics | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowRunCountQuery](#workflowruncountquery) | | app_id | path | Application ID | Yes | string | -| status | query | Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded | No | string | +| status | query | Workflow run status filter | No | string | | time_range | query | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | string | -| triggered_from | query | Filter by trigger source (optional): debugging or app-run. Default: debugging | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | ##### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs count retrieved successfully | [WorkflowRunCount](#workflowruncount) | +| 200 | Workflow runs count retrieved successfully | [WorkflowRunCountResponse](#workflowruncountresponse) | ### /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/preview @@ -2361,18 +2359,17 @@ Get workflow run list | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowRunListQuery](#workflowrunlistquery) | | app_id | path | Application ID | Yes | string | | last_id | query | Last run ID for pagination | No | string | -| limit | query | Number of items per page (1-100) | No | string | -| status | query | Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded | No | string | -| triggered_from | query | Filter by trigger source (optional): debugging or app-run. Default: debugging | No | string | +| limit | query | Number of items per page (1-100) | No | integer | +| status | query | Workflow run status filter | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | ##### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs retrieved successfully | [WorkflowRunPagination](#workflowrunpagination) | +| 200 | Workflow runs retrieved successfully | [WorkflowRunPaginationResponse](#workflowrunpaginationresponse) | ### /apps/{app_id}/workflow-runs/count @@ -2389,17 +2386,16 @@ Get workflow runs count statistics | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowRunCountQuery](#workflowruncountquery) | | app_id | path | Application ID | Yes | string | -| status | query | Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded | No | string | +| status | query | Workflow run status filter | No | string | | time_range | query | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | string | -| triggered_from | query | Filter by trigger source (optional): debugging or app-run. Default: debugging | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | ##### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs count retrieved successfully | [WorkflowRunCount](#workflowruncount) | +| 200 | Workflow runs count retrieved successfully | [WorkflowRunCountResponse](#workflowruncountresponse) | ### /apps/{app_id}/workflow-runs/tasks/{task_id}/stop @@ -2449,7 +2445,7 @@ Get workflow run detail | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetail](#workflowrundetail) | +| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetailResponse](#workflowrundetailresponse) | | 404 | Workflow run not found | | ### /apps/{app_id}/workflow-runs/{run_id}/export @@ -2470,7 +2466,7 @@ Generate a download URL for an archived workflow run. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Export URL generated | [WorkflowRunExport](#workflowrunexport) | +| 200 | Export URL generated | [WorkflowRunExportResponse](#workflowrunexportresponse) | ### /apps/{app_id}/workflow-runs/{run_id}/node-executions @@ -2494,7 +2490,7 @@ Get workflow run node execution list | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionList](#workflowrunnodeexecutionlist) | +| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | | 404 | Workflow run not found | | ### /apps/{app_id}/workflow/comments @@ -3180,7 +3176,7 @@ Get last run result for draft workflow node | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecution](#workflowrunnodeexecution) | +| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | | 403 | Permission denied | | | 404 | Node last run not found | | @@ -3207,7 +3203,7 @@ Run draft workflow node | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node run started successfully | [WorkflowRunNodeExecution](#workflowrunnodeexecution) | +| 200 | Node run started successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | | 403 | Permission denied | | | 404 | Node not found | | @@ -6720,9 +6716,9 @@ Get workflow run list ##### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow runs retrieved successfully | [WorkflowRunPaginationResponse](#workflowrunpaginationresponse) | ### /rag/pipelines/{pipeline_id}/workflow-runs/tasks/{task_id}/stop @@ -6760,9 +6756,9 @@ Get workflow run detail ##### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetailResponse](#workflowrundetailresponse) | ### /rag/pipelines/{pipeline_id}/workflow-runs/{run_id}/node-executions @@ -6780,9 +6776,9 @@ Get workflow run node execution list ##### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | ### /rag/pipelines/{pipeline_id}/workflows @@ -6915,9 +6911,9 @@ Set datasource variables ##### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Datasource variables set successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | ### /rag/pipelines/{pipeline_id}/workflows/draft/environment-variables @@ -6988,9 +6984,9 @@ Run draft workflow loop node ##### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | ### /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/run @@ -7009,9 +7005,9 @@ Run draft workflow node ##### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node run started successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | ### /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables @@ -7947,6 +7943,7 @@ Get workflow pause details ##### Description +Get workflow pause details GET /console/api/workflow/<workflow_run_id>/pause-details Returns information about why and where the workflow is paused. @@ -7955,13 +7952,14 @@ Returns information about why and where the workflow is paused. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| workflow_run_id | path | | Yes | string | +| workflow_run_id | path | Workflow run ID | Yes | string | ##### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow pause details retrieved successfully | [WorkflowPauseDetailsResponse](#workflowpausedetailsresponse) | +| 404 | Workflow run not found | | ### /workspaces @@ -10256,31 +10254,31 @@ Get banner list | ---- | ---- | ----------- | -------- | | result | string | Operation result | Yes | -#### AdvancedChatWorkflowRunForList +#### AdvancedChatWorkflowRunForListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| conversation_id | string | | No | -| created_at | object | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | -| elapsed_time | number | | No | -| exceptions_count | integer | | No | -| finished_at | object | | No | -| id | string | | No | -| message_id | string | | No | -| retry_index | integer | | No | -| status | string | | No | -| total_steps | integer | | No | -| total_tokens | integer | | No | -| version | string | | No | +| conversation_id | | | No | +| created_at | | | No | +| created_by_account | | | No | +| elapsed_time | | | No | +| exceptions_count | | | No | +| finished_at | | | No | +| id | string | | Yes | +| message_id | | | No | +| retry_index | | | No | +| status | | | No | +| total_steps | | | No | +| total_tokens | | | No | +| version | | | No | -#### AdvancedChatWorkflowRunPagination +#### AdvancedChatWorkflowRunPaginationResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [AdvancedChatWorkflowRunForList](#advancedchatworkflowrunforlist) ] | | No | -| has_more | boolean | | No | -| limit | integer | | No | +| data | [ [AdvancedChatWorkflowRunForListResponse](#advancedchatworkflowrunforlistresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | #### AdvancedChatWorkflowRunPayload @@ -12169,6 +12167,14 @@ Form input types. | form_inputs | object | Values the user provides for the form's own fields | Yes | | inputs | object | Values used to fill missing upstream variables referenced in form_content | Yes | +#### HumanInputPauseTypeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| backstage_input_url | | | No | +| form_id | string | | Yes | +| type | string | | Yes | + #### IconType | Name | Type | Description | Required | @@ -13101,6 +13107,14 @@ Enum class for model type. | ---- | ---- | ----------- | -------- | | click_id | string | Click Id from partner referral link | Yes | +#### PausedNodeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_id | string | | Yes | +| node_title | string | | Yes | +| pause_type | [HumanInputPauseTypeResponse](#humaninputpausetyperesponse) | | Yes | + #### Payload | Name | Type | Description | Required | @@ -14306,53 +14320,60 @@ User action configuration. | updated_at | | | No | | updated_by | | | No | -#### WorkflowRunCount +#### WorkflowPauseDetailsResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| failed | integer | | No | -| partial_succeeded | integer | | No | -| running | integer | | No | -| stopped | integer | | No | -| succeeded | integer | | No | -| total | integer | | No | +| paused_at | | | No | +| paused_nodes | [ [PausedNodeResponse](#pausednoderesponse) ] | | Yes | #### WorkflowRunCountQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | status | | Workflow run status filter | No | -| time_range | | Time range filter (e.g., 7d, 4h, 30m, 30s) | No | -| triggered_from | | Filter by trigger source: debugging or app-run | No | +| time_range | | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | +| triggered_from | | Filter by trigger source: debugging or app-run. Default: debugging | No | -#### WorkflowRunDetail +#### WorkflowRunCountResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | object | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | -| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | -| created_by_role | string | | No | -| elapsed_time | number | | No | -| error | string | | No | -| exceptions_count | integer | | No | -| finished_at | object | | No | -| graph | object | | No | -| id | string | | No | -| inputs | object | | No | -| outputs | object | | No | -| status | string | | No | -| total_steps | integer | | No | -| total_tokens | integer | | No | -| version | string | | No | +| failed | integer | | Yes | +| partial_succeeded | integer | | Yes | +| running | integer | | Yes | +| stopped | integer | | Yes | +| succeeded | integer | | Yes | +| total | integer | | Yes | -#### WorkflowRunExport +#### WorkflowRunDetailResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| presigned_url | string | Pre-signed URL for download | No | -| presigned_url_expires_at | string | Pre-signed URL expiration time | No | -| status | string | Export status: success/failed | No | +| created_at | | | No | +| created_by_account | | | No | +| created_by_end_user | | | No | +| created_by_role | | | No | +| elapsed_time | | | No | +| error | | | No | +| exceptions_count | | | No | +| finished_at | | | No | +| graph | | | Yes | +| id | string | | Yes | +| inputs | | | Yes | +| outputs | | | Yes | +| status | | | No | +| total_steps | | | No | +| total_tokens | | | No | +| version | | | No | + +#### WorkflowRunExportResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| presigned_url | | Pre-signed URL for download | No | +| presigned_url_expires_at | | Pre-signed URL expiration time | No | +| status | string | Export status: success/failed | Yes | #### WorkflowRunForArchivedLogResponse @@ -14364,21 +14385,21 @@ User action configuration. | total_tokens | | | No | | triggered_from | | | No | -#### WorkflowRunForList +#### WorkflowRunForListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | object | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | -| elapsed_time | number | | No | -| exceptions_count | integer | | No | -| finished_at | object | | No | -| id | string | | No | -| retry_index | integer | | No | -| status | string | | No | -| total_steps | integer | | No | -| total_tokens | integer | | No | -| version | string | | No | +| created_at | | | No | +| created_by_account | | | No | +| elapsed_time | | | No | +| exceptions_count | | | No | +| finished_at | | | No | +| id | string | | Yes | +| retry_index | | | No | +| status | | | No | +| total_steps | | | No | +| total_tokens | | | No | +| version | | | No | #### WorkflowRunForLogResponse @@ -14403,48 +14424,48 @@ User action configuration. | last_id | | Last run ID for pagination | No | | limit | integer | Number of items per page (1-100) | No | | status | | Workflow run status filter | No | -| triggered_from | | Filter by trigger source: debugging or app-run | No | +| triggered_from | | Filter by trigger source: debugging or app-run. Default: debugging | No | -#### WorkflowRunNodeExecution +#### WorkflowRunNodeExecutionListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | object | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | -| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | -| created_by_role | string | | No | -| elapsed_time | number | | No | -| error | string | | No | -| execution_metadata | object | | No | -| extras | object | | No | -| finished_at | object | | No | -| id | string | | No | -| index | integer | | No | -| inputs | object | | No | -| inputs_truncated | boolean | | No | -| node_id | string | | No | -| node_type | string | | No | -| outputs | object | | No | -| outputs_truncated | boolean | | No | -| predecessor_node_id | string | | No | -| process_data | object | | No | -| process_data_truncated | boolean | | No | -| status | string | | No | -| title | string | | No | +| data | [ [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) ] | | Yes | -#### WorkflowRunNodeExecutionList +#### WorkflowRunNodeExecutionResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [WorkflowRunNodeExecution](#workflowrunnodeexecution) ] | | No | +| created_at | | | No | +| created_by_account | | | No | +| created_by_end_user | | | No | +| created_by_role | | | No | +| elapsed_time | | | No | +| error | | | No | +| execution_metadata | | | No | +| extras | | | No | +| finished_at | | | No | +| id | string | | Yes | +| index | | | No | +| inputs | | | No | +| inputs_truncated | | | No | +| node_id | | | No | +| node_type | | | No | +| outputs | | | No | +| outputs_truncated | | | No | +| predecessor_node_id | | | No | +| process_data | | | No | +| process_data_truncated | | | No | +| status | | | No | +| title | | | No | -#### WorkflowRunPagination +#### WorkflowRunPaginationResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [WorkflowRunForList](#workflowrunforlist) ] | | No | -| has_more | boolean | | No | -| limit | integer | | No | +| data | [ [WorkflowRunForListResponse](#workflowrunforlistresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | #### WorkflowRunPayload diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index c17a83cad3..ba59780d59 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +from types import SimpleNamespace from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -44,6 +45,35 @@ def unwrap(func): return func +def make_node_execution(**overrides): + payload = { + "id": "node-exec-1", + "index": 1, + "predecessor_node_id": None, + "node_id": "node1", + "node_type": "start", + "title": "Start", + "inputs_dict": {"query": "hello"}, + "process_data_dict": {}, + "outputs_dict": {"answer": "world"}, + "status": "succeeded", + "error": None, + "elapsed_time": 1.0, + "execution_metadata_dict": {}, + "extras": {}, + "created_at": datetime(2026, 1, 1, 0, 0, 0), + "created_by_role": "account", + "created_by_account": None, + "created_by_end_user": None, + "finished_at": datetime(2026, 1, 1, 0, 0, 1), + "inputs_truncated": False, + "outputs_truncated": False, + "process_data_truncated": False, + } + payload.update(overrides) + return SimpleNamespace(**payload) + + class TestDraftWorkflowApi: @pytest.fixture def app(self, flask_app_with_containers: Flask): @@ -743,7 +773,7 @@ class TestRagPipelineWorkflowLastRunApi: pipeline = MagicMock() workflow = MagicMock() - node_exec = MagicMock() + node_exec = make_node_execution() service = MagicMock() service.get_draft_workflow.return_value = workflow @@ -757,7 +787,9 @@ class TestRagPipelineWorkflowLastRunApi: ), ): result = method(api, pipeline, "node1") - assert result == node_exec + assert result["id"] == "node-exec-1" + assert result["inputs"] == {"query": "hello"} + assert result["outputs"] == {"answer": "world"} def test_last_run_not_found(self, app: Flask): api = RagPipelineWorkflowLastRunApi() @@ -799,7 +831,7 @@ class TestRagPipelineDatasourceVariableApi: } service = MagicMock() - service.set_datasource_variables.return_value = MagicMock() + service.set_datasource_variables.return_value = make_node_execution(node_id="n1") with ( app.test_request_context("/", json=payload), @@ -814,4 +846,5 @@ class TestRagPipelineDatasourceVariableApi: ), ): result = method(api, pipeline) - assert result is not None + assert result["node_id"] == "n1" + assert result["process_data"] == {} diff --git a/api/tests/unit_tests/controllers/common/test_schema.py b/api/tests/unit_tests/controllers/common/test_schema.py index 575f8c839c..7cabafba0e 100644 --- a/api/tests/unit_tests/controllers/common/test_schema.py +++ b/api/tests/unit_tests/controllers/common/test_schema.py @@ -47,6 +47,10 @@ class QueryModel(BaseModel): ambiguous: int | str | None = Field(default=None, description="Ambiguous query parameter") +class ResponseAliasModel(BaseModel): + public_name: str = Field(validation_alias="internal_name") + + @pytest.fixture(autouse=True) def mock_console_ns(): """Mock the console_ns to avoid circular imports during test collection.""" @@ -146,6 +150,20 @@ def test_register_schema_models_calls_register_schema_model(monkeypatch: pytest. ] +def test_register_response_schema_model_uses_serialized_field_names(): + from controllers.common.schema import register_response_schema_model + + namespace = MagicMock(spec=Namespace) + + register_response_schema_model(namespace, ResponseAliasModel) + + model_name, schema = namespace.schema_model.call_args.args + + assert model_name == "ResponseAliasModel" + assert "public_name" in schema["properties"] + assert "internal_name" not in schema["properties"] + + def test_get_or_create_model_returns_existing_model(mock_console_ns): from controllers.common.schema import get_or_create_model diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py index c4a8148446..05c17b4e34 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py @@ -112,3 +112,24 @@ def test_pause_details_tenant_isolation(app: Flask, monkeypatch: pytest.MonkeyPa with pytest.raises(NotFoundError): with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"): response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1") + + +def test_pause_details_returns_empty_response_for_non_paused_run(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + account = _make_account() + _patch_console_guards(monkeypatch, account) + + workflow_run = Mock(spec=WorkflowRun) + workflow_run.tenant_id = "tenant-123" + workflow_run.status = WorkflowExecutionStatus.RUNNING + fake_db = SimpleNamespace(engine=Mock(), session=SimpleNamespace(get=lambda *_: workflow_run)) + monkeypatch.setattr(workflow_run_module, "db", fake_db) + + with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"): + response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1") + + assert status == 200 + assert response == {"paused_at": None, "paused_nodes": []} + + +def test_pause_details_response_schema_is_registered() -> None: + assert workflow_run_module.WorkflowPauseDetailsResponse.__name__ in workflow_run_module.console_ns.models diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py new file mode 100644 index 0000000000..e225e31563 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Any + +import pytest +from flask import Flask +from flask_restx import marshal + +from controllers.console.app import workflow_run as workflow_run_module + + +def _unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +def _serialize_200_response(handler, payload: Any) -> Any: + response_doc = getattr(handler, "__apidoc__", {}).get("responses", {}).get("200") + if response_doc is None: + return payload + + response_model = response_doc[1] + if isinstance(response_model, dict): + return marshal(payload, response_model) + return payload + + +def _account() -> SimpleNamespace: + return SimpleNamespace(id="account-1", name="Alice", email="alice@example.com") + + +def _workflow_run_summary(**overrides) -> SimpleNamespace: + created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) + payload = { + "id": "run-1", + "version": "v1", + "status": "succeeded", + "elapsed_time": 1.5, + "total_tokens": 10, + "total_steps": 2, + "created_by_account": _account(), + "created_at": created_at, + "finished_at": created_at, + "exceptions_count": 0, + "retry_index": 0, + } + payload.update(overrides) + return SimpleNamespace(**payload) + + +def _workflow_run_node_execution(**overrides) -> SimpleNamespace: + created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) + payload = { + "id": "node-exec-1", + "index": 1, + "predecessor_node_id": None, + "node_id": "node-1", + "node_type": "start", + "title": "Start", + "inputs_dict": {"query": "hello"}, + "process_data_dict": {"step": "prepared"}, + "outputs_dict": {"answer": "world"}, + "status": "succeeded", + "error": None, + "elapsed_time": 1.0, + "execution_metadata_dict": {"total_tokens": 3}, + "extras": {}, + "created_at": created_at, + "created_by_role": "account", + "created_by_account": _account(), + "created_by_end_user": None, + "finished_at": created_at, + "inputs_truncated": False, + "outputs_truncated": False, + "process_data_truncated": False, + } + payload.update(overrides) + return SimpleNamespace(**payload) + + +def test_workflow_run_list_returns_frontend_history_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + class WorkflowRunService: + def get_paginate_workflow_runs(self, **_kwargs): + return { + "limit": 10, + "has_more": False, + "data": [_workflow_run_summary()], + } + + monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService) + + api = workflow_run_module.WorkflowRunListApi() + handler = _unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflow-runs?limit=10", method="GET"): + payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1")) + + response = _serialize_200_response(api.get, payload) + + assert response["limit"] == 10 + assert response["has_more"] is False + assert response["data"][0] == { + "id": "run-1", + "version": "v1", + "status": "succeeded", + "elapsed_time": 1.5, + "total_tokens": 10, + "total_steps": 2, + "created_by_account": {"id": "account-1", "name": "Alice", "email": "alice@example.com"}, + "created_at": 1767323045, + "finished_at": 1767323045, + "exceptions_count": 0, + "retry_index": 0, + } + + +def test_advanced_chat_workflow_run_list_keeps_message_fields(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + class WorkflowRunService: + def get_paginate_advanced_chat_workflow_runs(self, **_kwargs): + return { + "limit": 1, + "has_more": True, + "data": [ + _workflow_run_summary( + conversation_id="conversation-1", + message_id="message-1", + ) + ], + } + + monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService) + + api = workflow_run_module.AdvancedChatAppWorkflowRunListApi() + handler = _unwrap(api.get) + + with app.test_request_context("/apps/app-1/advanced-chat/workflow-runs?limit=1", method="GET"): + payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1")) + + response = _serialize_200_response(api.get, payload) + + assert response["data"][0]["conversation_id"] == "conversation-1" + assert response["data"][0]["message_id"] == "message-1" + + +def test_workflow_run_detail_returns_frontend_detail_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) + workflow_run = SimpleNamespace( + id="run-1", + version="v1", + graph_dict={"nodes": []}, + inputs_dict={"query": "hello"}, + status="succeeded", + outputs_dict={"answer": "world"}, + error=None, + elapsed_time=1.5, + total_tokens=10, + total_steps=2, + created_by_role="account", + created_by_account=_account(), + created_by_end_user=None, + created_at=created_at, + finished_at=created_at, + exceptions_count=0, + ) + + class WorkflowRunService: + def get_workflow_run(self, **_kwargs): + return workflow_run + + monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService) + + api = workflow_run_module.WorkflowRunDetailApi() + handler = _unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflow-runs/run-1", method="GET"): + payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"), run_id="run-1") + + response = _serialize_200_response(api.get, payload) + + assert response == { + "id": "run-1", + "version": "v1", + "graph": {"nodes": []}, + "inputs": {"query": "hello"}, + "status": "succeeded", + "outputs": {"answer": "world"}, + "error": None, + "elapsed_time": 1.5, + "total_tokens": 10, + "total_steps": 2, + "created_by_role": "account", + "created_by_account": {"id": "account-1", "name": "Alice", "email": "alice@example.com"}, + "created_by_end_user": None, + "created_at": 1767323045, + "finished_at": 1767323045, + "exceptions_count": 0, + } + + +def test_workflow_run_node_executions_return_frontend_trace_contract( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + class WorkflowRunService: + def get_workflow_run_node_executions(self, **_kwargs): + return [_workflow_run_node_execution()] + + monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService) + monkeypatch.setattr(workflow_run_module, "current_user", SimpleNamespace(id="account-1")) + + api = workflow_run_module.WorkflowRunNodeExecutionListApi() + handler = _unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflow-runs/run-1/node-executions", method="GET"): + payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"), run_id="run-1") + + response = _serialize_200_response(api.get, payload) + + assert response == { + "data": [ + { + "id": "node-exec-1", + "index": 1, + "predecessor_node_id": None, + "node_id": "node-1", + "node_type": "start", + "title": "Start", + "inputs": {"query": "hello"}, + "process_data": {"step": "prepared"}, + "outputs": {"answer": "world"}, + "status": "succeeded", + "error": None, + "elapsed_time": 1.0, + "execution_metadata": {"total_tokens": 3}, + "extras": {}, + "created_at": 1767323045, + "created_by_role": "account", + "created_by_account": {"id": "account-1", "name": "Alice", "email": "alice@example.com"}, + "created_by_end_user": None, + "finished_at": 1767323045, + "inputs_truncated": False, + "outputs_truncated": False, + "process_data_truncated": False, + } + ] + } From f720a3bed2b23da49cca81e05473090ab2b69692 Mon Sep 17 00:00:00 2001 From: FFXN <31929997+FFXN@users.noreply.github.com> Date: Sat, 9 May 2026 18:06:01 +0800 Subject: [PATCH 12/53] fix: Image rendering in the knowledge base failed. (#35914) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/core/rag/datasource/retrieval_service.py | 6 +-- api/core/rag/extractor/pdf_extractor.py | 2 +- api/core/rag/extractor/word_extractor.py | 2 +- api/core/rag/retrieval/dataset_retrieval.py | 4 +- api/core/tools/signature.py | 10 ++-- api/models/dataset.py | 6 +-- .../datasource/test_datasource_retrieval.py | 4 +- .../rag/retrieval/test_dataset_retrieval.py | 2 +- .../unit_tests/core/tools/test_signature.py | 14 +++--- .../unit_tests/models/test_dataset_models.py | 47 +++++++++++++++++++ 10 files changed, 73 insertions(+), 24 deletions(-) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index b985ebbe1d..7769878e70 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -21,7 +21,7 @@ from core.rag.index_processor.constant.query_type import QueryType from core.rag.models.document import Document from core.rag.rerank.rerank_type import RerankMode from core.rag.retrieval.retrieval_methods import RetrievalMethod -from core.tools.signature import sign_upload_file +from core.tools.signature import sign_upload_file_preview_url from extensions.ext_database import db from graphon.model_runtime.entities.model_entities import ModelType from models.dataset import ( @@ -893,7 +893,7 @@ class RetrievalService: "name": upload_file.name, "extension": "." + upload_file.extension, "mime_type": upload_file.mime_type, - "source_url": sign_upload_file(upload_file.id, upload_file.extension), + "source_url": sign_upload_file_preview_url(upload_file.id, upload_file.extension), "size": upload_file.size, } return {"attachment_info": attachment_info, "segment_id": attachment_binding.segment_id} @@ -920,7 +920,7 @@ class RetrievalService: "name": upload_file.name, "extension": "." + upload_file.extension, "mime_type": upload_file.mime_type, - "source_url": sign_upload_file(upload_file.id, upload_file.extension), + "source_url": sign_upload_file_preview_url(upload_file.id, upload_file.extension), "size": upload_file.size, } if attachment_binding: diff --git a/api/core/rag/extractor/pdf_extractor.py b/api/core/rag/extractor/pdf_extractor.py index 02f0efc908..25f6fe3e2a 100644 --- a/api/core/rag/extractor/pdf_extractor.py +++ b/api/core/rag/extractor/pdf_extractor.py @@ -115,7 +115,7 @@ class PdfExtractor(BaseExtractor): """ image_content = [] upload_files = [] - base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + base_url = dify_config.FILES_URL try: image_objects = page.get_objects(filter=(pdfium_c.FPDF_PAGEOBJ_IMAGE,)) diff --git a/api/core/rag/extractor/word_extractor.py b/api/core/rag/extractor/word_extractor.py index 0330a43b28..60f8906181 100644 --- a/api/core/rag/extractor/word_extractor.py +++ b/api/core/rag/extractor/word_extractor.py @@ -110,7 +110,7 @@ class WordExtractor(BaseExtractor): def _extract_images_from_docx(self, doc): image_count = 0 image_map = {} - base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + base_url = dify_config.FILES_URL for r_id, rel in doc.part.rels.items(): if "image" in rel.target_ref: diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index 5631b3a921..010566d203 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -52,7 +52,7 @@ from core.rag.retrieval.template_prompts import ( METADATA_FILTER_USER_PROMPT_2, METADATA_FILTER_USER_PROMPT_3, ) -from core.tools.signature import sign_upload_file +from core.tools.signature import sign_upload_file_preview_url from core.tools.utils.dataset_retriever.dataset_retriever_base_tool import DatasetRetrieverBaseTool from core.workflow.file_reference import build_file_reference from core.workflow.nodes.knowledge_retrieval import exc @@ -529,7 +529,7 @@ class DatasetRetrieval: ), size=upload_file.size, storage_key=upload_file.key, - url=sign_upload_file(upload_file.id, upload_file.extension), + url=sign_upload_file_preview_url(upload_file.id, upload_file.extension), ) context_files.append(attachment_info) if show_retrieve_source: diff --git a/api/core/tools/signature.py b/api/core/tools/signature.py index 1807226924..3c7b523ff1 100644 --- a/api/core/tools/signature.py +++ b/api/core/tools/signature.py @@ -26,12 +26,14 @@ def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" -def sign_upload_file(upload_file_id: str, extension: str) -> str: +def sign_upload_file_preview_url(upload_file_id: str, extension: str) -> str: """ - sign file to get a temporary url for plugin access + Sign an upload file to get a temporary image preview URL. + + The URL generated by this function is only for external preview and download, + not for internal communication. """ - # Use internal URL for plugin/tool file access in Docker environments - base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + base_url = dify_config.FILES_URL file_preview_url = f"{base_url}/files/{upload_file_id}/image-preview" timestamp = str(int(time.time())) diff --git a/api/models/dataset.py b/api/models/dataset.py index a00e9f7640..ed7727e0f1 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -24,7 +24,7 @@ from core.rag.index_processor.constant.built_in_field import BuiltInField, Metad from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.index_processor.constant.query_type import QueryType from core.rag.retrieval.retrieval_methods import RetrievalMethod -from core.tools.signature import sign_upload_file +from core.tools.signature import sign_upload_file_preview_url from extensions.ext_storage import storage from libs.uuid_utils import uuidv7 @@ -1020,7 +1020,7 @@ class DocumentSegment(Base): encoded_sign = base64.urlsafe_b64encode(sign).decode() params = f"timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" - reference_url = dify_config.CONSOLE_API_URL or "" + reference_url = dify_config.FILES_URL or dify_config.CONSOLE_API_URL or "" base_url = f"{reference_url}/files/{upload_file_id}/image-preview" source_url = f"{base_url}?{params}" attachment_list.append( @@ -1162,7 +1162,7 @@ class DatasetQuery(TypeBase): "size": file_info.size, "extension": file_info.extension, "mime_type": file_info.mime_type, - "source_url": sign_upload_file(file_info.id, file_info.extension), + "source_url": sign_upload_file_preview_url(file_info.id, file_info.extension), } else: query["file_info"] = None diff --git a/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py b/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py index d38213dd89..f72351ffa2 100644 --- a/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py +++ b/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py @@ -1038,7 +1038,7 @@ class TestRetrievalServiceInternals: assert any(doc.metadata["doc_id"] == "processed-doc" for doc in all_documents) processor_instance.invoke.assert_called_once() - @patch("core.rag.datasource.retrieval_service.sign_upload_file", return_value="signed://file") + @patch("core.rag.datasource.retrieval_service.sign_upload_file_preview_url", return_value="signed://file") def test_get_segment_attachment_info_success(self, mock_sign): upload_file = SimpleNamespace( id="upload-1", @@ -1118,7 +1118,7 @@ class TestRetrievalServiceInternals: assert result == [] - @patch("core.rag.datasource.retrieval_service.sign_upload_file", return_value="signed://file") + @patch("core.rag.datasource.retrieval_service.sign_upload_file_preview_url", return_value="signed://file") def test_get_segment_attachment_infos_success(self, mock_sign): upload_file_1 = SimpleNamespace( id="upload-1", diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index b556ddf528..9334ad9b2f 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -4562,7 +4562,7 @@ class TestRetrieveCoverage: "core.rag.retrieval.dataset_retrieval.RetrievalService.format_retrieval_documents", return_value=[record], ), - patch("core.rag.retrieval.dataset_retrieval.sign_upload_file", return_value="https://signed"), + patch("core.rag.retrieval.dataset_retrieval.sign_upload_file_preview_url", return_value="https://signed"), patch("core.rag.retrieval.dataset_retrieval.db.session.execute") as mock_execute, ): bound_model_instance = Mock() diff --git a/api/tests/unit_tests/core/tools/test_signature.py b/api/tests/unit_tests/core/tools/test_signature.py index 353988d7a6..a75fdee908 100644 --- a/api/tests/unit_tests/core/tools/test_signature.py +++ b/api/tests/unit_tests/core/tools/test_signature.py @@ -9,7 +9,7 @@ import pytest from core.tools.signature import ( get_signed_file_url_for_plugin, sign_tool_file, - sign_upload_file, + sign_upload_file_preview_url, verify_plugin_file_signature, verify_tool_file_signature, ) @@ -89,32 +89,32 @@ def test_verify_tool_file_signature_rejects_expired_signature(monkeypatch: pytes assert verify_tool_file_signature("tool-file-id", timestamp, nonce, sign) is False -def test_sign_upload_file_prefers_internal_url(monkeypatch: pytest.MonkeyPatch) -> None: +def test_sign_upload_file_preview_url_uses_files_url(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x03" * 16) monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret") monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com") monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com") - url = sign_upload_file("upload-id", ".png") + url = sign_upload_file_preview_url("upload-id", ".png") parsed = urlparse(url) query = parse_qs(parsed.query) - assert parsed.netloc == "internal.example.com" + assert parsed.netloc == "files.example.com" assert parsed.path == "/files/upload-id/image-preview" assert query["timestamp"][0] assert query["nonce"][0] assert query["sign"][0] -def test_sign_upload_file_uses_files_url_fallback(monkeypatch: pytest.MonkeyPatch) -> None: +def test_sign_upload_file_preview_url_ignores_internal_files_url(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x05" * 16) monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret") monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com") - monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "") + monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com") - url = sign_upload_file("upload-id", ".png") + url = sign_upload_file_preview_url("upload-id", ".png") parsed = urlparse(url) query = parse_qs(parsed.query) diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 51d95c4239..3f14ebe8bf 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -12,7 +12,9 @@ This test suite covers: import json import pickle from datetime import UTC, datetime +from types import SimpleNamespace from unittest.mock import Mock, patch +from urllib.parse import parse_qs, urlparse from uuid import uuid4 from core.rag.index_processor.constant.index_type import IndexTechniqueType @@ -676,6 +678,51 @@ class TestDocumentSegmentIndexing: # Assert assert segment.hit_count == 5 + def test_document_segment_attachments_prefers_files_url_for_source_url(self, monkeypatch): + """Test attachment source URLs use FILES_URL before falling back to CONSOLE_API_URL.""" + # Arrange + segment = DocumentSegment( + tenant_id="tenant-1", + dataset_id="dataset-1", + document_id="document-1", + position=1, + content="Test", + word_count=1, + tokens=2, + created_by="user-1", + ) + segment.id = "segment-1" + attachment = SimpleNamespace( + id="upload-1", + name="image.png", + size=128, + extension="png", + mime_type="image/png", + ) + + monkeypatch.setattr("models.dataset.time.time", lambda: 1700000000) + monkeypatch.setattr("models.dataset.os.urandom", lambda _: b"\x01" * 16) + monkeypatch.setattr("models.dataset.dify_config.SECRET_KEY", "unit-secret") + monkeypatch.setattr("models.dataset.dify_config.FILES_URL", "https://files.example.com") + monkeypatch.setattr("models.dataset.dify_config.CONSOLE_API_URL", "https://console.example.com") + + with patch("models.dataset.db") as mock_db: + mock_db.session.execute.return_value.all.return_value = [(Mock(), attachment)] + + # Act + attachments = segment.attachments + + # Assert + assert len(attachments) == 1 + source_url = attachments[0]["source_url"] + parsed = urlparse(source_url) + query = parse_qs(parsed.query) + assert parsed.netloc == "files.example.com" + assert parsed.path == "/files/upload-1/image-preview" + assert query["timestamp"] == ["1700000000"] + assert query["nonce"] == ["01010101010101010101010101010101"] + assert query["sign"][0] + def test_document_segment_error_tracking(self): """Test document segment error tracking.""" # Arrange From 8581a68174642685374fdea6a1f321e0bdb244d2 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Sat, 9 May 2026 18:33:25 +0800 Subject: [PATCH 13/53] refactor(web): drop headless-ui, migrate overlay to dify-ui (#35963) Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- eslint-suppressions.json | 364 +++--------------- packages/dify-ui/src/dialog/index.tsx | 2 +- pnpm-lock.yaml | 131 ------- pnpm-workspace.yaml | 1 - .../apps/app-card-operations-flow.test.tsx | 22 -- .../develop/api-key-management-flow.test.tsx | 4 +- .../header/account-dropdown-flow.test.tsx | 1 + web/__tests__/header/nav-flow.test.tsx | 77 ---- .../(appDetailLayout)/[appId]/layout-main.tsx | 5 + .../delete-account/components/feed-back.tsx | 50 ++- .../(commonLayout)/delete-account/index.tsx | 42 +- .../app-sidebar/__tests__/index.spec.tsx | 3 + .../__tests__/app-info-detail-panel.spec.tsx | 25 +- .../__tests__/use-app-info-actions.spec.ts | 21 + .../app-info/app-info-detail-drawer.tsx | 34 ++ .../app-info/app-info-detail-panel.tsx | 15 +- .../components/app-sidebar/app-info/index.tsx | 84 +++- .../app-info/use-app-info-actions.ts | 80 +++- .../app-sidebar/app-sidebar-dropdown.tsx | 17 +- web/app/components/app-sidebar/index.tsx | 20 +- .../batch-add-annotation-modal/index.tsx | 62 +-- .../header-opts/__tests__/index.spec.tsx | 118 ------ .../__tests__/access-control-dialog.spec.tsx | 1 + .../__tests__/access-control.spec.tsx | 29 -- .../access-control-dialog.tsx | 10 +- .../__tests__/version-info-modal.spec.tsx | 16 + .../app/app-publisher/version-info-modal.tsx | 83 ++-- .../__tests__/edit-modal.spec.tsx | 7 +- .../conversation-history/edit-modal.tsx | 66 ++-- .../config-var/config-modal/index.tsx | 64 +-- .../config/automatic/get-automatic-res.tsx | 269 ++++++------- .../code-generator/get-code-generator-res.tsx | 179 ++++----- .../params-config/__tests__/index.spec.tsx | 31 -- .../dataset-config/params-config/index.tsx | 52 +-- .../app/create-app-dialog-shell.tsx | 51 +++ .../__tests__/index.spec.tsx | 127 +----- .../app/create-app-dialog/index.tsx | 20 +- .../components/app/create-app-modal/index.tsx | 31 +- .../__tests__/index.spec.tsx | 81 +++- .../dsl-confirm-modal.tsx | 63 +-- .../app/create-from-dsl-modal/index.tsx | 226 +++++------ .../components/app/duplicate-modal/index.tsx | 69 ++-- .../components/app/switch-app-modal/index.tsx | 122 +++--- .../app/workflow-log/__tests__/list.spec.tsx | 3 +- .../components/base/app-icon-picker/index.tsx | 97 +++-- .../sidebar/__tests__/index.spec.tsx | 21 +- .../sidebar/__tests__/rename-modal.spec.tsx | 26 +- .../content-dialog/__tests__/index.spec.tsx | 59 --- .../base/content-dialog/index.stories.tsx | 119 ------ .../components/base/content-dialog/index.tsx | 40 -- .../date-picker/index.tsx | 2 - .../date-and-time-picker/index.stories.tsx | 2 - .../base/date-and-time-picker/types.ts | 1 - .../base/dialog/__tests__/index.spec.tsx | 138 ------- .../components/base/dialog/index.stories.tsx | 152 -------- web/app/components/base/dialog/index.tsx | 70 ---- .../base/drawer-plus/__tests__/index.spec.tsx | 3 +- .../base/drawer/__tests__/index.spec.tsx | 92 +++-- .../components/base/drawer/index.stories.tsx | 2 +- web/app/components/base/drawer/index.tsx | 142 +++---- .../components/base/emoji-picker/index.tsx | 72 ++-- ...spec.tsx => feature-panel-drawer.spec.tsx} | 59 ++- .../annotation-reply/config-param-modal.tsx | 91 +++-- .../new-feature-panel/dialog-wrapper.tsx | 57 --- .../feature-panel-drawer.tsx | 60 +++ .../base/features/new-feature-panel/index.tsx | 13 +- .../moderation/moderation-setting-modal.tsx | 292 +++++++------- .../__tests__/audio-preview.spec.tsx | 15 +- .../__tests__/pdf-preview.spec.tsx | 12 +- .../__tests__/video-preview.spec.tsx | 17 +- .../base/file-uploader/audio-preview.tsx | 60 +-- .../__tests__/file-item.spec.tsx | 2 +- .../__tests__/file-image-item.spec.tsx | 5 +- .../base/file-uploader/pdf-preview.tsx | 175 +++++---- .../base/file-uploader/video-preview.tsx | 59 +-- .../__tests__/index.spec.tsx | 3 +- .../fullscreen-modal/__tests__/index.spec.tsx | 214 ---------- .../base/fullscreen-modal/index.stories.tsx | 59 --- .../base/fullscreen-modal/index.tsx | 65 ---- .../image-gallery/__tests__/index.spec.tsx | 2 +- .../__tests__/audio-preview.spec.tsx | 2 +- .../__tests__/image-preview.spec.tsx | 14 +- .../__tests__/video-preview.spec.tsx | 2 +- .../base/image-uploader/audio-preview.tsx | 56 ++- .../base/image-uploader/image-preview.tsx | 275 +++++++------ .../base/image-uploader/video-preview.tsx | 54 ++- .../modal-like-wrap/__tests__/index.spec.tsx | 84 ---- .../base/modal-like-wrap/index.stories.tsx | 131 ------- .../components/base/modal-like-wrap/index.tsx | 62 --- .../base/modal/__tests__/index.spec.tsx | 172 --------- .../components/base/modal/index.stories.tsx | 128 ------ web/app/components/base/modal/index.tsx | 93 ----- .../plugins/hitl-input-block/component-ui.tsx | 32 +- .../plugins/hitl-input-block/input-field.tsx | 4 +- .../__tests__/index.spec.tsx | 8 +- .../plugins/shortcuts-popup-plugin/index.tsx | 12 +- .../annotation-full/__tests__/modal.spec.tsx | 26 +- .../billing/annotation-full/modal.tsx | 45 ++- .../image-list/__tests__/index.spec.tsx | 2 +- .../image-previewer/__tests__/index.spec.tsx | 11 +- .../datasets/common/image-previewer/index.tsx | 138 +++---- .../dsl-confirm-modal.tsx | 63 +-- .../hooks/__tests__/use-dsl-import.spec.tsx | 94 ++++- .../hooks/use-dsl-import.ts | 158 +++++--- .../create-from-dsl-modal/index.tsx | 92 +++-- .../template-card/__tests__/index.spec.tsx | 30 ++ .../list/template-card/index.tsx | 52 ++- .../empty-dataset-creation-modal/index.tsx | 41 +- .../__tests__/index.spec.tsx | 3 +- .../create/stop-embedding-modal/index.tsx | 41 +- .../documents/components/rename-modal.tsx | 40 +- .../completed/common/regeneration-modal.tsx | 24 +- .../__tests__/chunk-detail-modal.spec.tsx | 28 +- .../components/chunk-detail-modal.tsx | 165 ++++---- .../components/result-item-external.tsx | 33 +- .../datasets/metadata/base/date-picker.tsx | 1 - .../metadata/edit-metadata-batch/modal.tsx | 95 +++-- .../__tests__/create-content.spec.tsx | 198 ++++------ .../dataset-metadata-drawer.spec.tsx | 46 +-- .../metadata-dataset/create-content.tsx | 115 ++++-- .../create-metadata-modal.tsx | 1 - .../dataset-metadata-drawer.tsx | 207 ++++++---- .../select-metadata-modal.tsx | 17 +- .../metadata/metadata-document/info-group.tsx | 4 +- .../datasets/rename-modal/index.tsx | 60 +-- .../__tests__/secret-key-modal.spec.tsx | 32 +- .../secret-key/secret-key-generate.tsx | 43 ++- .../develop/secret-key/secret-key-modal.tsx | 162 ++++---- .../explore/try-app/__tests__/index.spec.tsx | 56 +++ web/app/components/explore/try-app/index.tsx | 97 ++--- .../account-about/__tests__/index.spec.tsx | 2 +- .../components/header/account-about/index.tsx | 155 ++++---- .../api-based-extension-page/modal.tsx | 76 ++-- .../model-load-balancing-modal.spec.tsx | 8 - .../model-load-balancing-modal.tsx | 210 +++++----- .../header/nav/__tests__/index.spec.tsx | 54 --- .../nav/nav-selector/__tests__/index.spec.tsx | 54 --- .../install-plugin/install-bundle/index.tsx | 43 ++- .../install-from-github/index.tsx | 138 +++---- .../install-from-local-package/index.tsx | 95 ++--- .../steps/__tests__/uploading.spec.tsx | 87 ++++- .../steps/uploading.tsx | 91 +++-- .../install-from-marketplace/index.tsx | 110 +++--- .../__tests__/schema-modal.spec.tsx | 13 +- .../plugins/plugin-mutation-model/index.tsx | 79 ++-- .../__tests__/plugin-info.spec.tsx | 10 +- .../plugins/plugin-page/plugin-info.tsx | 33 +- .../__tests__/index.spec.tsx | 35 +- .../auto-update-setting/index.tsx | 1 - .../plugins/reference-setting-modal/index.tsx | 107 ++--- .../components/__tests__/index.spec.tsx | 6 +- ...blish-as-knowledge-pipeline-modal.spec.tsx | 9 +- .../__tests__/update-dsl-modal.spec.tsx | 20 +- .../__tests__/version-mismatch-modal.spec.tsx | 4 +- .../publish-as-knowledge-pipeline-modal.tsx | 132 ++++--- .../components/update-dsl-modal.tsx | 117 +++--- .../components/version-mismatch-modal.tsx | 63 +-- .../__tests__/use-update-dsl-modal.spec.ts | 56 ++- .../hooks/use-update-dsl-modal.ts | 43 ++- .../share/text-generation/info-modal.tsx | 65 ++-- .../mcp/__tests__/mcp-server-modal.spec.tsx | 67 +++- .../components/tools/mcp/mcp-server-modal.tsx | 147 ++++--- web/app/components/tools/mcp/modal.tsx | 26 +- .../__tests__/update-dsl-modal.spec.tsx | 35 ++ .../workflow/header/online-users.tsx | 1 - .../http/components/authorization/index.tsx | 126 +++--- .../nodes/http/components/curl-panel.tsx | 51 +-- .../json-schema-config-modal/index.tsx | 27 +- .../__tests__/integration.spec.tsx | 15 +- .../components/extract-parameter/update.tsx | 119 +++--- .../__tests__/input-var-list.spec.tsx | 27 +- .../components/workflow/operator/index.tsx | 2 +- .../conversation-variable-modal.tsx | 144 ++++--- .../action-menu/index.tsx | 3 +- .../delete-confirm-modal.tsx | 57 ++- .../panel/version-history-panel/empty.tsx | 2 +- .../version-history-panel/filter/index.tsx | 1 + .../restore-confirm-modal.tsx | 57 ++- .../components/workflow/update-dsl-modal.tsx | 164 ++++---- .../education-apply/expire-notice-modal.tsx | 117 +++--- .../education-apply/verify-state-modal.tsx | 62 +-- web/eslint.constants.mjs | 10 + web/models/pipeline.ts | 1 + web/package.json | 1 - web/service/__tests__/use-pipeline.spec.tsx | 81 ++++ web/service/use-pipeline.ts | 4 +- web/vitest.setup.ts | 40 +- 187 files changed, 5333 insertions(+), 6395 deletions(-) create mode 100644 web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx create mode 100644 web/app/components/app/create-app-dialog-shell.tsx delete mode 100644 web/app/components/base/content-dialog/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/content-dialog/index.stories.tsx delete mode 100644 web/app/components/base/content-dialog/index.tsx delete mode 100644 web/app/components/base/dialog/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/dialog/index.stories.tsx delete mode 100644 web/app/components/base/dialog/index.tsx rename web/app/components/base/features/new-feature-panel/__tests__/{dialog-wrapper.spec.tsx => feature-panel-drawer.spec.tsx} (51%) delete mode 100644 web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx create mode 100644 web/app/components/base/features/new-feature-panel/feature-panel-drawer.tsx delete mode 100644 web/app/components/base/fullscreen-modal/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/fullscreen-modal/index.stories.tsx delete mode 100644 web/app/components/base/fullscreen-modal/index.tsx delete mode 100644 web/app/components/base/modal-like-wrap/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/modal-like-wrap/index.stories.tsx delete mode 100644 web/app/components/base/modal-like-wrap/index.tsx delete mode 100644 web/app/components/base/modal/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/modal/index.stories.tsx delete mode 100644 web/app/components/base/modal/index.tsx create mode 100644 web/service/__tests__/use-pipeline.spec.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 683e6b09fe..f7ff4f8d6d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -159,21 +159,11 @@ "count": 5 } }, - "web/app/account/(commonLayout)/delete-account/components/feed-back.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/account/(commonLayout)/delete-account/components/verify-email.tsx": { "react/set-state-in-effect": { "count": 1 } }, - "web/app/account/(commonLayout)/delete-account/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/account/oauth/authorize/layout.tsx": { "ts/no-explicit-any": { "count": 1 @@ -211,9 +201,6 @@ "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-refresh/only-export-components": { "count": 1 }, @@ -272,16 +259,16 @@ "count": 1 } }, + "web/app/components/app/app-access-control/add-member-or-group-pop.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/app-publisher/features-wrapper.tsx": { "ts/no-explicit-any": { "count": 4 } }, - "web/app/components/app/app-publisher/version-info-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/base/var-highlight/index.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -293,9 +280,6 @@ } }, "web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -311,9 +295,6 @@ } }, "web/app/components/app/configuration/config-var/config-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 4 } @@ -356,9 +337,6 @@ } }, "web/app/components/app/configuration/config/automatic/get-automatic-res.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 4 }, @@ -387,9 +365,6 @@ } }, "web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 4 }, @@ -418,9 +393,6 @@ } }, "web/app/components/app/configuration/dataset-config/params-config/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 } @@ -494,26 +466,10 @@ "count": 1 } }, - "web/app/components/app/create-app-modal/index.tsx": { - "react/set-state-in-effect": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/create-from-dsl-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-refresh/only-export-components": { "count": 1 }, @@ -521,11 +477,6 @@ "count": 2 } }, - "web/app/components/app/duplicate-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/log/filter.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -561,9 +512,6 @@ } }, "web/app/components/app/switch-app-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 } @@ -881,11 +829,6 @@ "count": 3 } }, - "web/app/components/base/content-dialog/index.stories.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/date-and-time-picker/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 @@ -901,11 +844,6 @@ "count": 1 } }, - "web/app/components/base/dialog/index.stories.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/drawer-plus/index.stories.tsx": { "react/component-hook-factories": { "count": 1 @@ -916,11 +854,6 @@ "count": 1 } }, - "web/app/components/base/emoji-picker/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/error-boundary/index.tsx": { "react-refresh/only-export-components": { "count": 3 @@ -942,11 +875,6 @@ "count": 1 } }, - "web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx": { "ts/no-explicit-any": { "count": 3 @@ -973,9 +901,6 @@ } }, "web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -1544,16 +1469,6 @@ "count": 1 } }, - "web/app/components/base/modal-like-wrap/index.stories.tsx": { - "no-console": { - "count": 3 - } - }, - "web/app/components/base/modal/index.stories.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/new-audio-button/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -1600,6 +1515,11 @@ "count": 4 } }, + "web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": { "erasable-syntax-only/parameter-properties": { "count": 1 @@ -1685,8 +1605,8 @@ } }, "web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx": { - "ts/no-explicit-any": { - "count": 2 + "no-restricted-imports": { + "count": 1 } }, "web/app/components/base/prompt-editor/plugins/update-block.tsx": { @@ -1840,11 +1760,6 @@ "count": 4 } }, - "web/app/components/billing/annotation-full/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/billing/billing-page/__tests__/index.spec.tsx": { "ts/no-explicit-any": { "count": 4 @@ -1908,11 +1823,6 @@ "count": 1 } }, - "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -1921,9 +1831,6 @@ "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/datasets/create-from-pipeline/list/template-card/details/types.ts": { @@ -1931,16 +1838,6 @@ "count": 1 } }, - "web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create/file-preview/index.tsx": { "react/set-state-in-effect": { "count": 1 @@ -1995,11 +1892,6 @@ "count": 1 } }, - "web/app/components/datasets/create/stop-embedding-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create/website/firecrawl/index.tsx": { "no-console": { "count": 1 @@ -2058,11 +1950,6 @@ "count": 4 } }, - "web/app/components/datasets/documents/components/rename-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -2133,11 +2020,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -2212,21 +2094,21 @@ "count": 1 } }, + "web/app/components/datasets/formatted-text/flavours/edit-slice.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "web/app/components/datasets/formatted-text/flavours/preview-slice.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/datasets/formatted-text/flavours/type.ts": { "ts/no-empty-object-type": { "count": 1 } }, - "web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/hit-testing/components/result-item-external.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/hit-testing/components/score.tsx": { "unicorn/prefer-number-properties": { "count": 1 @@ -2245,11 +2127,6 @@ "count": 2 } }, - "web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": { "react/set-state-in-effect": { "count": 1 @@ -2263,36 +2140,11 @@ "count": 2 } }, - "web/app/components/datasets/metadata/metadata-dataset/create-content.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/datasets/metadata/types.ts": { "erasable-syntax-only/enums": { "count": 2 } }, - "web/app/components/datasets/rename-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/settings/chunk-structure/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -2311,16 +2163,6 @@ "count": 2 } }, - "web/app/components/develop/secret-key/secret-key-generate.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/develop/secret-key/secret-key-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/explore/banner/banner-item.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -2342,11 +2184,6 @@ "count": 1 } }, - "web/app/components/explore/try-app/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/explore/try-app/tab.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -2426,16 +2263,6 @@ "count": 1 } }, - "web/app/components/header/account-about/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/header/account-setting/api-based-extension-page/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/data-source-page-new/card.tsx": { "ts/no-explicit-any": { "count": 2 @@ -2505,6 +2332,11 @@ "count": 4 } }, + "web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/model-auth/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 6 @@ -2576,9 +2408,6 @@ } }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 }, @@ -2620,9 +2449,6 @@ "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-refresh/only-export-components": { "count": 1 } @@ -2638,9 +2464,6 @@ } }, "web/app/components/plugins/install-plugin/install-from-github/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 3 } @@ -2650,21 +2473,6 @@ "count": 1 } }, - "web/app/components/plugins/install-plugin/install-from-local-package/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, - "web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/marketplace/hooks.ts": { "@tanstack/query/exhaustive-deps": { "count": 1 @@ -2675,6 +2483,11 @@ "count": 1 } }, + "web/app/components/plugins/plugin-auth/authorized/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-auth/authorized/item.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2828,11 +2641,21 @@ "count": 7 } }, + "web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 } }, + "web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { "no-restricted-imports": { "count": 1 @@ -2846,11 +2669,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-mutation-model/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-page/context.ts": { "ts/no-explicit-any": { "count": 1 @@ -2866,21 +2684,11 @@ "count": 2 } }, - "web/app/components/plugins/plugin-page/plugin-info.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 } }, - "web/app/components/plugins/reference-setting-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/types.ts": { "erasable-syntax-only/enums": { "count": 7 @@ -2963,11 +2771,6 @@ "count": 4 } }, - "web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/rag-pipeline/components/rag-pipeline-children.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2983,16 +2786,6 @@ "count": 2 } }, - "web/app/components/rag-pipeline/components/update-dsl-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/rag-pipeline/components/version-mismatch-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/rag-pipeline/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 9 @@ -3048,11 +2841,6 @@ "count": 1 } }, - "web/app/components/share/text-generation/info-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/share/text-generation/menu-dropdown.tsx": { "react/set-state-in-effect": { "count": 1 @@ -3130,24 +2918,11 @@ "count": 1 } }, - "web/app/components/tools/mcp/mcp-server-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 5 - } - }, "web/app/components/tools/mcp/mcp-server-param-item.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/tools/mcp/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/mcp/provider-card.tsx": { "ts/no-explicit-any": { "count": 3 @@ -3265,6 +3040,11 @@ "count": 1 } }, + "web/app/components/workflow/block-selector/main.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/workflow/block-selector/market-place-plugin/action.tsx": { "react/set-state-in-effect": { "count": 1 @@ -3280,6 +3060,11 @@ "count": 1 } }, + "web/app/components/workflow/block-selector/tool-picker.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3843,16 +3628,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/http/components/authorization/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/http/components/curl-panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx": { "ts/no-explicit-any": { "count": 2 @@ -4034,11 +3809,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx": { "ts/no-explicit-any": { "count": 3 @@ -4159,9 +3929,6 @@ } }, "web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -4395,6 +4162,9 @@ } }, "web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 1 } @@ -4405,6 +4175,9 @@ } }, "web/app/components/workflow/operator/add-block.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -4466,9 +4239,6 @@ } }, "web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -4496,16 +4266,6 @@ "count": 4 } }, - "web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/panel/workflow-preview.tsx": { "ts/no-explicit-any": { "count": 2 @@ -4641,9 +4401,6 @@ } }, "web/app/components/workflow/update-dsl-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -4757,11 +4514,6 @@ "count": 1 } }, - "web/app/education-apply/expire-notice-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/education-apply/hooks.ts": { "react/set-state-in-effect": { "count": 5 diff --git a/packages/dify-ui/src/dialog/index.tsx b/packages/dify-ui/src/dialog/index.tsx index 24c5bcc463..dbb2448ff6 100644 --- a/packages/dify-ui/src/dialog/index.tsx +++ b/packages/dify-ui/src/dialog/index.tsx @@ -63,7 +63,7 @@ export function DialogContent({ /> <BaseDialog.Popup className={cn( - 'fixed top-1/2 left-1/2 z-1002 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl', + 'fixed top-1/2 left-1/2 z-1002 max-h-[80dvh] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl', 'transition-[transform,scale,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none', className, )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88e9eab4a0..64e49c6777 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,9 +42,6 @@ catalogs: '@formatjs/intl-localematcher': specifier: 0.8.6 version: 0.8.6 - '@headlessui/react': - specifier: 2.2.10 - version: 2.2.10 '@heroicons/react': specifier: 2.2.0 version: 2.2.0 @@ -901,9 +898,6 @@ importers: '@formatjs/intl-localematcher': specifier: 'catalog:' version: 0.8.6 - '@headlessui/react': - specifier: 'catalog:' - version: 2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@heroicons/react': specifier: 'catalog:' version: 2.2.0(react@19.2.6) @@ -2108,12 +2102,6 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.26.28': - resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - '@floating-ui/react@0.27.19': resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} peerDependencies: @@ -2129,13 +2117,6 @@ packages: '@formatjs/intl-localematcher@0.8.6': resolution: {integrity: sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==} - '@headlessui/react@2.2.10': - resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} - engines: {node: '>=10'} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - react-dom: ^18 || ^19 || ^19.0.0-rc - '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -3373,43 +3354,6 @@ packages: '@types/react': optional: true - '@react-aria/focus@3.21.5': - resolution: {integrity: sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-aria/interactions@3.27.1': - resolution: {integrity: sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-aria/ssr@3.9.10': - resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==} - engines: {node: '>= 12'} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-aria/utils@3.33.1': - resolution: {integrity: sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-stately/flags@3.1.2': - resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} - - '@react-stately/utils@3.11.0': - resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-types/shared@3.33.1': - resolution: {integrity: sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@reactflow/background@11.3.14': resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==} peerDependencies: @@ -3705,9 +3649,6 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/helpers@0.5.20': - resolution: {integrity: sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==} - '@t3-oss/env-core@0.13.11': resolution: {integrity: sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ==} peerDependencies: @@ -9410,14 +9351,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@floating-ui/react@0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@floating-ui/utils': 0.2.11 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - tabbable: 6.4.0 - '@floating-ui/react@0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -9434,16 +9367,6 @@ snapshots: dependencies: '@formatjs/fast-memoize': 3.1.4 - '@headlessui/react@2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@floating-ui/react': 0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-aria/focus': 3.21.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-aria/interactions': 3.27.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/react-virtual': 3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - use-sync-external-store: 1.6.0(react@19.2.6) - '@heroicons/react@2.2.0(react@19.2.6)': dependencies: react: 19.2.6 @@ -10469,55 +10392,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@react-aria/focus@3.21.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@react-aria/interactions': 3.27.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-aria/utils': 3.33.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.33.1(react@19.2.6) - '@swc/helpers': 0.5.20 - clsx: 2.1.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - - '@react-aria/interactions@3.27.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.6) - '@react-aria/utils': 3.33.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.33.1(react@19.2.6) - '@swc/helpers': 0.5.20 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - - '@react-aria/ssr@3.9.10(react@19.2.6)': - dependencies: - '@swc/helpers': 0.5.20 - react: 19.2.6 - - '@react-aria/utils@3.33.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.6) - '@react-stately/flags': 3.1.2 - '@react-stately/utils': 3.11.0(react@19.2.6) - '@react-types/shared': 3.33.1(react@19.2.6) - '@swc/helpers': 0.5.20 - clsx: 2.1.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - - '@react-stately/flags@3.1.2': - dependencies: - '@swc/helpers': 0.5.20 - - '@react-stately/utils@3.11.0(react@19.2.6)': - dependencies: - '@swc/helpers': 0.5.20 - react: 19.2.6 - - '@react-types/shared@3.33.1(react@19.2.6)': - dependencies: - react: 19.2.6 - '@reactflow/background@11.3.14(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -10888,10 +10762,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/helpers@0.5.20': - dependencies: - tslib: 2.8.1 - '@t3-oss/env-core@0.13.11(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(zod@4.4.3)': optionalDependencies: typescript: 6.0.3 @@ -16514,7 +16384,6 @@ time: '@eslint/js@10.0.1': '2026-02-06T22:34:56.290Z' '@floating-ui/react@0.27.19': '2026-03-03T03:02:09.664Z' '@formatjs/intl-localematcher@0.8.6': '2026-05-05T17:39:39.364Z' - '@headlessui/react@2.2.10': '2026-04-07T17:12:43.551Z' '@heroicons/react@2.2.0': '2024-11-18T15:33:27.317Z' '@hey-api/openapi-ts@0.97.1': '2026-05-04T00:37:14.271Z' '@hono/node-server@2.0.1': '2026-04-30T08:51:26.973Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2a8be97969..1373efd21e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -66,7 +66,6 @@ catalog: '@eslint/js': 10.0.1 '@floating-ui/react': 0.27.19 '@formatjs/intl-localematcher': 0.8.6 - '@headlessui/react': 2.2.10 '@heroicons/react': 2.2.0 '@hey-api/openapi-ts': 0.97.1 '@hono/node-server': 2.0.1 diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index ef3bee5167..8162f12dad 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -63,28 +63,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { } }) -// Mock headless UI Popover so it renders content without transition -vi.mock('@headlessui/react', async () => { - const actual = await vi.importActual<typeof import('@headlessui/react')>('@headlessui/react') - return { - ...actual, - Popover: ({ children, className }: { children: ((bag: { open: boolean }) => React.ReactNode) | React.ReactNode, className?: string }) => ( - <div className={className} data-testid="popover-wrapper"> - {typeof children === 'function' ? children({ open: true }) : children} - </div> - ), - PopoverButton: ({ children, className, ref: _ref, ...rest }: Record<string, unknown>) => ( - <button className={className as string} {...rest}>{children as React.ReactNode}</button> - ), - PopoverPanel: ({ children, className }: { children: ((bag: { close: () => void }) => React.ReactNode) | React.ReactNode, className?: string }) => ( - <div className={className}> - {typeof children === 'function' ? children({ close: vi.fn() }) : children} - </div> - ), - Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>, - } -}) - vi.mock('@/next/dynamic', () => ({ default: (loader: () => Promise<{ default: React.ComponentType }>) => { let Component: React.ComponentType<Record<string, unknown>> | null = null diff --git a/web/__tests__/develop/api-key-management-flow.test.tsx b/web/__tests__/develop/api-key-management-flow.test.tsx index 188b8e6304..233e152f8f 100644 --- a/web/__tests__/develop/api-key-management-flow.test.tsx +++ b/web/__tests__/develop/api-key-management-flow.test.tsx @@ -13,7 +13,7 @@ import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ApiServer from '@/app/components/develop/ApiServer' -// ---------- fake timers (HeadlessUI Dialog transitions) ---------- +// ---------- fake timers (modal transitions) ---------- beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }) }) @@ -100,7 +100,7 @@ describe('API Key management flow', () => { }) await flushUI() - // SecretKeyModal should render with real HeadlessUI Dialog + // SecretKeyModal should render with real modal content await waitFor(() => { expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument() diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx index b4a3befea0..eb128924c0 100644 --- a/web/__tests__/header/account-dropdown-flow.test.tsx +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -131,6 +131,7 @@ describe('Header Account Dropdown Flow', () => { payload: ACCOUNT_SETTING_TAB.MEMBERS, }) + fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) fireEvent.click(screen.getByText('common.userProfile.about')) await waitFor(() => { diff --git a/web/__tests__/header/nav-flow.test.tsx b/web/__tests__/header/nav-flow.test.tsx index 58c95f0a01..dba7b4bf4a 100644 --- a/web/__tests__/header/nav-flow.test.tsx +++ b/web/__tests__/header/nav-flow.test.tsx @@ -13,83 +13,6 @@ const mockOnLoadMore = vi.fn() let mockSelectedSegment = 'app' let mockIsCurrentWorkspaceEditor = true -vi.mock('@headlessui/react', () => { - type MenuContextValue = { - open: boolean - setOpen: React.Dispatch<React.SetStateAction<boolean>> - } - const MenuContext = React.createContext<MenuContextValue | null>(null) - - const Menu = ({ - children, - }: { - children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) - }) => { - const [open, setOpen] = React.useState(false) - const value = React.useMemo(() => ({ open, setOpen }), [open]) - - return ( - <MenuContext.Provider value={value}> - {typeof children === 'function' ? children({ open }) : children} - </MenuContext.Provider> - ) - } - - const MenuButton = ({ - children, - onClick, - ...props - }: React.ButtonHTMLAttributes<HTMLButtonElement>) => { - const context = React.useContext(MenuContext) - - return ( - <button - type="button" - aria-expanded={context?.open ?? false} - onClick={(event) => { - context?.setOpen(v => !v) - onClick?.(event) - }} - {...props} - > - {children} - </button> - ) - } - - const MenuItems = ({ - as: Component = 'div', - children, - ...props - }: { - as?: React.ElementType - children: React.ReactNode - } & Record<string, unknown>) => { - const context = React.useContext(MenuContext) - if (!context?.open) - return null - - return <Component {...props}>{children}</Component> - } - - const MenuItem = ({ - as: Component = 'div', - children, - ...props - }: { - as?: React.ElementType - children: React.ReactNode - } & Record<string, unknown>) => <Component {...props}>{children}</Component> - - return { - Menu, - MenuButton, - MenuItems, - MenuItem, - Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>, - } -}) - vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 46d7f7833e..1e001a5ca4 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -19,6 +19,8 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import AppSideBar from '@/app/components/app-sidebar' +import { AppInfoDetailLayer } from '@/app/components/app-sidebar/app-info' +import { useAppInfoActions } from '@/app/components/app-sidebar/app-info/use-app-info-actions' import { useStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' @@ -45,6 +47,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => { const media = useBreakpoints() const isMobile = media === MediaType.mobile const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext() + const appInfoActions = useAppInfoActions({ resetKey: appId }) const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({ appDetail: state.appDetail, setAppDetail: state.setAppDetail, @@ -162,11 +165,13 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => { {appDetail && ( <AppSideBar navigation={navigation} + appInfoActions={appInfoActions} /> )} <div className="grow overflow-hidden bg-components-panel-bg"> {children} </div> + <AppInfoDetailLayer actions={appInfoActions} /> </div> ) } diff --git a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx index ee1e72e6e7..5b68290dd0 100644 --- a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx @@ -1,9 +1,9 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import CustomDialog from '@/app/components/base/dialog' import Textarea from '@/app/components/base/textarea' import { useAppContext } from '@/context/app-context' import { useRouter } from '@/next/navigation' @@ -47,26 +47,34 @@ export default function FeedBack(props: DeleteAccountProps) { handleSuccess() }, [handleSuccess, props]) return ( - <CustomDialog - show={true} - onClose={props.onCancel} - title={t('account.feedbackTitle', { ns: 'common' })} - className="max-w-[480px]" - footer={false} + <Dialog + open + onOpenChange={(open) => { + if (!open) + props.onCancel() + }} > - <label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label> - <Textarea - rows={6} - value={userFeedback} - placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string} - onChange={(e) => { - setUserFeedback(e.target.value) - }} - /> - <div className="mt-3 flex w-full flex-col gap-2"> - <Button className="w-full" loading={isPending} variant="primary" onClick={handleSubmit}>{t('operation.submit', { ns: 'common' })}</Button> - <Button className="w-full" onClick={handleSkip}>{t('operation.skip', { ns: 'common' })}</Button> - </div> - </CustomDialog> + <DialogContent + className="max-w-[480px] overflow-hidden!" + backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]" + > + <DialogTitle className="pr-8 pb-3 title-2xl-semi-bold text-text-primary"> + {t('account.feedbackTitle', { ns: 'common' })} + </DialogTitle> + <label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label> + <Textarea + rows={6} + value={userFeedback} + placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string} + onChange={(e) => { + setUserFeedback(e.target.value) + }} + /> + <div className="mt-3 flex w-full flex-col gap-2"> + <Button className="w-full" loading={isPending} variant="primary" onClick={handleSubmit}>{t('operation.submit', { ns: 'common' })}</Button> + <Button className="w-full" onClick={handleSkip}>{t('operation.skip', { ns: 'common' })}</Button> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/account/(commonLayout)/delete-account/index.tsx b/web/app/account/(commonLayout)/delete-account/index.tsx index 416f680a60..e4af24e8bf 100644 --- a/web/app/account/(commonLayout)/delete-account/index.tsx +++ b/web/app/account/(commonLayout)/delete-account/index.tsx @@ -1,7 +1,7 @@ 'use client' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import CustomDialog from '@/app/components/base/dialog' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import CheckEmail from './components/check-email' import FeedBack from './components/feed-back' @@ -30,22 +30,30 @@ export default function DeleteAccount(props: DeleteAccountProps) { return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} /> return ( - <CustomDialog - show={true} - onClose={props.onCancel} - title={t('account.delete', { ns: 'common' })} - className="max-w-[480px]" - footer={false} + <Dialog + open + onOpenChange={(open) => { + if (!open) + props.onCancel() + }} > - {!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />} - {showVerifyEmail && ( - <VerifyEmail - onCancel={props.onCancel} - onConfirm={() => { - setShowFeedbackDialog(true) - }} - /> - )} - </CustomDialog> + <DialogContent + className="max-w-[480px] overflow-hidden!" + backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]" + > + <DialogTitle className="pr-8 pb-3 title-2xl-semi-bold text-text-primary"> + {t('account.delete', { ns: 'common' })} + </DialogTitle> + {!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />} + {showVerifyEmail && ( + <VerifyEmail + onCancel={props.onCancel} + onConfirm={() => { + setShowFeedbackDialog(true) + }} + /> + )} + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/app-sidebar/__tests__/index.spec.tsx b/web/app/components/app-sidebar/__tests__/index.spec.tsx index b2e1e92bbb..5c00ced6cc 100644 --- a/web/app/components/app-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/index.spec.tsx @@ -60,6 +60,9 @@ vi.mock('../app-info', () => ({ default: ({ expand }: { expand: boolean }) => ( <div data-testid="app-info" data-expand={expand} /> ), + AppInfoView: ({ expand }: { expand: boolean }) => ( + <div data-testid="app-info" data-expand={expand} /> + ), })) vi.mock('../app-sidebar-dropdown', () => ({ diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx index 9af90359f0..8017201e6e 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx @@ -11,17 +11,16 @@ vi.mock('../../../base/app-icon', () => ({ ), })) -vi.mock('@/app/components/base/content-dialog', () => ({ - default: ({ show, onClose, children, className }: { - show: boolean +vi.mock('../app-info-detail-drawer', () => ({ + AppInfoDetailDrawer: ({ open, onClose, children }: { + open: boolean onClose: () => void children: React.ReactNode - className?: string }) => ( - show + open ? ( - <div data-testid="content-dialog" className={className}> - <button type="button" data-testid="dialog-close" onClick={onClose}>Close</button> + <div data-testid="app-info-detail-drawer"> + <button type="button" data-testid="drawer-close" onClick={onClose}>Close</button> {children} </div> ) @@ -96,12 +95,12 @@ describe('AppInfoDetailPanel', () => { describe('Rendering', () => { it('should not render when show is false', () => { render(<AppInfoDetailPanel {...defaultProps} show={false} />) - expect(screen.queryByTestId('content-dialog')).not.toBeInTheDocument() + expect(screen.queryByTestId('app-info-detail-drawer')).not.toBeInTheDocument() }) - it('should render dialog when show is true', () => { + it('should render drawer when show is true', () => { render(<AppInfoDetailPanel {...defaultProps} />) - expect(screen.getByTestId('content-dialog')).toBeInTheDocument() + expect(screen.getByTestId('app-info-detail-drawer')).toBeInTheDocument() }) it('should display app name', () => { @@ -285,12 +284,12 @@ describe('AppInfoDetailPanel', () => { }) }) - describe('Dialog interactions', () => { - it('should call onClose when dialog close button is clicked', async () => { + describe('Drawer interactions', () => { + it('should call onClose when drawer close button is clicked', async () => { const user = userEvent.setup() render(<AppInfoDetailPanel {...defaultProps} />) - await user.click(screen.getByTestId('dialog-close')) + await user.click(screen.getByTestId('drawer-close')) expect(defaultProps.onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index 23e5e51949..fe5fa03f67 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -153,6 +153,27 @@ describe('useAppInfoActions', () => { expect(result.current.panelOpen).toBe(false) expect(onDetailExpand).toHaveBeenCalledWith(false) }) + + it('should reset app-scoped state when resetKey changes', () => { + const { result, rerender } = renderHook( + ({ resetKey }) => useAppInfoActions({ resetKey }), + { initialProps: { resetKey: 'app-1' } }, + ) + + act(() => { + result.current.openModal('delete') + result.current.setPanelOpen(true) + }) + + expect(result.current.panelOpen).toBe(true) + expect(result.current.activeModal).toBe('delete') + + rerender({ resetKey: 'app-2' }) + + expect(result.current.panelOpen).toBe(false) + expect(result.current.activeModal).toBeNull() + expect(result.current.secretEnvList).toEqual([]) + }) }) describe('Modal management', () => { diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx new file mode 100644 index 0000000000..e9217df03c --- /dev/null +++ b/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' + +type AppInfoDetailDrawerProps = { + open: boolean + onClose: () => void + children: ReactNode +} + +export function AppInfoDetailDrawer({ + open, + onClose, + children, +}: AppInfoDetailDrawerProps) { + if (!open) + return null + + return ( + <div className="absolute inset-0 z-50"> + <button + type="button" + aria-label="Close app info" + className="absolute inset-0 cursor-default bg-app-detail-overlay-bg" + onClick={onClose} + /> + <section + role="dialog" + aria-modal="false" + className="absolute top-2 bottom-2 left-2 flex w-[452px] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border-r border-divider-burn bg-app-detail-bg" + > + {children} + </section> + </div> + ) +} 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 3dabb2a91e..13a55f8984 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 @@ -14,9 +14,9 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view' -import ContentDialog from '@/app/components/base/content-dialog' import { AppModeEnum } from '@/types/app' import AppIcon from '../../base/app-icon' +import { AppInfoDetailDrawer } from './app-info-detail-drawer' import { getAppModeLabel } from './app-mode-labels' import AppOperations from './app-operations' @@ -94,10 +94,9 @@ const AppInfoDetailPanel = ({ }, [appDetail.mode, t, openModal]) return ( - <ContentDialog - show={show} + <AppInfoDetailDrawer + open={show} onClose={onClose} - className="absolute top-2 bottom-2 left-2 flex w-[452px] max-w-[calc(100vw-1rem)] flex-col rounded-2xl p-0!" > <div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4"> <div className="flex items-center gap-3 self-stretch"> @@ -109,16 +108,16 @@ const AppInfoDetailPanel = ({ imageUrl={appDetail.icon_url} /> <div className="flex flex-1 flex-col items-start justify-center overflow-hidden"> - <div className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</div> + <h2 className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</h2> <div className="system-2xs-medium-uppercase text-text-tertiary"> {getAppModeLabel(appDetail.mode, t)} </div> </div> </div> {appDetail.description && ( - <div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary"> + <p className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary"> {appDetail.description} - </div> + </p> )} <AppOperations gap={4} @@ -144,7 +143,7 @@ const AppInfoDetailPanel = ({ </Button> </div> )} - </ContentDialog> + </AppInfoDetailDrawer> ) } diff --git a/web/app/components/app-sidebar/app-info/index.tsx b/web/app/components/app-sidebar/app-info/index.tsx index a0628ec786..14a2defc1e 100644 --- a/web/app/components/app-sidebar/app-info/index.tsx +++ b/web/app/components/app-sidebar/app-info/index.tsx @@ -1,3 +1,4 @@ +import type { AppInfoActions } from './use-app-info-actions' import * as React from 'react' import { useAppContext } from '@/context/app-context' import AppInfoDetailPanel from './app-info-detail-panel' @@ -12,13 +13,22 @@ type IAppInfoProps = { onDetailExpand?: (expand: boolean) => void } -const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => { - const { isCurrentWorkspaceEditor } = useAppContext() +type AppInfoViewProps = Omit<IAppInfoProps, 'onDetailExpand'> & { + actions: AppInfoActions + renderDetail?: boolean +} +type AppInfoDetailLayerProps = { + actions: AppInfoActions + open?: boolean +} + +export const AppInfoDetailLayer = ({ + actions, + open = actions.panelOpen, +}: AppInfoDetailLayerProps) => { const { appDetail, - panelOpen, - setPanelOpen, closePanel, activeModal, openModal, @@ -31,26 +41,16 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx exportCheck, handleConfirmExport, onConfirmDelete, - } = useAppInfoActions({ onDetailExpand }) + } = actions if (!appDetail) return null return ( - <div> - {!onlyShowDetail && ( - <AppInfoTrigger - appDetail={appDetail} - expand={expand} - onClick={() => { - if (isCurrentWorkspaceEditor) - setPanelOpen(v => !v) - }} - /> - )} + <> <AppInfoDetailPanel appDetail={appDetail} - show={onlyShowDetail ? openState : panelOpen} + show={open} onClose={closePanel} openModal={openModal} exportCheck={exportCheck} @@ -68,8 +68,58 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx handleConfirmExport={handleConfirmExport} onConfirmDelete={onConfirmDelete} /> + </> + ) +} + +export const AppInfoView = ({ + expand, + onlyShowDetail = false, + openState = false, + actions, + renderDetail = true, +}: AppInfoViewProps) => { + const { isCurrentWorkspaceEditor } = useAppContext() + const { + appDetail, + panelOpen, + setPanelOpen, + } = actions + + if (!appDetail) + return null + + return ( + <div> + {!onlyShowDetail && ( + <AppInfoTrigger + appDetail={appDetail} + expand={expand} + onClick={() => { + if (isCurrentWorkspaceEditor) + setPanelOpen(v => !v) + }} + /> + )} + {renderDetail && ( + <AppInfoDetailLayer + actions={actions} + open={onlyShowDetail ? openState : panelOpen} + /> + )} </div> ) } +const AppInfo = ({ onDetailExpand, ...props }: IAppInfoProps) => { + const actions = useAppInfoActions({ onDetailExpand }) + + return ( + <AppInfoView + {...props} + actions={actions} + /> + ) +} + export default React.memo(AppInfo) diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index 262a8c7db0..134726a50c 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -1,3 +1,4 @@ +import type { Dispatch, SetStateAction } from 'react' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { EnvironmentVariable } from '@/app/components/workflow/types' @@ -19,9 +20,36 @@ export type AppInfoModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'imp type UseAppInfoActionsParams = { onDetailExpand?: (expand: boolean) => void + resetKey?: string } -export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { +type AppInfoUiState = { + resetKey?: string + panelOpen: boolean + activeModal: AppInfoModalType + secretEnvList: EnvironmentVariable[] +} + +const emptySecretEnvList: EnvironmentVariable[] = [] + +const createInitialUiState = (resetKey?: string): AppInfoUiState => ({ + resetKey, + panelOpen: false, + activeModal: null, + secretEnvList: [], +}) + +const resolveStateAction = <T>(value: SetStateAction<T>, previous: T) => { + return typeof value === 'function' + ? (value as (previous: T) => T)(previous) + : value +} + +const getCurrentUiState = (state: AppInfoUiState, resetKey?: string) => { + return state.resetKey === resetKey ? state : createInitialUiState(resetKey) +} + +export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoActionsParams) { const { t } = useTranslation() const { replace } = useRouter() const { onPlanInfoChanged } = useProviderContext() @@ -29,23 +57,55 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { const setAppDetail = useAppStore(state => state.setAppDetail) const invalidateAppList = useInvalidateAppList() - const [panelOpen, setPanelOpen] = useState(false) - const [activeModal, setActiveModal] = useState<AppInfoModalType>(null) - const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([]) + const [uiState, setUiState] = useState(() => createInitialUiState(resetKey)) + const uiStateMatchesResetKey = uiState.resetKey === resetKey + const panelOpen = uiStateMatchesResetKey ? uiState.panelOpen : false + const activeModal = uiStateMatchesResetKey ? uiState.activeModal : null + const secretEnvList = uiStateMatchesResetKey ? uiState.secretEnvList : emptySecretEnvList + + const setPanelOpen = useCallback<Dispatch<SetStateAction<boolean>>>((value) => { + setUiState((state) => { + const current = getCurrentUiState(state, resetKey) + return { + ...current, + panelOpen: resolveStateAction(value, current.panelOpen), + } + }) + }, [resetKey]) + + const setActiveModal = useCallback<Dispatch<SetStateAction<AppInfoModalType>>>((value) => { + setUiState((state) => { + const current = getCurrentUiState(state, resetKey) + return { + ...current, + activeModal: resolveStateAction(value, current.activeModal), + } + }) + }, [resetKey]) + + const setSecretEnvList = useCallback<Dispatch<SetStateAction<EnvironmentVariable[]>>>((value) => { + setUiState((state) => { + const current = getCurrentUiState(state, resetKey) + return { + ...current, + secretEnvList: resolveStateAction(value, current.secretEnvList), + } + }) + }, [resetKey]) const closePanel = useCallback(() => { setPanelOpen(false) onDetailExpand?.(false) - }, [onDetailExpand]) + }, [onDetailExpand, setPanelOpen]) const openModal = useCallback((modal: Exclude<AppInfoModalType, null>) => { closePanel() setActiveModal(modal) - }, [closePanel]) + }, [closePanel, setActiveModal]) const closeModal = useCallback(() => { setActiveModal(null) - }, []) + }, [setActiveModal]) const emitAppMetaUpdate = useCallback(() => { if (!appDetail?.id) @@ -178,7 +238,7 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { return } setActiveModal('exportWarning') - }, [appDetail, onExport]) + }, [appDetail, onExport, setActiveModal]) const handleConfirmExport = useCallback(async () => { if (!appDetail) @@ -198,7 +258,7 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { finally { closeModal() } - }, [appDetail, closeModal, onExport, t]) + }, [appDetail, closeModal, onExport, setSecretEnvList, t]) const onConfirmDelete = useCallback(async () => { if (!appDetail) @@ -235,3 +295,5 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { onConfirmDelete, } } + +export type AppInfoActions = ReturnType<typeof useAppInfoActions> diff --git a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx index 0b5f13d918..e0661b9c52 100644 --- a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx @@ -1,3 +1,4 @@ +import type { AppInfoActions } from './app-info/use-app-info-actions' import type { NavIcon } from './nav-link' import { cn } from '@langgenius/dify-ui/cn' import { @@ -27,9 +28,10 @@ type Props = { icon: NavIcon selectedIcon: NavIcon }> + appInfoActions?: AppInfoActions } -const AppSidebarDropdown = ({ navigation }: Props) => { +const AppSidebarDropdown = ({ navigation, appInfoActions }: Props) => { const { t } = useTranslation() const { isCurrentWorkspaceEditor } = useAppContext() const appDetail = useAppStore(state => state.appDetail) @@ -69,7 +71,10 @@ const AppSidebarDropdown = ({ navigation }: Props) => { <div className={cn('flex flex-col gap-2 rounded-lg p-2 pb-2.5', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')} onClick={() => { - setDetailExpand(true) + if (appInfoActions) + appInfoActions.setPanelOpen(true) + else + setDetailExpand(true) setOpen(false) }} > @@ -109,9 +114,11 @@ const AppSidebarDropdown = ({ navigation }: Props) => { </DropdownMenuContent> </DropdownMenu> </div> - <div className="z-20"> - <AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} /> - </div> + {!appInfoActions && ( + <div className="z-20"> + <AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} /> + </div> + )} </> ) } diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index 4750d7919c..6fe5fffe8c 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -1,3 +1,4 @@ +import type { AppInfoActions } from './app-info/use-app-info-actions' import type { NavIcon } from './nav-link' import { cn } from '@langgenius/dify-ui/cn' import { useHover, useKeyPress } from 'ahooks' @@ -10,7 +11,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { usePathname } from '@/next/navigation' import Divider from '../base/divider' import { getKeyboardKeyCodeBySystem } from '../workflow/utils' -import AppInfo from './app-info' +import AppInfo, { AppInfoView } from './app-info' import AppSidebarDropdown from './app-sidebar-dropdown' import DatasetInfo from './dataset-info' import DatasetSidebarDropdown from './dataset-sidebar-dropdown' @@ -36,12 +37,14 @@ type IAppDetailNavProps = { disabled?: boolean }> extraInfo?: (modeState: string) => React.ReactNode + appInfoActions?: AppInfoActions } const AppDetailNav = ({ navigation, extraInfo, iconType = 'app', + appInfoActions, }: IAppDetailNavProps) => { const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({ appSidebarExpand: state.appSidebarExpand, @@ -89,7 +92,10 @@ const AppDetailNav = ({ if (inWorkflowCanvas && hideHeader) { return ( <div className="flex w-0 shrink-0"> - <AppSidebarDropdown navigation={navigation} /> + <AppSidebarDropdown + navigation={navigation} + appInfoActions={appInfoActions} + /> </div> ) } @@ -117,7 +123,15 @@ const AppDetailNav = ({ )} > {iconType === 'app' && ( - <AppInfo expand={expand} /> + appInfoActions + ? ( + <AppInfoView + expand={expand} + actions={appInfoActions} + renderDetail={false} + /> + ) + : <AppInfo expand={expand} /> )} {iconType !== 'app' && ( <DatasetInfo expand={expand} /> diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index 550794cce7..0f6c27fd5a 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -1,13 +1,12 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import AnnotationFull from '@/app/components/billing/annotation-full' import { useProviderContext } from '@/context/provider-context' import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation' @@ -88,37 +87,40 @@ const BatchModal: FC<IBatchModalProps> = ({ } return ( - <Modal isShow={isShow} onClose={noop} className="max-w-[520px]! rounded-xl! px-8 py-6"> - <div className="relative pb-1 system-xl-medium text-text-primary">{t('batchModal.title', { ns: 'appAnnotation' })}</div> - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> - <CSVUploader - file={currentCSV} - updateFile={handleFile} - /> - <CSVDownloader /> + <Dialog open={isShow}> + <DialogContent className="w-full max-w-[520px]! overflow-hidden! rounded-xl! border-none px-8 py-6 text-left align-middle"> - {isAnnotationFull && ( - <div className="mt-4"> - <AnnotationFull /> + <div className="relative pb-1 system-xl-medium text-text-primary">{t('batchModal.title', { ns: 'appAnnotation' })}</div> + <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}> + <RiCloseLine className="h-4 w-4 text-text-tertiary" /> </div> - )} + <CSVUploader + file={currentCSV} + updateFile={handleFile} + /> + <CSVDownloader /> - <div className="mt-[28px] flex justify-end pt-6"> - <Button className="mr-2 system-sm-medium text-text-tertiary" onClick={onCancel}> - {t('batchModal.cancel', { ns: 'appAnnotation' })} - </Button> - <Button - variant="primary" - onClick={handleSend} - disabled={isAnnotationFull || !currentCSV} - loading={importStatus === ProcessStatus.PROCESSING || importStatus === ProcessStatus.WAITING} - > - {t('batchModal.run', { ns: 'appAnnotation' })} - </Button> - </div> - </Modal> + {isAnnotationFull && ( + <div className="mt-4"> + <AnnotationFull /> + </div> + )} + + <div className="mt-[28px] flex justify-end pt-6"> + <Button className="mr-2 system-sm-medium text-text-tertiary" onClick={onCancel}> + {t('batchModal.cancel', { ns: 'appAnnotation' })} + </Button> + <Button + variant="primary" + onClick={handleSend} + disabled={isAnnotationFull || !currentCSV} + loading={importStatus === ProcessStatus.PROCESSING || importStatus === ProcessStatus.WAITING} + > + {t('batchModal.run', { ns: 'appAnnotation' })} + </Button> + </div> + </DialogContent> + </Dialog> ) } export default React.memo(BatchModal) diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 5e7b2dc1d0..f953514702 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -5,129 +5,11 @@ import type { AnnotationItemBasic } from '../../type' import type { Locale } from '@/i18n-config' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' import HeaderOptions from '../index' -vi.mock('@headlessui/react', () => { - type PopoverContextValue = { open: boolean, setOpen: (open: boolean) => void } - type MenuContextValue = { open: boolean, setOpen: (open: boolean) => void } - const PopoverContext = React.createContext<PopoverContextValue | null>(null) - const MenuContext = React.createContext<MenuContextValue | null>(null) - - const Popover = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => { - const [open, setOpen] = React.useState(false) - const value = React.useMemo(() => ({ open, setOpen }), [open]) - return ( - <PopoverContext.Provider value={value}> - {typeof children === 'function' ? children({ open }) : children} - </PopoverContext.Provider> - ) - } - - const PopoverButton = React.forwardRef(({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }, ref: React.Ref<HTMLButtonElement>) => { - const context = React.useContext(PopoverContext) - const handleClick = () => { - context?.setOpen(!context.open) - onClick?.() - } - return ( - <button - ref={ref} - type="button" - aria-expanded={context?.open ?? false} - onClick={handleClick} - {...props} - > - {children} - </button> - ) - }) - - const PopoverPanel = React.forwardRef(({ children, ...props }: { children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode) }, ref: React.Ref<HTMLDivElement>) => { - const context = React.useContext(PopoverContext) - if (!context?.open) - return null - const content = typeof children === 'function' ? children({ close: () => context.setOpen(false) }) : children - return ( - <div ref={ref} {...props}> - {content} - </div> - ) - }) - - const Menu = ({ children }: { children: React.ReactNode }) => { - const [open, setOpen] = React.useState(false) - const value = React.useMemo(() => ({ open, setOpen }), [open]) - return ( - <MenuContext.Provider value={value}> - {children} - </MenuContext.Provider> - ) - } - - const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => { - const context = React.useContext(MenuContext) - const handleClick = () => { - context?.setOpen(!context.open) - onClick?.() - } - return ( - <button type="button" aria-expanded={context?.open ?? false} onClick={handleClick} {...props}> - {children} - </button> - ) - } - - const MenuItems = ({ children, ...props }: { children: React.ReactNode }) => { - const context = React.useContext(MenuContext) - if (!context?.open) - return null - return ( - <div {...props}> - {children} - </div> - ) - } - - return { - Dialog: ({ open, children, className }: { open?: boolean, children: React.ReactNode, className?: string }) => { - if (open === false) - return null - return ( - <div role="dialog" className={className}> - {children} - </div> - ) - }, - DialogBackdrop: ({ children, className, onClick }: { children?: React.ReactNode, className?: string, onClick?: () => void }) => ( - <div className={className} onClick={onClick}> - {children} - </div> - ), - DialogPanel: ({ children, className, ...props }: { children: React.ReactNode, className?: string }) => ( - <div className={className} {...props}> - {children} - </div> - ), - DialogTitle: ({ children, className, ...props }: { children: React.ReactNode, className?: string }) => ( - <div className={className} {...props}> - {children} - </div> - ), - Popover, - PopoverButton, - PopoverPanel, - Menu, - MenuButton, - MenuItems, - Transition: ({ show = true, children }: { show?: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null), - TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>, - } -}) - const mockJsonToCSV = vi.fn((_: unknown) => 'csv-content') const mockCSVDownloader = vi.fn(({ children }) => <>{children}</>) diff --git a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx index 13331f3f9c..03a35bd52a 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx @@ -10,6 +10,7 @@ describe('AccessControlDialog', () => { ) expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toHaveClass('custom-dialog') expect(screen.getByText('Dialog Content')).toBeInTheDocument() }) diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 4aaea1670f..a686ba9f2e 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable ts/no-explicit-any */ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' @@ -43,34 +42,6 @@ vi.mock('@/service/access-control', () => ({ useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) -vi.mock('@headlessui/react', () => { - const DialogComponent: any = ({ children, className, ...rest }: any) => ( - <div role="dialog" className={className} {...rest}>{children}</div> - ) - DialogComponent.Panel = ({ children, className, ...rest }: any) => ( - <div className={className} {...rest}>{children}</div> - ) - const DialogTitle = ({ children, className, ...rest }: any) => ( - <div className={className} {...rest}>{children}</div> - ) - const DialogDescription = ({ children, className, ...rest }: any) => ( - <div className={className} {...rest}>{children}</div> - ) - const TransitionChild = ({ children }: any) => ( - <>{typeof children === 'function' ? children({}) : children}</> - ) - const Transition = ({ show = true, children }: any) => ( - show ? <>{typeof children === 'function' ? children({}) : children}</> : null - ) - Transition.Child = TransitionChild - return { - Dialog: DialogComponent, - Transition, - DialogTitle, - Description: DialogDescription, - } -}) - vi.mock('ahooks', async (importOriginal) => { const actual = await importOriginal<typeof import('ahooks')>() return { diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx index 611c6f1c92..ed1301386c 100644 --- a/web/app/components/app/app-access-control/access-control-dialog.tsx +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -23,9 +23,15 @@ const AccessControlDialog = ({ const close = useCallback(() => { onClose?.() }, [onClose]) + return ( - <Dialog open={show} onOpenChange={open => !open && close()}> - <DialogContent className={cn('min-h-[323px] w-[600px] p-0', className)}> + <Dialog open={show} disablePointerDismissal onOpenChange={open => !open && close()}> + <DialogContent + className={cn( + 'h-auto max-h-none min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-all', + className, + )} + > <DialogCloseButton className="top-5 right-5 h-8 w-8" /> {children} </DialogContent> diff --git a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx index 4a461bd942..942a199a87 100644 --- a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx @@ -87,6 +87,22 @@ describe('VersionInfoModal', () => { expect(handleClose).toHaveBeenCalledTimes(1) }) + it('should close when the dialog requests close', () => { + const handleClose = vi.fn() + + render( + <VersionInfoModal + isOpen + onClose={handleClose} + onPublish={vi.fn()} + />, + ) + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + expect(handleClose).toHaveBeenCalledTimes(1) + }) + it('should validate release note length and clear previous errors before publishing', () => { const handlePublish = vi.fn() const handleClose = vi.fn() diff --git a/web/app/components/app/app-publisher/version-info-modal.tsx b/web/app/components/app/app-publisher/version-info-modal.tsx index 3fe4c61fb7..264975a08b 100644 --- a/web/app/components/app/app-publisher/version-info-modal.tsx +++ b/web/app/components/app/app-publisher/version-info-modal.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react' import type { VersionHistory } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import Input from '../../base/input' import Textarea from '../../base/textarea' @@ -66,46 +66,55 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({ }, []) return ( - <Modal className="p-0" isShow={isOpen} onClose={onClose}> - <div className="relative w-full p-6 pr-14 pb-4"> - <div className="title-2xl-semi-bold text-text-primary first-letter:capitalize"> - {versionInfo?.marked_name ? t('versionHistory.editVersionInfo', { ns: 'workflow' }) : t('versionHistory.nameThisVersion', { ns: 'workflow' })} - </div> - <div className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={onClose}> - <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> - </div> - </div> - <div className="flex flex-col gap-y-4 px-6 py-3"> - <div className="flex flex-col gap-y-1"> - <div className="flex h-6 items-center system-sm-semibold text-text-secondary"> - {t('versionHistory.editField.title', { ns: 'workflow' })} + <Dialog + open={isOpen} + onOpenChange={(open) => { + if (!open) + onClose() + }} + > + <DialogContent className="w-full max-w-[480px] overflow-hidden! border-none p-0 text-left align-middle"> + + <div className="relative w-full p-6 pr-14 pb-4"> + <div className="title-2xl-semi-bold text-text-primary first-letter:capitalize"> + {versionInfo?.marked_name ? t('versionHistory.editVersionInfo', { ns: 'workflow' }) : t('versionHistory.nameThisVersion', { ns: 'workflow' })} </div> - <Input - value={title} - placeholder={`${t('versionHistory.nameThisVersion', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`} - onChange={handleTitleChange} - destructive={titleError} - /> - </div> - <div className="flex flex-col gap-y-1"> - <div className="flex h-6 items-center system-sm-semibold text-text-secondary"> - {t('versionHistory.editField.releaseNotes', { ns: 'workflow' })} + <div className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={onClose}> + <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> </div> - <Textarea - value={releaseNotes} - placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`} - onChange={handleDescriptionChange} - destructive={releaseNotesError} - /> </div> - </div> - <div className="flex justify-end p-6 pt-5"> - <div className="flex items-center gap-x-3"> - <Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button variant="primary" onClick={handlePublish}>{t('common.publish', { ns: 'workflow' })}</Button> + <div className="flex flex-col gap-y-4 px-6 py-3"> + <div className="flex flex-col gap-y-1"> + <div className="flex h-6 items-center system-sm-semibold text-text-secondary"> + {t('versionHistory.editField.title', { ns: 'workflow' })} + </div> + <Input + value={title} + placeholder={`${t('versionHistory.nameThisVersion', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`} + onChange={handleTitleChange} + destructive={titleError} + /> + </div> + <div className="flex flex-col gap-y-1"> + <div className="flex h-6 items-center system-sm-semibold text-text-secondary"> + {t('versionHistory.editField.releaseNotes', { ns: 'workflow' })} + </div> + <Textarea + value={releaseNotes} + placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`} + onChange={handleDescriptionChange} + destructive={releaseNotesError} + /> + </div> </div> - </div> - </Modal> + <div className="flex justify-end p-6 pt-5"> + <div className="flex items-center gap-x-3"> + <Button nativeButton={false} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button nativeButton={false} variant="primary" onClick={handlePublish}>{t('common.publish', { ns: 'workflow' })}</Button> + </div> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx index 236f9403c9..1dd5d03889 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx @@ -3,8 +3,11 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import EditModal from '../edit-modal' -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => + open === false ? null : <>{children}</>, + DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, })) describe('Conversation history edit modal', () => { diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx index 7906a5f8da..ad73e111c7 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx @@ -2,10 +2,10 @@ import type { FC } from 'react' import type { ConversationHistoriesRole } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' type Props = { isShow: boolean @@ -25,37 +25,45 @@ const EditModal: FC<Props> = ({ const { t } = useTranslation() const [tempData, setTempData] = useState(data) return ( - <Modal - title={t('feature.conversationHistory.editModal.title', { ns: 'appDebug' })} - isShow={isShow} - onClose={onClose} + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('feature.conversationHistory.editModal.userPrefix', { ns: 'appDebug' })}</div> - <input - className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10" - value={tempData.user_prefix} - onChange={e => setTempData({ - ...tempData, - user_prefix: e.target.value, - })} - /> + <DialogContent className="w-full max-w-[480px] overflow-hidden! border-none p-6 text-left align-middle"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t('feature.conversationHistory.editModal.title', { ns: 'appDebug' })} + </DialogTitle> - <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('feature.conversationHistory.editModal.assistantPrefix', { ns: 'appDebug' })}</div> - <input - className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10" - value={tempData.assistant_prefix} - onChange={e => setTempData({ - ...tempData, - assistant_prefix: e.target.value, - })} - placeholder={t('chat.conversationNamePlaceholder', { ns: 'common' }) || ''} - /> + <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('feature.conversationHistory.editModal.userPrefix', { ns: 'appDebug' })}</div> + <input + className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10" + value={tempData.user_prefix} + onChange={e => setTempData({ + ...tempData, + user_prefix: e.target.value, + })} + /> - <div className="mt-10 flex justify-end"> - <Button className="mr-2 shrink-0" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button variant="primary" className="shrink-0" onClick={() => onSave(tempData)} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button> - </div> - </Modal> + <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('feature.conversationHistory.editModal.assistantPrefix', { ns: 'appDebug' })}</div> + <input + className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10" + value={tempData.assistant_prefix} + onChange={e => setTempData({ + ...tempData, + assistant_prefix: e.target.value, + })} + placeholder={t('chat.conversationNamePlaceholder', { ns: 'common' }) || ''} + /> + + <div className="mt-10 flex justify-end"> + <Button className="mr-2 shrink-0" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button variant="primary" className="shrink-0" onClick={() => onSave(tempData)} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 824b8a8d82..1976a4ffa0 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -2,13 +2,13 @@ import type { ChangeEvent, FC } from 'react' import type { Item as SelectItem } from './type-select' import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { useStore as useAppStore } from '@/app/components/app/store' -import Modal from '@/app/components/base/modal' import ConfigContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' @@ -141,35 +141,43 @@ const ConfigModal: FC<IConfigModalProps> = ({ } return ( - <Modal - title={t(`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`, { ns: 'appDebug' })} - isShow={isShow} - onClose={onClose} + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="mb-8" ref={modalRef} tabIndex={-1}> - <ConfigModalFormFields - checkboxDefaultSelectValue={checkboxDefaultSelectValue} - isStringInput={isStringInput} - jsonSchemaStr={jsonSchemaStr} - maxLength={max_length} - modelId={modelConfig.model_id} - onFilePayloadChange={payload => setTempPayload(payload as InputVar)} - onJSONSchemaChange={handleJSONSchemaChange} - onPayloadChange={handlePayloadChange} - onTypeChange={handleTypeChange} - onVarKeyBlur={handleVarKeyBlur} - onVarNameChange={handleVarNameChange} - options={options} - selectOptions={selectOptions} - tempPayload={tempPayload} - t={t} + <DialogContent className="overflow-hidden! border-none text-left align-middle"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`, { ns: 'appDebug' })} + </DialogTitle> + + <div className="mb-8" ref={modalRef} tabIndex={-1}> + <ConfigModalFormFields + checkboxDefaultSelectValue={checkboxDefaultSelectValue} + isStringInput={isStringInput} + jsonSchemaStr={jsonSchemaStr} + maxLength={max_length} + modelId={modelConfig.model_id} + onFilePayloadChange={payload => setTempPayload(payload as InputVar)} + onJSONSchemaChange={handleJSONSchemaChange} + onPayloadChange={handlePayloadChange} + onTypeChange={handleTypeChange} + onVarKeyBlur={handleVarKeyBlur} + onVarNameChange={handleVarNameChange} + options={options} + selectOptions={selectOptions} + tempPayload={tempPayload} + t={t} + /> + </div> + <ModalFoot + onConfirm={handleConfirm} + onCancel={onClose} /> - </div> - <ModalFoot - onConfirm={handleConfirm} - onCancel={onClose} - /> - </Modal> + </DialogContent> + </Dialog> ) } export default React.memo(ConfigModal) diff --git a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx index aff540cecf..ba08daf9b5 100644 --- a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx +++ b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx @@ -14,6 +14,7 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiDatabase2Line, @@ -32,9 +33,8 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Generator } from '@/app/components/base/icons/src/vender/other' import Loading from '@/app/components/base/loading' -import Modal from '@/app/components/base/modal' -import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug' @@ -282,143 +282,148 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ } return ( - <Modal - isShow={isShow} - onClose={onClose} - className="min-w-[1140px] p-0!" + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="flex h-[680px] flex-wrap"> - <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> - <div className="mb-5"> - <div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('generate.title', { ns: 'appDebug' })}</div> - <div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('generate.description', { ns: 'appDebug' })}</div> - </div> - <div> - <ModelParameterModal - popupClassName="w-[520px]!" - isAdvancedMode={true} - provider={model.provider} - completionParams={model.completion_params} - modelId={model.name} - setModel={handleModelChange} - onCompletionParamsChange={handleCompletionParamsChange} - hideDebugWithMultipleModel - /> - </div> - {isBasicMode && ( - <div className="mt-4"> - <div className="flex items-center"> - <div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary uppercase">{t('generate.tryIt', { ns: 'appDebug' })}</div> - <div - className="h-px grow" - style={{ - background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))', - }} - > + <DialogContent className="max-h-none w-[1140px] max-w-none! min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle"> + + <div className="flex h-[680px] flex-wrap"> + <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> + <div className="mb-5"> + <div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('generate.title', { ns: 'appDebug' })}</div> + <div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('generate.description', { ns: 'appDebug' })}</div> + </div> + <div> + <ModelParameterModal + popupClassName="w-[520px]!" + isAdvancedMode={true} + provider={model.provider} + completionParams={model.completion_params} + modelId={model.name} + setModel={handleModelChange} + onCompletionParamsChange={handleCompletionParamsChange} + hideDebugWithMultipleModel + /> + </div> + {isBasicMode && ( + <div className="mt-4"> + <div className="flex items-center"> + <div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary uppercase">{t('generate.tryIt', { ns: 'appDebug' })}</div> + <div + className="h-px grow" + style={{ + background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))', + }} + > + </div> + </div> + <div className="flex flex-wrap"> + {tryList.map(item => ( + <TryLabel + key={item.key} + Icon={item.icon} + text={t(`generate.template.${item.key}.name`, { ns: 'appDebug' })} + onClick={handleChooseTemplate(item.key)} + /> + ))} </div> </div> - <div className="flex flex-wrap"> - {tryList.map(item => ( - <TryLabel - key={item.key} - Icon={item.icon} - text={t(`generate.template.${item.key}.name`, { ns: 'appDebug' })} - onClick={handleChooseTemplate(item.key)} - /> - ))} + )} + + {/* inputs */} + <div className="mt-4"> + <div> + <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div> + {isBasicMode + ? ( + <InstructionEditorInBasic + editorKey={editorKey} + generatorType={GeneratorType.prompt} + value={instruction} + onChange={setInstruction} + availableVars={[]} + availableNodes={[]} + isShowCurrentBlock={!!currentPrompt} + isShowLastRunBlock={false} + /> + ) + : ( + <InstructionEditorInWorkflow + editorKey={editorKey} + generatorType={GeneratorType.prompt} + value={instruction} + onChange={setInstruction} + nodeId={nodeId || ''} + isShowCurrentBlock={!!currentPrompt} + /> + )} + </div> + <IdeaOutput + value={ideaOutput} + onChange={setIdeaOutput} + /> + + <div className="mt-7 flex justify-end space-x-2"> + <Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`, { ns: 'appDebug' })}</Button> + <Button + className="flex space-x-1" + variant="primary" + onClick={onGenerate} + disabled={isLoading} + > + <Generator className="h-4 w-4" /> + <span className="text-xs font-semibold">{t('generate.generate', { ns: 'appDebug' })}</span> + </Button> </div> </div> + </div> + + {(!isLoading && current) && ( + <div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0"> + <Result + current={current!} + isBasicMode={isBasicMode} + nodeId={nodeId!} + currentVersionIndex={currentVersionIndex || 0} + setCurrentVersionIndex={setCurrentVersionIndex} + versions={versions || []} + onApply={showConfirmOverwrite} + generatorType={GeneratorType.prompt} + /> + </div> )} - - {/* inputs */} - <div className="mt-4"> - <div> - <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div> - {isBasicMode - ? ( - <InstructionEditorInBasic - editorKey={editorKey} - generatorType={GeneratorType.prompt} - value={instruction} - onChange={setInstruction} - availableVars={[]} - availableNodes={[]} - isShowCurrentBlock={!!currentPrompt} - isShowLastRunBlock={false} - /> - ) - : ( - <InstructionEditorInWorkflow - editorKey={editorKey} - generatorType={GeneratorType.prompt} - value={instruction} - onChange={setInstruction} - nodeId={nodeId || ''} - isShowCurrentBlock={!!currentPrompt} - /> - )} - </div> - <IdeaOutput - value={ideaOutput} - onChange={setIdeaOutput} - /> - - <div className="mt-7 flex justify-end space-x-2"> - <Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`, { ns: 'appDebug' })}</Button> - <Button - className="flex space-x-1" - variant="primary" - onClick={onGenerate} - disabled={isLoading} - > - <Generator className="h-4 w-4" /> - <span className="text-xs font-semibold">{t('generate.generate', { ns: 'appDebug' })}</span> - </Button> - </div> - </div> + {isLoading && renderLoading} + {isShowAutoPromptResPlaceholder() && <ResPlaceholder />} + <AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}> + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> + {t('generate.overwriteTitle', { ns: 'appDebug' })} + </AlertDialogTitle> + <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> + {t('generate.overwriteMessage', { ns: 'appDebug' })} + </AlertDialogDescription> + </div> + <AlertDialogActions> + <AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogConfirmButton + onClick={() => { + hideShowConfirmOverwrite() + onFinished(current!) + }} + > + {t('operation.confirm', { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> </div> - - {(!isLoading && current) && ( - <div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0"> - <Result - current={current!} - isBasicMode={isBasicMode} - nodeId={nodeId!} - currentVersionIndex={currentVersionIndex || 0} - setCurrentVersionIndex={setCurrentVersionIndex} - versions={versions || []} - onApply={showConfirmOverwrite} - generatorType={GeneratorType.prompt} - /> - </div> - )} - {isLoading && renderLoading} - {isShowAutoPromptResPlaceholder() && <ResPlaceholder />} - <AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}> - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t('generate.overwriteTitle', { ns: 'appDebug' })} - </AlertDialogTitle> - <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t('generate.overwriteMessage', { ns: 'appDebug' })} - </AlertDialogDescription> - </div> - <AlertDialogActions> - <AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton - onClick={() => { - hideShowConfirmOverwrite() - onFinished(current!) - }} - > - {t('operation.confirm', { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> - </div> - </Modal> + </DialogContent> + </Dialog> ) } export default React.memo(GetAutomaticRes) diff --git a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx index c69c0f5dae..b603243e00 100644 --- a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx +++ b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx @@ -13,6 +13,7 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useBoolean, @@ -23,7 +24,6 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Generator } from '@/app/components/base/icons/src/vender/other' import Loading from '@/app/components/base/loading' -import Modal from '@/app/components/base/modal' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -202,99 +202,104 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( ) return ( - <Modal - isShow={isShow} - onClose={onClose} - className="min-w-[1140px] p-0!" + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="relative flex h-[680px] flex-wrap"> - <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> - <div className="mb-5"> - <div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('codegen.title', { ns: 'appDebug' })}</div> - <div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('codegen.description', { ns: 'appDebug' })}</div> - </div> - <div className="mb-4"> - <ModelParameterModal - popupClassName="w-[520px]!" - isAdvancedMode={true} - provider={model.provider} - completionParams={model.completion_params} - modelId={model.name} - setModel={handleModelChange} - onCompletionParamsChange={handleCompletionParamsChange} - hideDebugWithMultipleModel - /> - </div> - <div> - <div className="text-[0px]"> - <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div> - <InstructionEditor - editorKey={editorKey} - value={instruction} - onChange={setInstruction} - nodeId={nodeId} - generatorType={GeneratorType.code} - isShowCurrentBlock={!!currentCode} + <DialogContent className="max-h-none w-full min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle"> + + <div className="relative flex h-[680px] flex-wrap"> + <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> + <div className="mb-5"> + <div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('codegen.title', { ns: 'appDebug' })}</div> + <div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('codegen.description', { ns: 'appDebug' })}</div> + </div> + <div className="mb-4"> + <ModelParameterModal + popupClassName="w-[520px]!" + isAdvancedMode={true} + provider={model.provider} + completionParams={model.completion_params} + modelId={model.name} + setModel={handleModelChange} + onCompletionParamsChange={handleCompletionParamsChange} + hideDebugWithMultipleModel /> </div> - <IdeaOutput - value={ideaOutput} - onChange={setIdeaOutput} - /> + <div> + <div className="text-[0px]"> + <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div> + <InstructionEditor + editorKey={editorKey} + value={instruction} + onChange={setInstruction} + nodeId={nodeId} + generatorType={GeneratorType.code} + isShowCurrentBlock={!!currentCode} + /> + </div> + <IdeaOutput + value={ideaOutput} + onChange={setIdeaOutput} + /> - <div className="mt-7 flex justify-end space-x-2"> - <Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`, { ns: 'appDebug' })}</Button> - <Button - className="flex space-x-1" - variant="primary" - onClick={onGenerate} - disabled={isLoading} - > - <Generator className="h-4 w-4" /> - <span className="text-xs font-semibold">{t('codegen.generate', { ns: 'appDebug' })}</span> - </Button> + <div className="mt-7 flex justify-end space-x-2"> + <Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`, { ns: 'appDebug' })}</Button> + <Button + className="flex space-x-1" + variant="primary" + onClick={onGenerate} + disabled={isLoading} + > + <Generator className="h-4 w-4" /> + <span className="text-xs font-semibold">{t('codegen.generate', { ns: 'appDebug' })}</span> + </Button> + </div> </div> </div> + {isLoading && renderLoading} + {!isLoading && !current && <ResPlaceholder />} + {(!isLoading && current) && ( + <div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0"> + <Result + current={current!} + currentVersionIndex={currentVersionIndex || 0} + setCurrentVersionIndex={setCurrentVersionIndex} + versions={versions || []} + onApply={showConfirmOverwrite} + generatorType={GeneratorType.code} + /> + </div> + )} </div> - {isLoading && renderLoading} - {!isLoading && !current && <ResPlaceholder />} - {(!isLoading && current) && ( - <div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0"> - <Result - current={current!} - currentVersionIndex={currentVersionIndex || 0} - setCurrentVersionIndex={setCurrentVersionIndex} - versions={versions || []} - onApply={showConfirmOverwrite} - generatorType={GeneratorType.code} - /> - </div> - )} - </div> - <AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}> - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t('codegen.overwriteConfirmTitle', { ns: 'appDebug' })} - </AlertDialogTitle> - <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t('codegen.overwriteConfirmMessage', { ns: 'appDebug' })} - </AlertDialogDescription> - </div> - <AlertDialogActions> - <AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton - onClick={() => { - hideShowConfirmOverwrite() - onFinished(current!) - }} - > - {t('operation.confirm', { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> - </Modal> + <AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}> + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> + {t('codegen.overwriteConfirmTitle', { ns: 'appDebug' })} + </AlertDialogTitle> + <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> + {t('codegen.overwriteConfirmMessage', { ns: 'appDebug' })} + </AlertDialogDescription> + </div> + <AlertDialogActions> + <AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogConfirmButton + onClick={() => { + hideShowConfirmOverwrite() + onFinished(current!) + }} + > + {t('operation.confirm', { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx index b5924a5f23..e9f535999b 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx @@ -13,37 +13,6 @@ import { RerankingModeEnum } from '@/models/datasets' import { RETRIEVE_TYPE } from '@/types/app' import ParamsConfig from '../index' -vi.mock('@headlessui/react', () => ({ - Dialog: ({ children, className }: { children: React.ReactNode, className?: string }) => ( - <div role="dialog" className={className}> - {children} - </div> - ), - DialogPanel: ({ children, className, ...props }: { children: React.ReactNode, className?: string }) => ( - <div className={className} {...props}> - {children} - </div> - ), - DialogTitle: ({ children, className, ...props }: { children: React.ReactNode, className?: string }) => ( - <div className={className} {...props}> - {children} - </div> - ), - Transition: ({ show, children }: { show: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null), - TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>, - Switch: ({ checked, onChange, children, ...props }: { checked: boolean, onChange?: (value: boolean) => void, children?: React.ReactNode }) => ( - <button - type="button" - role="switch" - aria-checked={checked} - onClick={() => onChange?.(!checked)} - {...props} - > - {children} - </button> - ), -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(), useCurrentProviderAndModel: vi.fn(), diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.tsx index d4578af1d7..15d34f19d7 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.tsx @@ -3,12 +3,12 @@ import type { DataSet } from '@/models/datasets' import type { DatasetConfigs } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiEqualizer2Line } from '@remixicon/react' import { memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' -import Modal from '@/app/components/base/modal' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { @@ -123,32 +123,36 @@ const ParamsConfig = ({ </Button> { rerankSettingModalOpen && ( - <Modal - isShow={rerankSettingModalOpen} - onClose={() => { - setRerankSettingModalOpen(false) + <Dialog + open={rerankSettingModalOpen} + onOpenChange={(open) => { + if (!open) { + setRerankSettingModalOpen(false) + } }} - className="sm:min-w-[528px]" > - <ConfigContent - datasetConfigs={tempDataSetConfigs} - onChange={handleSetTempDataSetConfigs} - selectedDatasets={selectedDatasets} - /> + <DialogContent className="w-full max-w-[480px] overflow-hidden! border-none text-left align-middle sm:min-w-[528px]"> - <div className="mt-6 flex justify-end"> - <Button - className="mr-2 shrink-0" - onClick={() => { - setTempDataSetConfigs(datasetConfigs) - setRerankSettingModalOpen(false) - }} - > - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button variant="primary" className="shrink-0" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button> - </div> - </Modal> + <ConfigContent + datasetConfigs={tempDataSetConfigs} + onChange={handleSetTempDataSetConfigs} + selectedDatasets={selectedDatasets} + /> + + <div className="mt-6 flex justify-end"> + <Button + className="mr-2 shrink-0" + onClick={() => { + setTempDataSetConfigs(datasetConfigs) + setRerankSettingModalOpen(false) + }} + > + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button variant="primary" className="shrink-0" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/app/create-app-dialog-shell.tsx b/web/app/components/app/create-app-dialog-shell.tsx new file mode 100644 index 0000000000..80466efaa1 --- /dev/null +++ b/web/app/components/app/create-app-dialog-shell.tsx @@ -0,0 +1,51 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' + +type CreateAppDialogShellProps = { + children: ReactNode + contentClassName?: string + onClose: () => void + show: boolean + title: ReactNode +} + +export function CreateAppDialogShell({ + children, + contentClassName, + onClose, + show, + title, +}: CreateAppDialogShellProps) { + return ( + <Dialog + open={show} + onOpenChange={(nextOpen) => { + if (!nextOpen) + onClose() + }} + > + <DialogContent + backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]" + className="top-0 left-0 h-screen max-h-none w-screen max-w-none translate-x-0 translate-y-0 overflow-hidden rounded-none border-none bg-transparent p-4 shadow-none" + > + <div className="h-full w-full rounded-2xl border border-effects-highlight bg-background-default-subtle"> + <div className={cn('relative h-full overflow-hidden', contentClassName)}> + <DialogTitle className="sr-only">{title}</DialogTitle> + <button + type="button" + aria-label="Close" + className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover" + onClick={onClose} + > + <span aria-hidden="true" className="i-ri-close-large-line h-3.5 w-3.5 text-components-button-tertiary-text" /> + </button> + {children} + </div> + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx index cc59ce7456..59febff8d5 100644 --- a/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx @@ -25,17 +25,6 @@ vi.mock('../app-list', () => ({ }, })) -// Store captured callbacks from useKeyPress -let capturedEscCallback: (() => void) | undefined -const mockUseKeyPress = vi.fn((key: string, callback: () => void) => { - if (key === 'esc') - capturedEscCallback = callback -}) - -vi.mock('ahooks', () => ({ - useKeyPress: (key: string, callback: () => void) => mockUseKeyPress(key, callback), -})) - describe('CreateAppTemplateDialog', () => { const defaultProps = { show: false, @@ -46,53 +35,18 @@ describe('CreateAppTemplateDialog', () => { beforeEach(() => { vi.clearAllMocks() - capturedEscCallback = undefined }) describe('Rendering', () => { it('should not render when show is false', () => { render(<CreateAppTemplateDialog {...defaultProps} />) - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false - // FullScreenModal should not render any content when open is false expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('should render modal when show is true', () => { render(<CreateAppTemplateDialog {...defaultProps} show={true} />) - // FullScreenModal renders with role="dialog" - // FullScreenModal renders with role="dialog" expect(screen.getByRole('dialog'))!.toBeInTheDocument() expect(screen.getByTestId('app-list'))!.toBeInTheDocument() }) @@ -113,7 +67,7 @@ describe('CreateAppTemplateDialog', () => { }) describe('Props', () => { - it('should pass show prop to FullScreenModal', () => { + it('should pass show prop to the dialog shell', () => { const { rerender } = render(<CreateAppTemplateDialog {...defaultProps} />) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() @@ -122,15 +76,17 @@ describe('CreateAppTemplateDialog', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - it('should pass closable prop to FullScreenModal', () => { - // Since the FullScreenModal is always rendered with closable=true - // we can verify that the modal renders with the proper structure - render(<CreateAppTemplateDialog {...defaultProps} show={true} />) + it('should close from the dialog shell close button', () => { + const mockOnClose = vi.fn() - // Verify that the modal has the proper dialog structure - const dialog = screen.getByRole('dialog') - expect(dialog)!.toBeInTheDocument() - expect(dialog)!.toHaveAttribute('aria-modal', 'true') + render(<CreateAppTemplateDialog {...defaultProps} show={true} onClose={mockOnClose} />) + + const closeButton = screen.getByRole('button', { name: 'Close' }) + expect(closeButton)!.toBeInTheDocument() + + fireEvent.click(closeButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) }) }) @@ -143,8 +99,6 @@ describe('CreateAppTemplateDialog', () => { const dialog = screen.getByRole('dialog') expect(dialog)!.toBeInTheDocument() - // Test that AppList component renders (child component interactions) - // Test that AppList component renders (child component interactions) expect(screen.getByTestId('app-list'))!.toBeInTheDocument() expect(screen.getByTestId('app-list-success'))!.toBeInTheDocument() }) @@ -183,65 +137,6 @@ describe('CreateAppTemplateDialog', () => { }) }) - describe('useKeyPress Integration', () => { - it('should set up ESC key listener when modal is shown', () => { - render(<CreateAppTemplateDialog {...defaultProps} show={true} />) - - expect(mockUseKeyPress).toHaveBeenCalledWith('esc', expect.any(Function)) - }) - - it('should handle ESC key press to close modal', () => { - const mockOnClose = vi.fn() - render( - <CreateAppTemplateDialog - {...defaultProps} - show={true} - onClose={mockOnClose} - />, - ) - - expect(capturedEscCallback).toBeDefined() - expect(typeof capturedEscCallback).toBe('function') - - // Simulate ESC key press - capturedEscCallback?.() - - expect(mockOnClose).toHaveBeenCalledTimes(1) - }) - - it('should not call onClose when ESC key is pressed and modal is not shown', () => { - const mockOnClose = vi.fn() - render( - <CreateAppTemplateDialog - {...defaultProps} - show={false} // Modal not shown - onClose={mockOnClose} - />, - ) - - // The callback should still be created but not execute onClose - expect(capturedEscCallback).toBeDefined() - - // Simulate ESC key press - capturedEscCallback?.() - - // onClose should not be called because modal is not shown - expect(mockOnClose).not.toHaveBeenCalled() - }) - }) - - describe('Callback Dependencies', () => { - it('should create stable callback reference for ESC key handler', () => { - render(<CreateAppTemplateDialog {...defaultProps} show={true} />) - - // Verify that useKeyPress was called with a function - const calls = mockUseKeyPress.mock.calls - expect(calls.length).toBeGreaterThan(0) - expect(calls[0]![0]).toBe('esc') - expect(typeof calls[0]![1]).toBe('function') - }) - }) - describe('Edge Cases', () => { it('should handle null props gracefully', () => { expect(() => { diff --git a/web/app/components/app/create-app-dialog/index.tsx b/web/app/components/app/create-app-dialog/index.tsx index 762dddc028..45950a9f62 100644 --- a/web/app/components/app/create-app-dialog/index.tsx +++ b/web/app/components/app/create-app-dialog/index.tsx @@ -1,7 +1,6 @@ 'use client' -import { useKeyPress } from 'ahooks' -import { useCallback } from 'react' -import FullScreenModal from '@/app/components/base/fullscreen-modal' +import { useTranslation } from 'react-i18next' +import { CreateAppDialogShell } from '../create-app-dialog-shell' import AppList from './app-list' type CreateAppDialogProps = { @@ -12,19 +11,10 @@ type CreateAppDialogProps = { } const CreateAppTemplateDialog = ({ show, onSuccess, onClose, onCreateFromBlank }: CreateAppDialogProps) => { - const handleEscKeyPress = useCallback(() => { - if (show) - onClose() - }, [show, onClose]) - - useKeyPress('esc', handleEscKeyPress) + const { t } = useTranslation() return ( - <FullScreenModal - open={show} - closable - onClose={onClose} - > + <CreateAppDialogShell show={show} title={t('newApp.startFromTemplate', { ns: 'app' })} onClose={onClose}> <AppList onCreateFromBlank={onCreateFromBlank} onSuccess={() => { @@ -32,7 +22,7 @@ const CreateAppTemplateDialog = ({ show, onSuccess, onClose, onCreateFromBlank } onClose() }} /> - </FullScreenModal> + </CreateAppDialogShell> ) } diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 2ff8a0aacd..42514e986d 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -7,11 +7,10 @@ import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Divider from '@/app/components/base/divider' -import FullScreenModal from '@/app/components/base/fullscreen-modal' import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' @@ -28,6 +27,7 @@ import { trackCreateApp } from '@/utils/create-app-tracking' import { basePath } from '@/utils/var' import AppIconPicker from '../../base/app-icon-picker' import ShortcutsName from '../../workflow/shortcuts-name' +import { CreateAppDialogShell } from '../create-app-dialog-shell' type CreateAppProps = { onSuccess: () => void @@ -36,6 +36,10 @@ type CreateAppProps = { defaultAppMode?: AppModeEnum } +const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => { + return appMode === AppModeEnum.CHAT || appMode === AppModeEnum.AGENT_CHAT || appMode === AppModeEnum.COMPLETION +} + function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) { const { t } = useTranslation() const { push } = useRouter() @@ -45,7 +49,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [name, setName] = useState('') const [description, setDescription] = useState('') - const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(false) + const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(() => shouldExpandBeginnerAppTypes(defaultAppMode)) const { plan, enableBilling } = useProviderContext() const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) @@ -53,11 +57,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const isCreatingRef = useRef(false) - useEffect(() => { - if (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.AGENT_CHAT || appMode === AppModeEnum.COMPLETION) - setIsAppTypeExpanded(true) - }, [appMode]) - const onCreate = useCallback(async () => { if (!appMode) { toast.error(t('newApp.appTypeRequired', { ns: 'app' })) @@ -88,8 +87,8 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') getRedirection(isCurrentWorkspaceEditor, app, push) } - catch (e: any) { - toast.error(e.message || t('newApp.appCreateFailed', { ns: 'app' })) + catch (error) { + toast.error(error instanceof Error ? error.message : t('newApp.appCreateFailed', { ns: 'app' })) } isCreatingRef.current = false }, [name, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor]) @@ -290,15 +289,17 @@ type CreateAppDialogProps = CreateAppProps & { show: boolean } const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppDialogProps) => { + const { t } = useTranslation() + return ( - <FullScreenModal - overflowVisible - closable - open={show} + <CreateAppDialogShell + show={show} + title={t('newApp.startFromBlank', { ns: 'app' })} + contentClassName="overflow-visible" onClose={onClose} > <CreateApp onClose={onClose} onSuccess={onSuccess} onCreateFromTemplate={onCreateFromTemplate} defaultAppMode={defaultAppMode} /> - </FullScreenModal> + </CreateAppDialogShell> ) } diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index b3d007c2e7..dac5b22ee6 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -307,6 +307,78 @@ describe('CreateFromDSLModal', () => { expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW }) }) + it('should close the DSL mismatch modal when dialog requests close', async () => { + vi.useFakeTimers() + mockImportDSL.mockResolvedValue({ + id: 'import-close', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + render( + <CreateFromDSLModal + show + onClose={vi.fn()} + activeTab={CreateFromDSLModalTab.FROM_URL} + dslUrl="https://example.com/app.yml" + />, + ) + + await act(async () => { + fireEvent.click(getCreateButton()) + }) + + await act(async () => { + vi.advanceTimersByTime(300) + }) + + expect(screen.getByText('newApp.appCreateDSLErrorTitle'))!.toBeInTheDocument() + + vi.useRealTimers() + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + await waitFor(() => { + expect(screen.queryByText('newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument() + }) + }) + + it('should close the DSL mismatch modal when cancel is clicked', async () => { + vi.useFakeTimers() + mockImportDSL.mockResolvedValue({ + id: 'import-cancel', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + render( + <CreateFromDSLModal + show + onClose={vi.fn()} + activeTab={CreateFromDSLModalTab.FROM_URL} + dslUrl="https://example.com/app.yml" + />, + ) + + await act(async () => { + fireEvent.click(getCreateButton()) + }) + + await act(async () => { + vi.advanceTimersByTime(300) + }) + + expect(screen.getByText('newApp.appCreateDSLErrorTitle'))!.toBeInTheDocument() + + vi.useRealTimers() + fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Cancel' }).at(-1)!) + + await waitFor(() => { + expect(screen.queryByText('newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument() + }) + }) + it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => { let resolveImport!: (value: { id: string, status: DSLImportStatus, app_id: string, app_mode: string }) => void mockImportDSL.mockImplementationOnce(() => new Promise((resolve) => { @@ -397,6 +469,7 @@ describe('CreateFromDSLModal', () => { mockImportDSL.mockResolvedValueOnce({ id: 'import-failed', status: DSLImportStatus.FAILED, + error: 'Invalid YAML format', }) mockImportDSL.mockRejectedValueOnce(new Error('boom')) @@ -412,7 +485,7 @@ describe('CreateFromDSLModal', () => { await act(async () => { fireEvent.click(getCreateButton()) }) - expect(toastMocks.error).toHaveBeenCalledWith('newApp.appCreateFailed') + expect(toastMocks.error).toHaveBeenCalledWith('Invalid YAML format') rerender( <CreateFromDSLModal @@ -427,6 +500,7 @@ describe('CreateFromDSLModal', () => { fireEvent.click(getCreateButton()) }) expect(toastMocks.error).toHaveBeenCalledTimes(2) + expect(toastMocks.error).toHaveBeenLastCalledWith('newApp.appCreateFailed') }) it('should handle pending import confirmation failures and cancellation', async () => { @@ -438,7 +512,7 @@ describe('CreateFromDSLModal', () => { current_dsl_version: '2.0.0', }) mockImportDSLConfirm - .mockResolvedValueOnce({ status: DSLImportStatus.FAILED }) + .mockResolvedValueOnce({ status: DSLImportStatus.FAILED, error: 'Confirm failed' }) .mockRejectedValueOnce(new Error('boom')) render( @@ -465,11 +539,12 @@ describe('CreateFromDSLModal', () => { await act(async () => { fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!) }) - expect(toastMocks.error).toHaveBeenCalledWith('newApp.appCreateFailed') + expect(toastMocks.error).toHaveBeenCalledWith('Confirm failed') await act(async () => { fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!) }) expect(toastMocks.error).toHaveBeenCalledTimes(2) + expect(toastMocks.error).toHaveBeenLastCalledWith('newApp.appCreateFailed') }) }) diff --git a/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx b/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx index d0c97e185c..726b3f1742 100644 --- a/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx +++ b/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx @@ -1,6 +1,13 @@ -import { Button } from '@langgenius/dify-ui/button' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' type DSLConfirmModalProps = { versions?: { @@ -20,32 +27,36 @@ const DSLConfirmModal = ({ const { t } = useTranslation() return ( - <Modal - isShow - onClose={() => onCancel()} - className="w-[480px]" + <AlertDialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} > - <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div> - <div className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> - <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> - <br /> - <div> - {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - <span className="system-md-medium">{versions.importedVersion}</span> - </div> - <div> - {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - <span className="system-md-medium">{versions.systemVersion}</span> - </div> + <AlertDialogContent className="w-[480px] overflow-hidden! border-none text-left align-middle shadow-xl"> + <div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4"> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</AlertDialogTitle> + <AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary"> + <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> + <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> + <br /> + <div> + {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} + <span className="system-md-medium">{versions.importedVersion}</span> + </div> + <div> + {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} + <span className="system-md-medium">{versions.systemVersion}</span> + </div> + </AlertDialogDescription> </div> - </div> - <div className="flex items-start justify-end gap-2 self-stretch pt-6"> - <Button variant="secondary" onClick={() => onCancel()}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button variant="primary" tone="destructive" onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</Button> - </div> - </Modal> + <AlertDialogActions> + <AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> ) } diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index bc5f352634..5b8a4e7469 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -3,14 +3,12 @@ import type { MouseEventHandler } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { RiCloseLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' -import { noop } from 'es-toolkit/function' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' @@ -47,7 +45,7 @@ export enum CreateFromDSLModalTab { const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => { const { push } = useRouter() const { t } = useTranslation() - const [currentFile, setDSLFile] = useState<File | undefined>(droppedFile) + const [currentFile, setCurrentFile] = useState<File | undefined>(droppedFile) const [fileContent, setFileContent] = useState<string>() const [currentTab, setCurrentTab] = useState(activeTab) const [dslUrlValue, setDslUrlValue] = useState(dslUrl) @@ -56,22 +54,22 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const [importId, setImportId] = useState<string>() const { handleCheckPluginDependencies } = usePluginDependencies() - const readFile = (file: File) => { + const readFile = useCallback((file: File) => { const reader = new FileReader() reader.onload = function (event) { const content = event.target?.result setFileContent(content as string) } reader.readAsText(file) - } + }, []) - const handleFile = (file?: File) => { - setDSLFile(file) + const handleFile = useCallback((file?: File) => { + setCurrentFile(file) if (file) readFile(file) if (!file) setFileContent('') - } + }, [readFile]) const { isCurrentWorkspaceEditor } = useAppContext() const { plan, enableBilling } = useProviderContext() @@ -82,7 +80,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS useEffect(() => { if (droppedFile) handleFile(droppedFile) - }, [droppedFile]) + }, [droppedFile, handleFile]) const onCreate = async (_e?: React.MouseEvent) => { if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) @@ -141,11 +139,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS setImportId(id) } else { - toast.error(t('newApp.appCreateFailed', { ns: 'app' })) + toast.error(response.error || t('newApp.appCreateFailed', { ns: 'app' })) } } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { + catch { toast.error(t('newApp.appCreateFailed', { ns: 'app' })) } isCreatingRef.current = false @@ -187,11 +184,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push) } else if (status === DSLImportStatus.FAILED) { - toast.error(t('newApp.appCreateFailed', { ns: 'app' })) + toast.error(response.error || t('newApp.appCreateFailed', { ns: 'app' })) } } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { + catch { toast.error(t('newApp.appCreateFailed', { ns: 'app' })) } } @@ -219,108 +215,112 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS return ( <> - <Modal - className="w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl" - isShow={show} - onClose={noop} - > - <div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary"> - {t('importApp', { ns: 'app' })} - <div - className="flex h-8 w-8 cursor-pointer items-center" - onClick={() => onClose()} - > - <RiCloseLine className="h-5 w-5 text-text-tertiary" /> + <Dialog open={show}> + <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl"> + + <div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary"> + {t('importApp', { ns: 'app' })} + <div + className="flex h-8 w-8 cursor-pointer items-center" + onClick={() => onClose()} + > + <span className="i-ri-close-line h-5 w-5 text-text-tertiary" /> + </div> </div> - </div> - <div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary"> - { - tabs.map(tab => ( - <div - key={tab.key} - className={cn( - 'relative flex h-full cursor-pointer items-center', - currentTab === tab.key && 'text-text-primary', - )} - onClick={() => setCurrentTab(tab.key)} - > - {tab.label} - { - currentTab === tab.key && ( - <div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div> - ) - } - </div> - )) - } - </div> - <div className="px-6 py-4"> - { - currentTab === CreateFromDSLModalTab.FROM_FILE && ( - <Uploader - className="mt-0" - file={currentFile} - updateFile={handleFile} - /> - ) - } - { - currentTab === CreateFromDSLModalTab.FROM_URL && ( - <div> - <div className="mb-1 system-md-semibold text-text-secondary">DSL URL</div> - <Input - placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''} - value={dslUrlValue} - onChange={e => setDslUrlValue(e.target.value)} + <div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary"> + { + tabs.map(tab => ( + <div + key={tab.key} + className={cn( + 'relative flex h-full cursor-pointer items-center', + currentTab === tab.key && 'text-text-primary', + )} + onClick={() => setCurrentTab(tab.key)} + > + {tab.label} + { + currentTab === tab.key && ( + <div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div> + ) + } + </div> + )) + } + </div> + <div className="px-6 py-4"> + { + currentTab === CreateFromDSLModalTab.FROM_FILE && ( + <Uploader + className="mt-0" + file={currentFile} + updateFile={handleFile} /> - </div> - ) - } - </div> - {isAppsFull && ( - <div className="px-6"> - <AppsFull className="mt-0" loc="app-create-dsl" /> + ) + } + { + currentTab === CreateFromDSLModalTab.FROM_URL && ( + <div> + <div className="mb-1 system-md-semibold text-text-secondary">DSL URL</div> + <Input + placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''} + value={dslUrlValue} + onChange={e => setDslUrlValue(e.target.value)} + /> + </div> + ) + } </div> - )} - <div className="flex justify-end px-6 py-5"> - <Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button - disabled={buttonDisabled} - variant="primary" - onClick={handleCreateApp} - className="gap-1" - > - <span>{t('newApp.Create', { ns: 'app' })}</span> - <ShortcutsName keys={['ctrl', '↵']} bgColor="white" /> - </Button> - </div> - </Modal> - <Modal - isShow={showErrorModal} - onClose={() => setShowErrorModal(false)} - className="w-[480px]" + {isAppsFull && ( + <div className="px-6"> + <AppsFull className="mt-0" loc="app-create-dsl" /> + </div> + )} + <div className="flex justify-end px-6 py-5"> + <Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button> + <Button + disabled={buttonDisabled} + variant="primary" + onClick={handleCreateApp} + className="gap-1" + > + <span>{t('newApp.Create', { ns: 'app' })}</span> + <ShortcutsName keys={['ctrl', '↵']} bgColor="white" /> + </Button> + </div> + </DialogContent> + </Dialog> + <Dialog + open={showErrorModal} + onOpenChange={(open) => { + if (!open) + setShowErrorModal(false) + }} > - <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div> - <div className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> - <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> - <br /> - <div> - {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - <span className="system-md-medium">{versions?.importedVersion}</span> - </div> - <div> - {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - <span className="system-md-medium">{versions?.systemVersion}</span> + <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle"> + + <div className="flex flex-col items-start gap-2 self-stretch pb-4"> + <div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div> + <div className="flex grow flex-col system-md-regular text-text-secondary"> + <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> + <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> + <br /> + <div> + {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} + <span className="system-md-medium">{versions?.importedVersion}</span> + </div> + <div> + {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} + <span className="system-md-medium">{versions?.systemVersion}</span> + </div> </div> </div> - </div> - <div className="flex items-start justify-end gap-2 self-stretch pt-6"> - <Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button variant="primary" tone="destructive" onClick={onDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button> - </div> - </Modal> + <div className="flex items-start justify-end gap-2 self-stretch pt-6"> + <Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button> + <Button variant="primary" tone="destructive" onClick={onDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button> + </div> + </DialogContent> + </Dialog> </> ) } diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index e55ac6cf66..7a83c6c277 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -1,16 +1,14 @@ 'use client' import type { AppIconType } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { useProviderContext } from '@/context/provider-context' import AppIconPicker from '../../base/app-icon-picker' @@ -71,40 +69,39 @@ const DuplicateAppModal = ({ return ( <> - <Modal - isShow={show} - onClose={noop} - className={cn('relative max-w-[480px]!', 'px-8')} - > - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> - <div className="relative mt-3 mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('duplicateTitle', { ns: 'app' })}</div> - <div className="mb-9 system-sm-regular text-text-secondary"> - <div className="mb-2 system-md-medium">{t('appCustomize.subTitle', { ns: 'explore' })}</div> - <div className="flex items-center justify-between space-x-2"> - <AppIcon - size="large" - onClick={() => { setShowAppIconPicker(true) }} - className="cursor-pointer" - iconType={appIcon.type} - icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} - background={appIcon.type === 'image' ? undefined : appIcon.background} - imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} - /> - <Input - value={name} - onChange={e => setName(e.target.value)} - className="h-10" - /> + <Dialog open={show}> + <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none px-8 text-left align-middle"> + + <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}> + <RiCloseLine className="h-4 w-4 text-text-tertiary" /> </div> - {isAppsFull && <AppsFull className="mt-4" loc="app-duplicate-create" />} - </div> - <div className="flex flex-row-reverse"> - <Button disabled={isAppsFull} className="ml-2 w-24" variant="primary" onClick={submit}>{t('duplicate', { ns: 'app' })}</Button> - <Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> - </div> - </Modal> + <div className="relative mt-3 mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('duplicateTitle', { ns: 'app' })}</div> + <div className="mb-9 system-sm-regular text-text-secondary"> + <div className="mb-2 system-md-medium">{t('appCustomize.subTitle', { ns: 'explore' })}</div> + <div className="flex items-center justify-between space-x-2"> + <AppIcon + size="large" + onClick={() => { setShowAppIconPicker(true) }} + className="cursor-pointer" + iconType={appIcon.type} + icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} + background={appIcon.type === 'image' ? undefined : appIcon.background} + imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} + /> + <Input + value={name} + onChange={e => setName(e.target.value)} + className="h-10" + /> + </div> + {isAppsFull && <AppsFull className="mt-4" loc="app-duplicate-create" />} + </div> + <div className="flex flex-row-reverse"> + <Button disabled={isAppsFull} className="ml-2 w-24" variant="primary" onClick={submit}>{t('duplicate', { ns: 'app' })}</Button> + <Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> + </div> + </DialogContent> + </Dialog> {showAppIconPicker && ( <AppIconPicker onSelect={(payload) => { diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index 8da710210e..c46ce7ad13 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -12,9 +12,9 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' @@ -22,7 +22,6 @@ import AppIcon from '@/app/components/base/app-icon' import Checkbox from '@/app/components/base/checkbox' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' @@ -109,69 +108,68 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo return ( <> - <Modal - className={cn('w-[600px] max-w-[600px] p-8')} - isShow={show} - onClose={noop} - > - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> - <div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-default-burn p-3 shadow-xl"> - <AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" /> - </div> - <div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('switch', { ns: 'app' })}</div> - <div className="my-1 text-sm leading-5 text-text-tertiary"> - <span>{t('switchTipStart', { ns: 'app' })}</span> - <span className="font-medium text-text-secondary">{t('switchTip', { ns: 'app' })}</span> - <span>{t('switchTipEnd', { ns: 'app' })}</span> - </div> - <div className="pb-4"> - <div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('switchLabel', { ns: 'app' })}</div> - <div className="flex items-center justify-between space-x-2"> - <AppIcon - size="large" - onClick={() => { setShowAppIconPicker(true) }} - className="cursor-pointer" - iconType={appIcon.type} - icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} - background={appIcon.type === 'image' ? undefined : appIcon.background} - imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} - /> - <Input - value={name} - onChange={e => setName(e.target.value)} - placeholder={t('newApp.appNamePlaceholder', { ns: 'app' }) || ''} - className="h-10 grow" - /> + <Dialog open={show}> + <DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn('w-[600px] max-w-[600px] p-8'))}> + + <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}> + <RiCloseLine className="h-4 w-4 text-text-tertiary" /> </div> - {showAppIconPicker && ( - <AppIconPicker - onSelect={(payload) => { - setAppIcon(payload) - setShowAppIconPicker(false) - }} - onClose={() => { - setAppIcon(appDetail.icon_type === 'image' - ? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon } - : { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background }) - setShowAppIconPicker(false) - }} - /> - )} - </div> - {isAppsFull && <AppsFull loc="app-switch" />} - <div className="flex items-center justify-between pt-6"> - <div className="flex items-center"> - <Checkbox className="shrink-0" checked={removeOriginal} onCheck={() => setRemoveOriginal(!removeOriginal)} /> - <div className="ml-2 cursor-pointer text-sm leading-5 text-text-secondary" onClick={() => setRemoveOriginal(!removeOriginal)}>{t('removeOriginal', { ns: 'app' })}</div> + <div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-default-burn p-3 shadow-xl"> + <AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" /> </div> - <div className="flex items-center"> - <Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button className="border-red-700" disabled={isAppsFull || !name} variant="primary" tone="destructive" onClick={goStart}>{t('switchStart', { ns: 'app' })}</Button> + <div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('switch', { ns: 'app' })}</div> + <div className="my-1 text-sm leading-5 text-text-tertiary"> + <span>{t('switchTipStart', { ns: 'app' })}</span> + <span className="font-medium text-text-secondary">{t('switchTip', { ns: 'app' })}</span> + <span>{t('switchTipEnd', { ns: 'app' })}</span> </div> - </div> - </Modal> + <div className="pb-4"> + <div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('switchLabel', { ns: 'app' })}</div> + <div className="flex items-center justify-between space-x-2"> + <AppIcon + size="large" + onClick={() => { setShowAppIconPicker(true) }} + className="cursor-pointer" + iconType={appIcon.type} + icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} + background={appIcon.type === 'image' ? undefined : appIcon.background} + imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} + /> + <Input + value={name} + onChange={e => setName(e.target.value)} + placeholder={t('newApp.appNamePlaceholder', { ns: 'app' }) || ''} + className="h-10 grow" + /> + </div> + {showAppIconPicker && ( + <AppIconPicker + onSelect={(payload) => { + setAppIcon(payload) + setShowAppIconPicker(false) + }} + onClose={() => { + setAppIcon(appDetail.icon_type === 'image' + ? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon } + : { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background }) + setShowAppIconPicker(false) + }} + /> + )} + </div> + {isAppsFull && <AppsFull loc="app-switch" />} + <div className="flex items-center justify-between pt-6"> + <div className="flex items-center"> + <Checkbox className="shrink-0" checked={removeOriginal} onCheck={() => setRemoveOriginal(!removeOriginal)} /> + <div className="ml-2 cursor-pointer text-sm leading-5 text-text-secondary" onClick={() => setRemoveOriginal(!removeOriginal)}>{t('removeOriginal', { ns: 'app' })}</div> + </div> + <div className="flex items-center"> + <Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button> + <Button className="border-red-700" disabled={isAppsFull || !name} variant="primary" tone="destructive" onClick={goStart}>{t('switchStart', { ns: 'app' })}</Button> + </div> + </div> + </DialogContent> + </Dialog> <AlertDialog open={showConfirmDelete} onOpenChange={handleConfirmDeleteOpenChange} diff --git a/web/app/components/app/workflow-log/__tests__/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx index 35e6369e67..2259ed30c5 100644 --- a/web/app/components/app/workflow-log/__tests__/list.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx @@ -460,9 +460,10 @@ describe('WorkflowAppLogList', () => { // Open drawer const dataRows = screen.getAllByRole('row') await user.click(dataRows[1]!) - await screen.findByRole('dialog') + const dialog = await screen.findByRole('dialog') // Close drawer using Escape key + dialog.focus() await user.keyboard('{Escape}') await waitFor(() => { diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx index 64a88f16e1..52a52541e9 100644 --- a/web/app/components/base/app-icon-picker/index.tsx +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -4,15 +4,14 @@ import type { OnImageInput } from './ImageInput' import type { AppIconType, ImageFile } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { RiImageCircleAiLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' import Divider from '../divider' import EmojiPickerInner from '../emoji-picker/Inner' import { useLocalFileUploader } from '../image-uploader/hooks' -import Modal from '../modal' import ImageInput from './ImageInput' import s from './style.module.css' import getCroppedImg from './utils' @@ -45,7 +44,6 @@ const AppIconPicker: FC<AppIconPickerProps> = ({ onSelect, onClose, initialEmoji, - className, }) => { const { t } = useTranslation() @@ -113,57 +111,54 @@ const AppIconPicker: FC<AppIconPickerProps> = ({ } return ( - <Modal - onClose={noop} - isShow - closable={false} - wrapperClassName={className} - className={cn(s.container, 'h-[462px]! w-[362px]! p-0!')} - > - {!DISABLE_UPLOAD_IMAGE_AS_ICON && ( - <div className="w-full p-2 pb-0"> - <div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary"> - {tabs.map(tab => ( - <button - type="button" - key={tab.key} - className={cn( - 'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary', - activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md', - )} - onClick={() => setActiveTab(tab.key as AppIconType)} - > - {tab.icon} - {' '} + <Dialog open> + <DialogContent className={cn('max-h-none w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[462px]! w-[362px]! p-0!')}> + + {!DISABLE_UPLOAD_IMAGE_AS_ICON && ( + <div className="w-full p-2 pb-0"> + <div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary"> + {tabs.map(tab => ( + <button + type="button" + key={tab.key} + className={cn( + 'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary', + activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md', + )} + onClick={() => setActiveTab(tab.key as AppIconType)} + > + {tab.icon} + {' '}   - {tab.label} - </button> - ))} + {tab.label} + </button> + ))} + </div> </div> + )} + + {activeTab === 'emoji' && ( + <EmojiPickerInner + className={cn('flex-1 overflow-hidden pt-2')} + emoji={initialEmoji?.icon} + background={initialEmoji?.background ?? undefined} + onSelect={handleSelectEmoji} + /> + )} + {activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />} + + <Divider className="m-0" /> + <div className="flex w-full items-center justify-center gap-2 p-3"> + <Button className="w-full" onClick={() => onClose?.()}> + {t('iconPicker.cancel', { ns: 'app' })} + </Button> + + <Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}> + {t('iconPicker.ok', { ns: 'app' })} + </Button> </div> - )} - - {activeTab === 'emoji' && ( - <EmojiPickerInner - className={cn('flex-1 overflow-hidden pt-2')} - emoji={initialEmoji?.icon} - background={initialEmoji?.background ?? undefined} - onSelect={handleSelectEmoji} - /> - )} - {activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />} - - <Divider className="m-0" /> - <div className="flex w-full items-center justify-center gap-2 p-3"> - <Button className="w-full" onClick={() => onClose?.()}> - {t('iconPicker.cancel', { ns: 'app' })} - </Button> - - <Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}> - {t('iconPicker.ok', { ns: 'app' })} - </Button> - </div> - </Modal> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx index 33e25dbe01..19513deff1 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx @@ -62,18 +62,15 @@ vi.mock('@/next/navigation', () => ({ usePathname: () => '/test', })) -// Mock Modal to avoid Headless UI issues in tests -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, isShow, title }: { children: React.ReactNode, isShow: boolean, title: React.ReactNode }) => { - if (!isShow) - return null - return ( - <div data-testid="modal"> - {!!title && <div data-testid="modal-title">{title}</div>} - {children} - </div> - ) - }, +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => + open === false ? null : <>{children}</>, + DialogContent: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="modal">{children}</div> + ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="modal-title">{children}</div> + ), })) describe('Sidebar Index', () => { diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/rename-modal.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/rename-modal.spec.tsx index 754d5c89b4..9c741beb0e 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/rename-modal.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/rename-modal.spec.tsx @@ -4,25 +4,13 @@ import userEvent from '@testing-library/user-event' import * as ReactI18next from 'react-i18next' import RenameModal from '../rename-modal' -vi.mock('@/app/components/base/modal', () => ({ - default: ({ - title, - isShow, - children, - }: { - title: ReactNode - isShow: boolean - children: ReactNode - }) => { - if (!isShow) - return null - return ( - <div role="dialog"> - <h2>{title}</h2> - {children} - </div> - ) - }, +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: { children: ReactNode, open?: boolean }) => + open === false ? null : <>{children}</>, + DialogContent: ({ children }: { children: ReactNode }) => ( + <div role="dialog">{children}</div> + ), + DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>, })) describe('RenameModal', () => { diff --git a/web/app/components/base/content-dialog/__tests__/index.spec.tsx b/web/app/components/base/content-dialog/__tests__/index.spec.tsx deleted file mode 100644 index e987d306a1..0000000000 --- a/web/app/components/base/content-dialog/__tests__/index.spec.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import ContentDialog from '../index' - -describe('ContentDialog', () => { - it('renders children when show is true', async () => { - render( - <ContentDialog show={true}> - <div>Dialog body</div> - </ContentDialog>, - ) - - await screen.findByText('Dialog body') - expect(screen.getByText('Dialog body')).toBeInTheDocument() - - const backdrop = document.querySelector('.bg-app-detail-overlay-bg') - expect(backdrop).toBeTruthy() - }) - - it('does not render children when show is false', () => { - render( - <ContentDialog show={false}> - <div>Hidden content</div> - </ContentDialog>, - ) - - expect(screen.queryByText('Hidden content')).toBeNull() - expect(document.querySelector('.bg-app-detail-overlay-bg')).toBeNull() - }) - - it('calls onClose when backdrop is clicked', async () => { - const onClose = vi.fn() - render( - <ContentDialog show={true} onClose={onClose}> - <div>Body</div> - </ContentDialog>, - ) - - const user = userEvent.setup() - const backdrop = document.querySelector('.bg-app-detail-overlay-bg') as HTMLElement | null - expect(backdrop).toBeTruthy() - - await user.click(backdrop!) - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('applies provided className to the content panel', () => { - render( - <ContentDialog show={true} className="my-panel-class"> - <div>Panel content</div> - </ContentDialog>, - ) - - const contentPanel = document.querySelector('.bg-app-detail-bg') as HTMLElement | null - expect(contentPanel).toBeTruthy() - expect(contentPanel?.className).toContain('my-panel-class') - expect(screen.getByText('Panel content')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/base/content-dialog/index.stories.tsx b/web/app/components/base/content-dialog/index.stories.tsx deleted file mode 100644 index 8ddd5c667d..0000000000 --- a/web/app/components/base/content-dialog/index.stories.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useEffect, useState } from 'react' -import ContentDialog from '.' - -type Props = React.ComponentProps<typeof ContentDialog> - -const meta = { - title: 'Base/Feedback/ContentDialog', - component: ContentDialog, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: 'Sliding panel overlay used in the app detail view. Includes dimmed backdrop and animated entrance/exit transitions.', - }, - }, - }, - tags: ['autodocs'], - argTypes: { - className: { - control: 'text', - description: 'Additional classes applied to the sliding panel container.', - }, - show: { - control: 'boolean', - description: 'Controls visibility of the dialog.', - }, - onClose: { - control: false, - description: 'Invoked when the overlay/backdrop is clicked.', - }, - children: { - control: false, - table: { disable: true }, - }, - }, - args: { - show: false, - children: null, - }, -} satisfies Meta<typeof ContentDialog> - -export default meta -type Story = StoryObj<typeof meta> - -const DemoWrapper = (props: Props) => { - const [open, setOpen] = useState(props.show) - - useEffect(() => { - setOpen(props.show) - }, [props.show]) - - return ( - <div className="relative h-[480px] w-full overflow-hidden bg-gray-100"> - <div className="flex h-full items-center justify-center"> - <button - className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700" - onClick={() => setOpen(true)} - > - Open dialog - </button> - </div> - - <ContentDialog - {...props} - show={open} - onClose={() => { - props.onClose?.() - setOpen(false) - }} - > - <div className="flex h-full flex-col space-y-4 bg-white p-6"> - <h2 className="text-lg font-semibold text-gray-900">Plan summary</h2> - <p className="text-sm text-gray-600"> - Use this area to present rich content for the selected run, configuration details, or - any supporting context. - </p> - <div className="flex-1 overflow-y-auto rounded-md border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500"> - Scrollable placeholder content. Add domain-specific information, activity logs, or - editors in the real application. - </div> - <div className="flex justify-end gap-2 pt-4"> - <button - className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50" - onClick={() => setOpen(false)} - > - Cancel - </button> - <button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700"> - Apply changes - </button> - </div> - </div> - </ContentDialog> - </div> - ) -} - -export const Default: Story = { - args: { - children: null, - }, - render: args => <DemoWrapper {...args} />, -} - -export const NarrowPanel: Story = { - render: args => <DemoWrapper {...args} />, - args: { - className: 'max-w-[420px]', - children: null, - }, - parameters: { - docs: { - description: { - story: 'Applies a custom width class to show the dialog as a narrower information panel.', - }, - }, - }, -} diff --git a/web/app/components/base/content-dialog/index.tsx b/web/app/components/base/content-dialog/index.tsx deleted file mode 100644 index 5367a35fc4..0000000000 --- a/web/app/components/base/content-dialog/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ReactNode } from 'react' -import { Transition, TransitionChild } from '@headlessui/react' -import { cn } from '@langgenius/dify-ui/cn' - -type ContentDialogProps = { - className?: string - show: boolean - onClose?: () => void - children: ReactNode -} - -const ContentDialog = ({ - className, - show, - onClose, - children, -}: ContentDialogProps) => { - return ( - <Transition - show={show} - as="div" - className="absolute top-0 left-0 z-[70] box-border h-full w-full p-2" - > - <TransitionChild> - <div - className={cn('absolute inset-0 left-0 w-full bg-app-detail-overlay-bg', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')} - onClick={onClose} - /> - </TransitionChild> - - <TransitionChild> - <div className={cn('absolute left-0 w-full border-r border-divider-burn bg-app-detail-bg', 'duration-100 ease-in data-closed:-translate-x-full', 'data-enter:translate-x-0 data-enter:duration-300 data-enter:ease-out', 'data-leave:-translate-x-full data-leave:duration-200 data-leave:ease-in', className)}> - {children} - </div> - </TransitionChild> - </Transition> - ) -} - -export default ContentDialog diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx index 7858fa2fbe..60888a6ec4 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx @@ -31,7 +31,6 @@ const DatePicker = ({ needTimePicker = true, renderTrigger, triggerWrapClassName, - popupZIndexClassname, noConfirm, getIsDateDisabled, }: DatePickerProps) => { @@ -236,7 +235,6 @@ const DatePicker = ({ <PopoverContent placement="bottom-end" sideOffset={0} - className={popupZIndexClassname} popupClassName="border-none bg-transparent shadow-none" > <div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5"> diff --git a/web/app/components/base/date-and-time-picker/index.stories.tsx b/web/app/components/base/date-and-time-picker/index.stories.tsx index 1ed35afe88..430688d0e8 100644 --- a/web/app/components/base/date-and-time-picker/index.stories.tsx +++ b/web/app/components/base/date-and-time-picker/index.stories.tsx @@ -35,7 +35,6 @@ const DatePickerPlayground = (props: DatePickerProps) => { return ( <div className="inline-flex flex-col items-start gap-3"> <DatePicker - popupZIndexClassname="z-50" {...props} value={value} onChange={setValue} @@ -65,7 +64,6 @@ export const Playground: Story = { const [value, setValue] = useState(getDateWithTimezone({})) <DatePicker - popupZIndexClassname="z-50" value={value} timezone={dayjs.tz.guess()} onChange={setValue} diff --git a/web/app/components/base/date-and-time-picker/types.ts b/web/app/components/base/date-and-time-picker/types.ts index 7dda1d013c..8d27f03ad1 100644 --- a/web/app/components/base/date-and-time-picker/types.ts +++ b/web/app/components/base/date-and-time-picker/types.ts @@ -30,7 +30,6 @@ export type DatePickerProps = { triggerWrapClassName?: string renderTrigger?: (props: TriggerProps) => React.ReactElement minuteFilter?: (minutes: string[]) => string[] - popupZIndexClassname?: string noConfirm?: boolean getIsDateDisabled?: (date: Dayjs) => boolean } diff --git a/web/app/components/base/dialog/__tests__/index.spec.tsx b/web/app/components/base/dialog/__tests__/index.spec.tsx deleted file mode 100644 index 241e89be26..0000000000 --- a/web/app/components/base/dialog/__tests__/index.spec.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { act, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' -import CustomDialog from '../index' - -describe('CustomDialog Component', () => { - const setup = () => userEvent.setup() - - it('should render children and title when show is true', async () => { - render( - <CustomDialog show={true} title="Modal Title"> - <div data-testid="dialog-content">Main Content</div> - </CustomDialog>, - ) - - const title = await screen.findByText('Modal Title') - const content = screen.getByTestId('dialog-content') - - expect(title).toBeInTheDocument() - expect(content).toBeInTheDocument() - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should not render anything when show is false', async () => { - render( - <CustomDialog show={false} title="Hidden Title"> - <div>Content</div> - </CustomDialog>, - ) - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument() - }) - - it('should apply the correct semantic tag to title using titleAs', async () => { - render( - <CustomDialog show={true} title="Semantic Title" titleAs="h1"> - Content - </CustomDialog>, - ) - - const title = await screen.findByRole('heading', { level: 1 }) - expect(title).toHaveTextContent('Semantic Title') - }) - - it('should render the footer only when the prop is provided', async () => { - const { rerender } = render( - <CustomDialog show={true}>Content</CustomDialog>, - ) - - await screen.findByRole('dialog') - expect(screen.queryByText('Footer Content')).not.toBeInTheDocument() - - rerender( - <CustomDialog show={true} footer={<div data-testid="footer-node">Footer Content</div>}> - Content - </CustomDialog>, - ) - - expect(await screen.findByTestId('footer-node')).toBeInTheDocument() - }) - - it('should call onClose when Escape key is pressed', async () => { - const user = setup() - const onCloseMock = vi.fn() - - render( - <CustomDialog show={true} onClose={onCloseMock}> - Content - </CustomDialog>, - ) - - await screen.findByRole('dialog') - - await act(async () => { - await user.keyboard('{Escape}') - }) - - expect(onCloseMock).toHaveBeenCalledTimes(1) - }) - - it('should call onClose when the backdrop is clicked', async () => { - const user = setup() - const onCloseMock = vi.fn() - - render( - <CustomDialog show={true} onClose={onCloseMock}> - Content - </CustomDialog>, - ) - - await screen.findByRole('dialog') - - const backdrop = document.querySelector('.bg-background-overlay-backdrop') - expect(backdrop).toBeInTheDocument() - - await act(async () => { - await user.click(backdrop!) - }) - - expect(onCloseMock).toHaveBeenCalledTimes(1) - }) - - it('should apply custom class names to internal elements', async () => { - render( - <CustomDialog - show={true} - title="Title" - className="custom-panel-container" - titleClassName="custom-title-style" - bodyClassName="custom-body-style" - footer="Footer" - footerClassName="custom-footer-style" - > - <div data-testid="content">Content</div> - </CustomDialog>, - ) - - await screen.findByRole('dialog') - - expect(document.querySelector('.custom-panel-container')).toBeInTheDocument() - expect(document.querySelector('.custom-title-style')).toBeInTheDocument() - expect(document.querySelector('.custom-body-style')).toBeInTheDocument() - expect(document.querySelector('.custom-footer-style')).toBeInTheDocument() - }) - - it('should maintain accessibility attributes (aria-modal)', async () => { - render( - <CustomDialog show={true} title="Accessibility Test"> - <button>Focusable Item</button> - </CustomDialog>, - ) - - const dialog = await screen.findByRole('dialog') - // Headless UI should automatically set aria-modal="true" - expect(dialog).toHaveAttribute('aria-modal', 'true') - }) -}) diff --git a/web/app/components/base/dialog/index.stories.tsx b/web/app/components/base/dialog/index.stories.tsx deleted file mode 100644 index 2c9863da3b..0000000000 --- a/web/app/components/base/dialog/index.stories.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useEffect, useState } from 'react' -import Dialog from '.' - -const meta = { - title: 'Base/Feedback/Dialog', - component: Dialog, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: 'Modal dialog built on Headless UI. Provides animated overlay, title slot, and optional footer region.', - }, - }, - }, - tags: ['autodocs'], - argTypes: { - className: { - control: 'text', - description: 'Additional classes applied to the panel.', - }, - titleClassName: { - control: 'text', - description: 'Extra classes for the title element.', - }, - bodyClassName: { - control: 'text', - description: 'Extra classes for the content area.', - }, - footerClassName: { - control: 'text', - description: 'Extra classes for the footer container.', - }, - title: { - control: 'text', - description: 'Dialog title.', - }, - show: { - control: 'boolean', - description: 'Controls visibility of the dialog.', - }, - onClose: { - control: false, - description: 'Called when the dialog backdrop or close handler fires.', - }, - }, - args: { - title: 'Manage API Keys', - show: false, - children: null, - }, -} satisfies Meta<typeof Dialog> - -export default meta -type Story = StoryObj<typeof meta> - -const DialogDemo = (props: React.ComponentProps<typeof Dialog>) => { - const [open, setOpen] = useState(props.show) - useEffect(() => { - setOpen(props.show) - }, [props.show]) - - return ( - <div className="relative flex h-[480px] items-center justify-center bg-gray-100"> - <button - className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700" - onClick={() => setOpen(true)} - > - Show dialog - </button> - - <Dialog - {...props} - show={open} - onClose={() => { - props.onClose?.() - setOpen(false) - }} - > - <div className="space-y-4 text-sm text-gray-600"> - <p> - Centralize API key management for collaborators. You can revoke, rotate, or generate new keys directly from this dialog. - </p> - <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500"> - This placeholder area represents a form or table that would live inside the dialog body. - </div> - </div> - </Dialog> - </div> - ) -} - -export const Default: Story = { - render: args => <DialogDemo {...args} />, - args: { - footer: ( - <> - <button className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"> - Cancel - </button> - <button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700"> - Save changes - </button> - </> - ), - }, -} - -export const WithoutFooter: Story = { - render: args => <DialogDemo {...args} />, - args: { - footer: undefined, - title: 'Read-only summary', - }, - parameters: { - docs: { - description: { - story: 'Demonstrates the dialog when no footer actions are provided.', - }, - }, - }, -} - -export const CustomStyling: Story = { - render: args => <DialogDemo {...args} />, - args: { - className: 'max-w-[560px] bg-white/95 backdrop-blur-sm', - bodyClassName: 'bg-gray-50 rounded-xl p-5', - footerClassName: 'justify-between px-4 pb-4 pt-4', - titleClassName: 'text-lg text-primary-600', - footer: ( - <> - <span className="text-xs text-gray-400">Last synced 2 minutes ago</span> - <div className="flex gap-2"> - <button className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"> - Close - </button> - <button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700"> - Refresh data - </button> - </div> - </> - ), - }, - parameters: { - docs: { - description: { - story: 'Applies custom classes to the panel, body, title, and footer to match different surfaces.', - }, - }, - }, -} diff --git a/web/app/components/base/dialog/index.tsx b/web/app/components/base/dialog/index.tsx deleted file mode 100644 index 84d0c9b999..0000000000 --- a/web/app/components/base/dialog/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { ElementType, ReactNode } from 'react' -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react' -import { cn } from '@langgenius/dify-ui/cn' -import { Fragment, useCallback } from 'react' - -// https://headlessui.com/react/dialog - -type DialogProps = { - className?: string - titleClassName?: string - bodyClassName?: string - footerClassName?: string - titleAs?: ElementType - title?: ReactNode - children: ReactNode - footer?: ReactNode - show: boolean - onClose?: () => void -} - -const CustomDialog = ({ - className, - titleClassName, - bodyClassName, - footerClassName, - titleAs, - title, - children, - footer, - show, - onClose, -}: DialogProps) => { - const close = useCallback(() => onClose?.(), [onClose]) - return ( - <Transition appear show={show} as={Fragment}> - <Dialog as="div" className="relative z-40" onClose={close}> - <TransitionChild> - <div className={cn('fixed inset-0 bg-background-overlay-backdrop backdrop-blur-[6px]', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')} /> - </TransitionChild> - - <div className="fixed inset-0 overflow-y-auto"> - <div className="flex min-h-full items-center justify-center"> - <TransitionChild> - <DialogPanel className={cn('w-full max-w-[800px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl transition-all', 'duration-100 ease-in data-closed:scale-95 data-closed:opacity-0', 'data-enter:scale-100 data-enter:opacity-100', 'data-enter:scale-95 data-leave:opacity-0', className)}> - {Boolean(title) && ( - <DialogTitle - as={titleAs || 'h3'} - className={cn('pr-8 pb-3 title-2xl-semi-bold text-text-primary', titleClassName)} - > - {title} - </DialogTitle> - )} - <div className={cn(bodyClassName)}> - {children} - </div> - {Boolean(footer) && ( - <div className={cn('flex items-center justify-end gap-2 px-6 pt-3 pb-6', footerClassName)}> - {footer} - </div> - )} - </DialogPanel> - </TransitionChild> - </div> - </div> - </Dialog> - </Transition> - ) -} - -export default CustomDialog diff --git a/web/app/components/base/drawer-plus/__tests__/index.spec.tsx b/web/app/components/base/drawer-plus/__tests__/index.spec.tsx index 2d3667dd8f..b3a7d2cd2b 100644 --- a/web/app/components/base/drawer-plus/__tests__/index.spec.tsx +++ b/web/app/components/base/drawer-plus/__tests__/index.spec.tsx @@ -172,8 +172,7 @@ describe('DrawerPlus', () => { />, ) - const dialog = screen.getByRole('dialog') - expect(dialog.className).toContain('custom-dialog') + expect(document.querySelector('.custom-dialog')).toBeInTheDocument() }) it('should apply custom contentClassName', () => { diff --git a/web/app/components/base/drawer/__tests__/index.spec.tsx b/web/app/components/base/drawer/__tests__/index.spec.tsx index cd7eb937cc..1f9c1c258f 100644 --- a/web/app/components/base/drawer/__tests__/index.spec.tsx +++ b/web/app/components/base/drawer/__tests__/index.spec.tsx @@ -6,52 +6,60 @@ import Drawer from '../index' // Capture dialog onClose for testing let capturedDialogOnClose: (() => void) | null = null -// Mock @headlessui/react -vi.mock('@headlessui/react', () => ({ - Dialog: ({ children, open, onClose, className, unmount }: { - children: React.ReactNode - open: boolean - onClose: () => void - className: string - unmount: boolean - }) => { - capturedDialogOnClose = onClose - if (!open) - return null - return ( +// Mock Base UI Dialog anatomy; behavior is covered at the legacy wrapper boundary here. +vi.mock('@base-ui/react/dialog', () => ({ + Dialog: { + Root: ({ children, open, onOpenChange }: { + children: React.ReactNode + open: boolean + onOpenChange: (open: boolean) => void + }) => { + capturedDialogOnClose = () => onOpenChange(false) + if (!open) + return null + return <>{children}</> + }, + Portal: ({ children }: { + children: React.ReactNode + }) => <>{children}</>, + Backdrop: ({ children, className }: { + children?: React.ReactNode + className: string + }) => ( <div - data-testid="dialog" - data-open={open} - data-unmount={unmount} + data-testid="dialog-backdrop" className={className} - role="dialog" + onClick={() => capturedDialogOnClose?.()} > {children} </div> - ) + ), + Popup: ({ children, className, ...props }: { + children: React.ReactNode + className: string + }) => ( + <div + data-testid="dialog" + className={className} + role="dialog" + {...props} + > + {children} + </div> + ), + Title: ({ children, className, render, ...props }: { + children: React.ReactNode + className?: string + render?: React.ReactElement + }) => { + const Component = render?.type ?? 'h2' + return ( + <Component data-testid="dialog-title" className={className} {...props}> + {children} + </Component> + ) + }, }, - DialogBackdrop: ({ children, className, onClick }: { - children?: React.ReactNode - className: string - onClick: () => void - }) => ( - <div - data-testid="dialog-backdrop" - className={className} - onClick={onClick} - > - {children} - </div> - ), - DialogTitle: ({ children, as: _as, className, ...props }: { - children: React.ReactNode - as?: string - className?: string - }) => ( - <div data-testid="dialog-title" className={className} {...props}> - {children} - </div> - ), })) // Mock XMarkIcon @@ -343,10 +351,10 @@ describe('Drawer', () => { describe('Custom ClassNames', () => { it('should apply custom dialogClassName', () => { // Arrange & Act - renderDrawer({ dialogClassName: 'custom-dialog-class' }) + const { container } = renderDrawer({ dialogClassName: 'custom-dialog-class' }) // Assert - expect(screen.getByRole('dialog').className).toContain('custom-dialog-class') + expect(container.querySelector('.custom-dialog-class')).toBeInTheDocument() }) it('should apply custom dialogBackdropClassName', () => { diff --git a/web/app/components/base/drawer/index.stories.tsx b/web/app/components/base/drawer/index.stories.tsx index ca7b3bc243..57ab35281c 100644 --- a/web/app/components/base/drawer/index.stories.tsx +++ b/web/app/components/base/drawer/index.stories.tsx @@ -10,7 +10,7 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Sliding panel built on Headless UI dialog primitives. Supports optional mask, custom footer, and close behaviour.', + component: 'Sliding panel built on Base UI dialog primitives. Supports optional mask, custom footer, and close behaviour.', }, }, }, diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx index 88c5874dd5..d6c0d58e7c 100644 --- a/web/app/components/base/drawer/index.tsx +++ b/web/app/components/base/drawer/index.tsx @@ -1,5 +1,6 @@ 'use client' -import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react' +// eslint-disable-next-line no-restricted-imports -- Temporary legacy drawer exception: remove this direct Base UI wrapper after callers migrate to dify-ui drawer primitives. +import { Dialog as BaseDialog } from '@base-ui/react/dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' @@ -47,80 +48,81 @@ export default function Drawer({ }: IDrawerProps) { const { t } = useTranslation() return ( - <Dialog - unmount={unmount} + <BaseDialog.Root open={isOpen} - onClose={() => { - if (!clickOutsideNotOpen) + disablePointerDismissal={clickOutsideNotOpen} + onOpenChange={(open) => { + if (!open && !clickOutsideNotOpen) onClose() }} - className={cn('fixed inset-0 z-30 overflow-y-auto', dialogClassName)} > - <div className={cn('flex h-screen w-screen justify-end', positionCenter && 'justify-center!', containerClassName)}> - {/* mask */} - {!noOverlay && ( - <DialogBackdrop - className={cn('fixed inset-0 z-40', mask && 'bg-black/30', dialogBackdropClassName)} - onClick={() => { - if (!clickOutsideNotOpen) - onClose() - }} - /> - )} - <div className={cn('relative z-50 flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassName)}> - <> - <div className="flex justify-between"> - {title && ( - <DialogTitle - as="h3" - className="text-lg leading-6 font-medium text-text-primary" - > - {title} - </DialogTitle> - )} - {showClose && ( - <DialogTitle className="mb-4 flex cursor-pointer items-center" as="div"> - <span - className="i-heroicons-x-mark h-4 w-4 text-text-tertiary" - onClick={onClose} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') - onClose() - }} - role="button" - tabIndex={0} - aria-label={t('operation.close', { ns: 'common' })} - data-testid="close-icon" - /> - </DialogTitle> - )} - </div> - {description && <div className="mt-2 text-xs font-normal text-text-tertiary">{description}</div>} - {children} - </> - {footer || (footer === null - ? null - : ( - <div className="mt-10 flex flex-row justify-end"> - <Button - className="mr-2" - onClick={() => { - onCancel?.() - }} - > - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button - onClick={() => { - onOk?.() - }} - > - {t('operation.save', { ns: 'common' })} - </Button> + <BaseDialog.Portal> + <div className={cn('fixed inset-0 z-30 overflow-y-auto', dialogClassName)}> + <div className={cn('flex h-screen w-screen justify-end', positionCenter && 'justify-center!', containerClassName)}> + {!noOverlay && ( + <BaseDialog.Backdrop + className={cn('fixed inset-0 z-40', mask && 'bg-black/30', dialogBackdropClassName)} + /> + )} + <BaseDialog.Popup + data-unmount={unmount} + className={cn('relative z-50 flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassName)} + > + <> + <div className="flex justify-between"> + {title && ( + <BaseDialog.Title + render={<h3 />} + className="text-lg leading-6 font-medium text-text-primary" + > + {title} + </BaseDialog.Title> + )} + {showClose && ( + <div className="mb-4 flex cursor-pointer items-center"> + <span + className="i-heroicons-x-mark h-4 w-4 text-text-tertiary" + onClick={onClose} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') + onClose() + }} + role="button" + tabIndex={0} + aria-label={t('operation.close', { ns: 'common' })} + data-testid="close-icon" + /> + </div> + )} </div> - ))} + {description && <div className="mt-2 text-xs font-normal text-text-tertiary">{description}</div>} + {children} + </> + {footer || (footer === null + ? null + : ( + <div className="mt-10 flex flex-row justify-end"> + <Button + className="mr-2" + onClick={() => { + onCancel?.() + }} + > + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button + onClick={() => { + onOk?.() + }} + > + {t('operation.save', { ns: 'common' })} + </Button> + </div> + ))} + </BaseDialog.Popup> + </div> </div> - </div> - </Dialog> + </BaseDialog.Portal> + </BaseDialog.Root> ) } diff --git a/web/app/components/base/emoji-picker/index.tsx b/web/app/components/base/emoji-picker/index.tsx index 75112e70eb..9ff81b999f 100644 --- a/web/app/components/base/emoji-picker/index.tsx +++ b/web/app/components/base/emoji-picker/index.tsx @@ -2,12 +2,11 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { noop } from 'es-toolkit/function' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import Modal from '@/app/components/base/modal' import EmojiPickerInner from './Inner' type IEmojiPickerProps = { @@ -34,39 +33,42 @@ const EmojiPicker: FC<IEmojiPickerProps> = ({ return isModal ? ( - <Modal - onClose={noop} - isShow - closable={false} - wrapperClassName={className} - className={cn('flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl')} - > - <EmojiPickerInner - className="pt-3" - onSelect={handleSelectEmoji} - /> - <Divider className="mt-3 mb-0" /> - <div className="flex w-full items-center justify-center gap-2 p-3"> - <Button - className="w-full" - onClick={() => { - onClose?.() - }} - > - {t('iconPicker.cancel', { ns: 'app' })} - </Button> - <Button - disabled={selectedEmoji === '' || !selectedBackground} - variant="primary" - className="w-full" - onClick={() => { - onSelect?.(selectedEmoji, selectedBackground!) - }} - > - {t('iconPicker.ok', { ns: 'app' })} - </Button> - </div> - </Modal> + <Dialog open> + <DialogContent + className={cn( + 'max-h-none w-full overflow-hidden! text-left align-middle', + 'flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl', + className, + )} + > + + <EmojiPickerInner + className="pt-3" + onSelect={handleSelectEmoji} + /> + <Divider className="mt-3 mb-0" /> + <div className="flex w-full items-center justify-center gap-2 p-3"> + <Button + className="w-full" + onClick={() => { + onClose?.() + }} + > + {t('iconPicker.cancel', { ns: 'app' })} + </Button> + <Button + disabled={selectedEmoji === '' || !selectedBackground} + variant="primary" + className="w-full" + onClick={() => { + onSelect?.(selectedEmoji, selectedBackground!) + }} + > + {t('iconPicker.ok', { ns: 'app' })} + </Button> + </div> + </DialogContent> + </Dialog> ) : <></> } diff --git a/web/app/components/base/features/new-feature-panel/__tests__/dialog-wrapper.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/feature-panel-drawer.spec.tsx similarity index 51% rename from web/app/components/base/features/new-feature-panel/__tests__/dialog-wrapper.spec.tsx rename to web/app/components/base/features/new-feature-panel/__tests__/feature-panel-drawer.spec.tsx index 374976c366..a4c45ff1e4 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/dialog-wrapper.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/feature-panel-drawer.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import DialogWrapper from '../dialog-wrapper' +import { FeaturePanelDrawer } from '../feature-panel-drawer' -describe('DialogWrapper', () => { +describe('FeaturePanelDrawer', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -9,9 +9,9 @@ describe('DialogWrapper', () => { describe('Rendering', () => { it('should render children when show is true', () => { render( - <DialogWrapper show> + <FeaturePanelDrawer show> <div data-testid="content">Content</div> - </DialogWrapper>, + </FeaturePanelDrawer>, ) expect(screen.getByTestId('content')).toBeInTheDocument() @@ -19,9 +19,9 @@ describe('DialogWrapper', () => { it('should not render children when show is false', () => { render( - <DialogWrapper show={false}> + <FeaturePanelDrawer show={false}> <div data-testid="content">Content</div> - </DialogWrapper>, + </FeaturePanelDrawer>, ) expect(screen.queryByTestId('content')).not.toBeInTheDocument() @@ -31,45 +31,44 @@ describe('DialogWrapper', () => { describe('Props', () => { it('should apply workflow styles by default', () => { render( - <DialogWrapper show> + <FeaturePanelDrawer show> <div data-testid="content">Content</div> - </DialogWrapper>, + </FeaturePanelDrawer>, ) - const wrapper = screen.getByTestId('content').parentElement - expect(wrapper).toHaveClass('rounded-l-2xl') - expect(wrapper).not.toHaveClass('rounded-2xl') + const drawer = screen.getByRole('dialog') + expect(drawer).toHaveClass('data-[swipe-direction=right]:!top-[112px]') + expect(drawer).toHaveClass('data-[swipe-direction=right]:!rounded-l-2xl') + expect(drawer).not.toHaveClass('data-[swipe-direction=right]:!rounded-2xl') }) it('should apply non-workflow styles when inWorkflow is false', () => { render( - <DialogWrapper show inWorkflow={false}> + <FeaturePanelDrawer show inWorkflow={false}> <div data-testid="content">Content</div> - </DialogWrapper>, + </FeaturePanelDrawer>, ) - const content = screen.getByTestId('content') - const panel = content.parentElement - const layoutContainer = screen.getByTestId('dialog-layout-container') + const drawer = screen.getByRole('dialog') + const layoutContainer = screen.getByTestId('feature-panel-drawer-layout') - expect(layoutContainer).toHaveClass('pr-2') - expect(layoutContainer).toHaveClass('pt-[64px]') - expect(layoutContainer).not.toHaveClass('pt-[112px]') + expect(layoutContainer).toBeInTheDocument() - expect(panel).toHaveClass('rounded-2xl') - expect(panel).toHaveClass('border-[0.5px]') - expect(panel).not.toHaveClass('rounded-l-2xl') + expect(drawer).toHaveClass('data-[swipe-direction=right]:!top-[64px]') + expect(drawer).toHaveClass('data-[swipe-direction=right]:!right-2') + expect(drawer).toHaveClass('data-[swipe-direction=right]:!rounded-2xl') + expect(drawer).toHaveClass('data-[swipe-direction=right]:!border-[0.5px]') + expect(drawer).not.toHaveClass('data-[swipe-direction=right]:!rounded-l-2xl') }) it('should accept custom className', () => { render( - <DialogWrapper show className="custom-class"> + <FeaturePanelDrawer show className="custom-class"> <div data-testid="content">Content</div> - </DialogWrapper>, + </FeaturePanelDrawer>, ) - const wrapper = screen.getByTestId('content').parentElement - expect(wrapper).toHaveClass('custom-class') + expect(screen.getByRole('dialog')).toHaveClass('custom-class') }) }) @@ -78,9 +77,9 @@ describe('DialogWrapper', () => { const onClose = vi.fn() render( - <DialogWrapper show onClose={onClose}> + <FeaturePanelDrawer show onClose={onClose}> <div>Content</div> - </DialogWrapper>, + </FeaturePanelDrawer>, ) fireEvent.keyDown(document, { key: 'Escape' }) @@ -92,9 +91,9 @@ describe('DialogWrapper', () => { it('should not throw when escape is pressed without onClose', () => { render( - <DialogWrapper show> + <FeaturePanelDrawer show> <div>Content</div> - </DialogWrapper>, + </FeaturePanelDrawer>, ) expect(() => { diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx index 13b4133ab6..73f76a6ce6 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx @@ -2,11 +2,11 @@ import type { FC } from 'react' import type { AnnotationReplyConfig } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' @@ -58,52 +58,61 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a setLoading(false) } return ( - <Modal isShow={isShow} onClose={onHide} className="!mt-14 !w-[640px] !max-w-none !p-6"> - <div className="mb-2 title-2xl-semi-bold text-text-primary"> - {t(`initSetup.${isInit ? 'title' : 'configTitle'}`, { ns: 'appAnnotation' })} - </div> + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onHide() + }} + > + <DialogContent className="!mt-14 !w-[640px] !max-w-none overflow-hidden! border-none !p-6 text-left align-middle"> - <div className="mt-6 space-y-3"> - <Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}> - <ScoreSlider - className="mt-1" - value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100} - onChange={(val) => { - setAnnotationConfig({ - ...annotationConfig, - score_threshold: val / 100, - }) - }} - /> - </Item> + <div className="mb-2 title-2xl-semi-bold text-text-primary"> + {t(`initSetup.${isInit ? 'title' : 'configTitle'}`, { ns: 'appAnnotation' })} + </div> - <Item title={t('modelProvider.embeddingModel.key', { ns: 'common' })} tooltip={t('embeddingModelSwitchTip', { ns: 'appAnnotation' })}> - <div className="pt-1"> - <ModelSelector - defaultModel={embeddingModel && { - provider: embeddingModel.providerName, - model: embeddingModel.modelName, - }} - modelList={embeddingsModelList} - onSelect={(val) => { - setEmbeddingModel({ - providerName: val.provider, - modelName: val.model, + <div className="mt-6 space-y-3"> + <Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}> + <ScoreSlider + className="mt-1" + value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100} + onChange={(val) => { + setAnnotationConfig({ + ...annotationConfig, + score_threshold: val / 100, }) }} /> - </div> - </Item> - </div> + </Item> - <div className="mt-6 flex justify-end gap-2"> - <Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleSave} loading={isLoading}> - <div></div> - <div>{t(`initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`, { ns: 'appAnnotation' })}</div> - </Button> - </div> - </Modal> + <Item title={t('modelProvider.embeddingModel.key', { ns: 'common' })} tooltip={t('embeddingModelSwitchTip', { ns: 'appAnnotation' })}> + <div className="pt-1"> + <ModelSelector + defaultModel={embeddingModel && { + provider: embeddingModel.providerName, + model: embeddingModel.modelName, + }} + modelList={embeddingsModelList} + onSelect={(val) => { + setEmbeddingModel({ + providerName: val.provider, + modelName: val.model, + }) + }} + /> + </div> + </Item> + </div> + + <div className="mt-6 flex justify-end gap-2"> + <Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button variant="primary" onClick={handleSave} loading={isLoading}> + <div></div> + <div>{t(`initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`, { ns: 'appAnnotation' })}</div> + </Button> + </div> + </DialogContent> + </Dialog> ) } export default React.memo(ConfigParamModal) diff --git a/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx b/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx deleted file mode 100644 index d3e0814777..0000000000 --- a/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ReactNode } from 'react' -import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' -import { cn } from '@langgenius/dify-ui/cn' -import { Fragment, useCallback } from 'react' - -type DialogProps = { - className?: string - children: ReactNode - show: boolean - onClose?: () => void - inWorkflow?: boolean -} - -const DialogWrapper = ({ - className, - children, - show, - onClose, - inWorkflow = true, -}: DialogProps) => { - const close = useCallback(() => onClose?.(), [onClose]) - return ( - <Transition appear show={show} as={Fragment}> - <Dialog as="div" className="relative z-40" onClose={close}> - <TransitionChild> - <div className={cn( - 'fixed inset-0 bg-black/25', - 'data-closed:opacity-0', - 'data-enter:opacity-100 data-enter:duration-300 data-enter:ease-out', - 'data-leave:opacity-0 data-leave:duration-200 data-leave:ease-in', - )} - /> - </TransitionChild> - - <div className="fixed inset-0"> - <div className={cn('flex min-h-full flex-col items-end justify-center pb-2', inWorkflow ? 'pt-[112px]' : 'pt-[64px] pr-2')} data-testid="dialog-layout-container"> - <TransitionChild> - <DialogPanel className={cn( - 'relative flex h-0 w-[420px] grow flex-col overflow-hidden border-components-panel-border bg-components-panel-bg-alt p-0 text-left align-middle shadow-xl transition-all', - inWorkflow ? 'rounded-l-2xl border-t-[0.5px] border-b-[0.5px] border-l-[0.5px]' : 'rounded-2xl border-[0.5px]', - 'data-closed:scale-95 data-closed:opacity-0', - 'data-enter:scale-100 data-enter:opacity-100 data-enter:duration-300 data-enter:ease-out', - 'data-leave:scale-95 data-leave:opacity-0 data-leave:duration-200 data-leave:ease-in', - className, - )} - > - {children} - </DialogPanel> - </TransitionChild> - </div> - </div> - </Dialog> - </Transition> - ) -} - -export default DialogWrapper diff --git a/web/app/components/base/features/new-feature-panel/feature-panel-drawer.tsx b/web/app/components/base/features/new-feature-panel/feature-panel-drawer.tsx new file mode 100644 index 0000000000..42a89a156d --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/feature-panel-drawer.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' +import { useCallback } from 'react' + +type FeaturePanelDrawerProps = { + className?: string + children: ReactNode + show: boolean + onClose?: () => void + inWorkflow?: boolean +} + +export function FeaturePanelDrawer({ + className, + children, + show, + onClose, + inWorkflow = true, +}: FeaturePanelDrawerProps) { + const close = useCallback(() => onClose?.(), [onClose]) + + return ( + <Drawer + open={show} + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + close() + }} + > + <DrawerPortal> + <DrawerBackdrop className="bg-black/25" /> + <DrawerViewport data-testid="feature-panel-drawer-layout"> + <DrawerPopup + className={cn( + 'border-components-panel-border bg-components-panel-bg-alt p-0 text-left align-middle', + 'data-[swipe-direction=right]:!h-auto data-[swipe-direction=right]:!w-[420px] data-[swipe-direction=right]:!max-w-[calc(100vw-2rem)]', + inWorkflow + ? 'data-[swipe-direction=right]:!top-[112px] data-[swipe-direction=right]:!right-0 data-[swipe-direction=right]:!bottom-2 data-[swipe-direction=right]:!rounded-l-2xl data-[swipe-direction=right]:!rounded-r-none data-[swipe-direction=right]:!border-t-[0.5px] data-[swipe-direction=right]:!border-r-0 data-[swipe-direction=right]:!border-b-[0.5px] data-[swipe-direction=right]:!border-l-[0.5px]' + : 'data-[swipe-direction=right]:!top-[64px] data-[swipe-direction=right]:!right-2 data-[swipe-direction=right]:!bottom-2 data-[swipe-direction=right]:!rounded-2xl data-[swipe-direction=right]:!border-[0.5px]', + className, + )} + > + <DrawerContent className="flex min-h-0 flex-1 touch-auto flex-col overflow-hidden p-0 pb-0"> + {children} + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> + </Drawer> + ) +} diff --git a/web/app/components/base/features/new-feature-panel/index.tsx b/web/app/components/base/features/new-feature-panel/index.tsx index 84c7715577..61e961c82b 100644 --- a/web/app/components/base/features/new-feature-panel/index.tsx +++ b/web/app/components/base/features/new-feature-panel/index.tsx @@ -1,13 +1,13 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { InputVar } from '@/app/components/workflow/types' import type { PromptVariable } from '@/models/debug' -import { RiCloseLine } from '@remixicon/react' +import { DrawerCloseButton } from '@langgenius/dify-ui/drawer' import { useTranslation } from 'react-i18next' import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply' import Citation from '@/app/components/base/features/new-feature-panel/citation' import ConversationOpener from '@/app/components/base/features/new-feature-panel/conversation-opener' -import DialogWrapper from '@/app/components/base/features/new-feature-panel/dialog-wrapper' +import { FeaturePanelDrawer } from '@/app/components/base/features/new-feature-panel/feature-panel-drawer' import FileUpload from '@/app/components/base/features/new-feature-panel/file-upload' import FollowUp from '@/app/components/base/features/new-feature-panel/follow-up' import ImageUpload from '@/app/components/base/features/new-feature-panel/image-upload' @@ -48,7 +48,7 @@ const NewFeaturePanel = ({ const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts) return ( - <DialogWrapper + <FeaturePanelDrawer show={show} onClose={onClose} inWorkflow={inWorkflow} @@ -60,7 +60,10 @@ const NewFeaturePanel = ({ <div className="system-xl-semibold text-text-primary">{t('common.features', { ns: 'workflow' })}</div> <div className="body-xs-regular text-text-tertiary">{t('common.featuresDescription', { ns: 'workflow' })}</div> </div> - <div className="h-8 w-8 cursor-pointer p-2" onClick={onClose}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-8 w-8 p-2" + /> </div> {/* list */} <div className="grow basis-0 overflow-y-auto px-4 pb-4"> @@ -96,7 +99,7 @@ const NewFeaturePanel = ({ )} </div> </div> - </DialogWrapper> + </FeaturePanelDrawer> ) } diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index 0ae2f458e1..7522c73445 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -3,12 +3,11 @@ import type { CodeBasedExtensionItem } from '@/models/common' import type { ModerationConfig, ModerationContentConfig } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import Modal from '@/app/components/base/modal' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -226,165 +225,164 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ } return ( - <Modal - isShow - onClose={noop} - className="mt-14! w-[600px]! max-w-none! p-6!" - > - <div className="flex items-center justify-between"> - <div className="title-2xl-semi-bold text-text-primary">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div> - <div - role="button" - tabIndex={0} - className="cursor-pointer p-1" - onClick={onCancel} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onCancel() - } - }} - > - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> + <Dialog open> + <DialogContent className="mt-14! w-[600px]! max-w-none! overflow-hidden! border-none p-6! text-left align-middle"> + + <div className="flex items-center justify-between"> + <div className="title-2xl-semi-bold text-text-primary">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div> + <div + role="button" + tabIndex={0} + className="cursor-pointer p-1" + onClick={onCancel} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onCancel() + } + }} + > + <span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> + </div> </div> - </div> - <div className="py-2"> - <div className="text-sm leading-9 font-medium text-text-primary"> - {t('feature.moderation.modal.provider.title', { ns: 'appDebug' })} - </div> - <div className="grid grid-cols-3 gap-2.5"> - { - providers.map(provider => ( - <div - key={provider.key} - className={cn( - 'flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary', - localeData.type !== provider.key && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', - localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled', - )} - onClick={() => handleDataTypeChange(provider.key)} - > - <div className={cn( - 'mr-2 h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs', - localeData.type === provider.key && 'border-[5px] border-components-radio-border-checked', - )} + <div className="py-2"> + <div className="text-sm leading-9 font-medium text-text-primary"> + {t('feature.moderation.modal.provider.title', { ns: 'appDebug' })} + </div> + <div className="grid grid-cols-3 gap-2.5"> + { + providers.map(provider => ( + <div + key={provider.key} + className={cn( + 'flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary', + localeData.type !== provider.key && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', + localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', + localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled', + )} + onClick={() => handleDataTypeChange(provider.key)} > + <div className={cn( + 'mr-2 h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs', + localeData.type === provider.key && 'border-[5px] border-components-radio-border-checked', + )} + > + </div> + {provider.name} + </div> + )) + } + </div> + { + !isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && ( + <div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2"> + <span className="mr-1 i-custom-vender-line-general-info-circle h-4 w-4 text-[#F79009]" /> + <div className="flex items-center text-xs font-medium text-gray-700"> + {t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })} + <span + className="cursor-pointer text-primary-600" + onClick={handleOpenSettingsModal} + > +   + {t('settings.provider', { ns: 'common' })} +  + </span> + {t('feature.moderation.modal.openaiNotConfig.after', { ns: 'appDebug' })} </div> - {provider.name} </div> - )) + ) } </div> { - !isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && ( - <div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2"> - <span className="mr-1 i-custom-vender-line-general-info-circle h-4 w-4 text-[#F79009]" /> - <div className="flex items-center text-xs font-medium text-gray-700"> - {t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })} - <span - className="cursor-pointer text-primary-600" - onClick={handleOpenSettingsModal} - > -   - {t('settings.provider', { ns: 'common' })} -  - </span> - {t('feature.moderation.modal.openaiNotConfig.after', { ns: 'appDebug' })} + localeData.type === 'keywords' && ( + <div className="py-2"> + <div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div> + <div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div> + <div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2"> + <textarea + value={localeData.config?.keywords || ''} + onChange={handleDataKeywordsChange} + className="block h-full w-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden" + placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''} + /> + <div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary"> + <span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span> + / + <span className="text-text-tertiary"> + 100 + {t('feature.moderation.modal.keywords.line', { ns: 'appDebug' })} + </span> + </div> </div> </div> ) } - </div> - { - localeData.type === 'keywords' && ( - <div className="py-2"> - <div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div> - <div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div> - <div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2"> - <textarea - value={localeData.config?.keywords || ''} - onChange={handleDataKeywordsChange} - className="block h-full w-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden" - placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''} - /> - <div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary"> - <span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span> - / - <span className="text-text-tertiary"> - 100 - {t('feature.moderation.modal.keywords.line', { ns: 'appDebug' })} - </span> + { + localeData.type === 'api' && ( + <div className="py-2"> + <div className="flex h-9 items-center justify-between"> + <div className="text-sm font-medium text-text-primary">{t('apiBasedExtension.selector.title', { ns: 'common' })}</div> + <a + href={docLink('/use-dify/workspace/api-extension/api-extension')} + target="_blank" + rel="noopener noreferrer" + className="group flex items-center text-xs text-text-tertiary hover:text-primary-600" + > + <span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3 text-text-tertiary group-hover:text-primary-600" /> + {t('apiBasedExtension.link', { ns: 'common' })} + </a> </div> + <ApiBasedExtensionSelector + value={localeData.config?.api_based_extension_id || ''} + onChange={handleDataApiBasedChange} + /> </div> - </div> - ) - } - { - localeData.type === 'api' && ( - <div className="py-2"> - <div className="flex h-9 items-center justify-between"> - <div className="text-sm font-medium text-text-primary">{t('apiBasedExtension.selector.title', { ns: 'common' })}</div> - <a - href={docLink('/use-dify/workspace/api-extension/api-extension')} - target="_blank" - rel="noopener noreferrer" - className="group flex items-center text-xs text-text-tertiary hover:text-primary-600" - > - <span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3 text-text-tertiary group-hover:text-primary-600" /> - {t('apiBasedExtension.link', { ns: 'common' })} - </a> - </div> - <ApiBasedExtensionSelector - value={localeData.config?.api_based_extension_id || ''} - onChange={handleDataApiBasedChange} + ) + } + { + systemTypes.findIndex(t => t === localeData.type) < 0 + && currentProvider?.form_schema + && ( + <FormGeneration + forms={currentProvider?.form_schema} + value={localeData.config} + onChange={handleDataExtraChange} /> - </div> - ) - } - { - systemTypes.findIndex(t => t === localeData.type) < 0 - && currentProvider?.form_schema - && ( - <FormGeneration - forms={currentProvider?.form_schema} - value={localeData.config} - onChange={handleDataExtraChange} - /> - ) - } - <Divider bgStyle="gradient" className="my-3 h-px" /> - <ModerationContent - title={t('feature.moderation.modal.content.input', { ns: 'appDebug' }) || ''} - config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }} - onConfigChange={config => handleDataContentChange('inputs_config', config)} - info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''} - showPreset={localeData.type !== 'api'} - /> - <ModerationContent - title={t('feature.moderation.modal.content.output', { ns: 'appDebug' }) || ''} - config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }} - onConfigChange={config => handleDataContentChange('outputs_config', config)} - info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''} - showPreset={localeData.type !== 'api'} - /> - <div className="mt-1 mb-8 text-xs font-medium text-text-tertiary">{t('feature.moderation.modal.content.condition', { ns: 'appDebug' })}</div> - <div className="flex items-center justify-end"> - <Button - onClick={onCancel} - className="mr-2" - > - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button - variant="primary" - onClick={handleSave} - disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured} - > - {t('operation.save', { ns: 'common' })} - </Button> - </div> - </Modal> + ) + } + <Divider bgStyle="gradient" className="my-3 h-px" /> + <ModerationContent + title={t('feature.moderation.modal.content.input', { ns: 'appDebug' }) || ''} + config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }} + onConfigChange={config => handleDataContentChange('inputs_config', config)} + info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''} + showPreset={localeData.type !== 'api'} + /> + <ModerationContent + title={t('feature.moderation.modal.content.output', { ns: 'appDebug' }) || ''} + config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }} + onConfigChange={config => handleDataContentChange('outputs_config', config)} + info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''} + showPreset={localeData.type !== 'api'} + /> + <div className="mt-1 mb-8 text-xs font-medium text-text-tertiary">{t('feature.moderation.modal.content.condition', { ns: 'appDebug' })}</div> + <div className="flex items-center justify-end"> + <Button + onClick={onCancel} + className="mr-2" + > + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button + variant="primary" + onClick={handleSave} + disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured} + > + {t('operation.save', { ns: 'common' })} + </Button> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx index 5a5740ef0d..7029bc8e0a 100644 --- a/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx @@ -39,15 +39,14 @@ describe('AudioPreview', () => { expect(onCancel).toHaveBeenCalled() }) - it('should stop propagation when backdrop is clicked', () => { - const { baseElement } = render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />) + it('should not close when backdrop is clicked', () => { + const onCancel = vi.fn() + render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />) - const backdrop = baseElement.querySelector('[tabindex="-1"]') - const event = new MouseEvent('click', { bubbles: true }) - const stopPropagation = vi.spyOn(event, 'stopPropagation') - backdrop!.dispatchEvent(event) + const dialog = screen.getByRole('dialog') + fireEvent.click(dialog) - expect(stopPropagation).toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() }) it('should call onCancel when Escape key is pressed', () => { @@ -64,6 +63,6 @@ describe('AudioPreview', () => { render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />) const audio = document.querySelector('audio') - expect(audio?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body) + expect(audio?.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body) }) }) diff --git a/web/app/components/base/file-uploader/__tests__/pdf-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/pdf-preview.spec.tsx index b3c48a7061..3aa0f9e6d6 100644 --- a/web/app/components/base/file-uploader/__tests__/pdf-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/pdf-preview.spec.tsx @@ -35,7 +35,7 @@ describe('PdfPreview', () => { } const getControl = (rightClass: 'right-24' | 'right-16' | 'right-6') => { - const control = document.querySelector(`div.absolute.${rightClass}.top-6`) as HTMLDivElement | null + const control = document.querySelector(`button.absolute.${rightClass}.top-6`) as HTMLButtonElement | null expect(control).toBeInTheDocument() return control! } @@ -129,14 +129,12 @@ describe('PdfPreview', () => { expect(mockOnCancel).toHaveBeenCalled() }) - it('should render the overlay and stop click propagation', () => { + it('should render the overlay and keep backdrop clicks from closing', () => { render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />) - const overlay = document.querySelector('[tabindex="-1"]') + const overlay = screen.getByRole('dialog') expect(overlay).toBeInTheDocument() - const event = new MouseEvent('click', { bubbles: true }) - const stopPropagation = vi.spyOn(event, 'stopPropagation') - overlay!.dispatchEvent(event) - expect(stopPropagation).toHaveBeenCalled() + fireEvent.click(overlay) + expect(mockOnCancel).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx index c95455caf3..0430bd37f9 100644 --- a/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import VideoPreview from '../video-preview' describe('VideoPreview', () => { @@ -39,15 +39,14 @@ describe('VideoPreview', () => { expect(onCancel).toHaveBeenCalled() }) - it('should stop propagation when backdrop is clicked', () => { - const { baseElement } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />) + it('should not close when backdrop is clicked', () => { + const onCancel = vi.fn() + render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />) - const backdrop = baseElement.querySelector('[tabindex="-1"]') - const event = new MouseEvent('click', { bubbles: true }) - const stopPropagation = vi.spyOn(event, 'stopPropagation') - backdrop!.dispatchEvent(event) + const dialog = screen.getByRole('dialog') + fireEvent.click(dialog) - expect(stopPropagation).toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() }) it('should call onCancel when Escape key is pressed', () => { @@ -64,6 +63,6 @@ describe('VideoPreview', () => { render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />) const video = document.querySelector('video') - expect(video?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body) + expect(video?.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body) }) }) diff --git a/web/app/components/base/file-uploader/audio-preview.tsx b/web/app/components/base/file-uploader/audio-preview.tsx index 28988b3a7c..6d38fad169 100644 --- a/web/app/components/base/file-uploader/audio-preview.tsx +++ b/web/app/components/base/file-uploader/audio-preview.tsx @@ -1,8 +1,5 @@ import type { FC } from 'react' -import * as React from 'react' -import { createPortal } from 'react-dom' - -import { useHotkeys } from 'react-hotkeys-hook' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' type AudioPreviewProps = { url: string @@ -14,31 +11,40 @@ const AudioPreview: FC<AudioPreviewProps> = ({ title, onCancel, }) => { - useHotkeys('esc', onCancel) - - return createPortal( - <div - className="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8" - onClick={e => e.stopPropagation()} - tabIndex={-1} + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} + disablePointerDismissal > - <div> - <audio controls title={title} autoPlay={false} preload="metadata"> - <source - type="audio/mpeg" - src={url} - className="max-h-full max-w-full" - /> - </audio> - </div> - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" - onClick={onCancel} + <DialogContent + className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!" + backdropClassName="bg-transparent!" > - <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-btn" /> - </div> - </div>, - document.body, + <div + aria-label={title} + tabIndex={-1} + onClick={e => e.stopPropagation()} + > + <audio controls title={title} autoPlay={false} preload="metadata"> + <source + type="audio/mpeg" + src={url} + className="max-h-full max-w-full" + /> + </audio> + </div> + <div + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" + onClick={onCancel} + > + <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-btn" /> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx index 002eced6ca..183313cd91 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx @@ -186,7 +186,7 @@ describe('FileInAttachmentItem', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]!) - // ImagePreview renders via createPortal with class "image-preview-container" + // ImagePreview renders through Dialog with class "image-preview-container" const previewContainer = document.querySelector('.image-preview-container')! expect(previewContainer)!.toBeInTheDocument() diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx index cfe6719b20..c84d9e3d69 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx @@ -95,8 +95,7 @@ describe('FileImageItem', () => { const img = screen.getByRole('img') fireEvent.click(img.parentElement!) - // ImagePreview renders via createPortal with class "image-preview-container", not role="dialog" - // ImagePreview renders via createPortal with class "image-preview-container", not role="dialog" + // ImagePreview renders through Dialog with class "image-preview-container" expect(document.querySelector('.image-preview-container'))!.toBeInTheDocument() }) @@ -114,7 +113,7 @@ describe('FileImageItem', () => { const img = screen.getByRole('img') fireEvent.click(img.parentElement!) - // ImagePreview renders via createPortal with class "image-preview-container" + // ImagePreview renders through Dialog with class "image-preview-container" const previewContainer = document.querySelector('.image-preview-container')! expect(previewContainer)!.toBeInTheDocument() diff --git a/web/app/components/base/file-uploader/pdf-preview.tsx b/web/app/components/base/file-uploader/pdf-preview.tsx index c129f9cfb3..07fb2bab76 100644 --- a/web/app/components/base/file-uploader/pdf-preview.tsx +++ b/web/app/components/base/file-uploader/pdf-preview.tsx @@ -1,12 +1,11 @@ import type { FC } from 'react' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import { t } from 'i18next' -import * as React from 'react' import { useState } from 'react' -import { createPortal } from 'react-dom' import { useHotkeys } from 'react-hotkeys-hook' +import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { PdfHighlighter, PdfLoader } from './pdf-highlighter-adapter' @@ -20,6 +19,7 @@ const PdfPreview: FC<PdfPreviewProps> = ({ url, onCancel, }) => { + const { t } = useTranslation() const media = useBreakpoints() const [scale, setScale] = useState(1) const [position, setPosition] = useState({ x: 0, y: 0 }) @@ -42,87 +42,106 @@ const PdfPreview: FC<PdfPreviewProps> = ({ }) } - useHotkeys('esc', onCancel) useHotkeys('up', zoomIn) useHotkeys('down', zoomOut) - return createPortal( - <div - className={`fixed inset-0 z-1000 flex items-center justify-center bg-black/80 ${!isMobile && 'p-8'}`} - onClick={e => e.stopPropagation()} - tabIndex={-1} + const zoomOutLabel = t('operation.zoomOut', { ns: 'common' }) + const zoomInLabel = t('operation.zoomIn', { ns: 'common' }) + const cancelLabel = t('operation.cancel', { ns: 'common' }) + + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} + disablePointerDismissal > - <div - className="h-[95vh] max-h-full w-screen max-w-full overflow-hidden" - style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }} + <DialogContent + className={`inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 shadow-none! ${!isMobile ? 'p-8!' : 'p-0!'}`} + backdropClassName="bg-transparent!" > - <PdfLoader - workerSrc="/pdf.worker.min.mjs" - url={url} - beforeLoad={<div className="flex h-64 items-center justify-center"><Loading type="app" /></div>} + <div + aria-label={url} + tabIndex={-1} + onClick={e => e.stopPropagation()} + className="h-[95vh] max-h-full w-screen max-w-full overflow-hidden" + style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }} > - {(pdfDocument) => { - return ( - <PdfHighlighter - pdfDocument={pdfDocument} - enableAreaSelection={event => event.altKey} - scrollRef={noop} - onScrollChange={noop} - onSelectionFinished={() => null} - highlightTransform={() => { return <div /> }} - highlights={[]} - /> - ) - }} - </PdfLoader> - </div> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" - onClick={zoomOut} - > - <RiZoomOutLine className="h-4 w-4 text-gray-500" /> - </div> - )} - /> - <TooltipContent> - {t('operation.zoomOut', { ns: 'common' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" - onClick={zoomIn} - > - <RiZoomInLine className="h-4 w-4 text-gray-500" /> - </div> - )} - /> - <TooltipContent> - {t('operation.zoomIn', { ns: 'common' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]" - onClick={onCancel} - > - <RiCloseLine className="h-4 w-4 text-gray-500" /> - </div> - )} - /> - <TooltipContent> - {t('operation.cancel', { ns: 'common' })} - </TooltipContent> - </Tooltip> - </div>, - document.body, + <PdfLoader + workerSrc="/pdf.worker.min.mjs" + url={url} + beforeLoad={<div className="flex h-64 items-center justify-center"><Loading type="app" /></div>} + > + {(pdfDocument) => { + return ( + <PdfHighlighter + pdfDocument={pdfDocument} + enableAreaSelection={event => event.altKey} + scrollRef={noop} + onScrollChange={noop} + onSelectionFinished={() => null} + highlightTransform={() => { return <div /> }} + highlights={[]} + /> + ) + }} + </PdfLoader> + </div> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={zoomOutLabel} + className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" + onClick={zoomOut} + > + <RiZoomOutLine className="h-4 w-4 text-gray-500" /> + </button> + )} + /> + <TooltipContent> + {zoomOutLabel} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={zoomInLabel} + className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" + onClick={zoomIn} + > + <RiZoomInLine className="h-4 w-4 text-gray-500" /> + </button> + )} + /> + <TooltipContent> + {zoomInLabel} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={cancelLabel} + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]" + onClick={onCancel} + > + <RiCloseLine className="h-4 w-4 text-gray-500" /> + </button> + )} + /> + <TooltipContent> + {cancelLabel} + </TooltipContent> + </Tooltip> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/base/file-uploader/video-preview.tsx b/web/app/components/base/file-uploader/video-preview.tsx index ec1644400f..986bf61dce 100644 --- a/web/app/components/base/file-uploader/video-preview.tsx +++ b/web/app/components/base/file-uploader/video-preview.tsx @@ -1,7 +1,5 @@ import type { FC } from 'react' -import * as React from 'react' -import { createPortal } from 'react-dom' -import { useHotkeys } from 'react-hotkeys-hook' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' type VideoPreviewProps = { url: string @@ -13,31 +11,40 @@ const VideoPreview: FC<VideoPreviewProps> = ({ title, onCancel, }) => { - useHotkeys('esc', onCancel) - - return createPortal( - <div - className="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8" - onClick={e => e.stopPropagation()} - tabIndex={-1} + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} + disablePointerDismissal > - <div> - <video controls title={title} autoPlay={false} preload="metadata"> - <source - type="video/mp4" - src={url} - className="max-h-full max-w-full" - /> - </video> - </div> - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" - onClick={onCancel} + <DialogContent + className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!" + backdropClassName="bg-transparent!" > - <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="video-preview-close-btn" /> - </div> - </div>, - document.body, + <div + aria-label={title} + tabIndex={-1} + onClick={e => e.stopPropagation()} + > + <video controls title={title} autoPlay={false} preload="metadata"> + <source + type="video/mp4" + src={url} + className="max-h-full max-w-full" + /> + </video> + </div> + <div + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" + onClick={onCancel} + > + <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="video-preview-close-btn" /> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/base/float-right-container/__tests__/index.spec.tsx b/web/app/components/base/float-right-container/__tests__/index.spec.tsx index ee820230d8..236a30dd20 100644 --- a/web/app/components/base/float-right-container/__tests__/index.spec.tsx +++ b/web/app/components/base/float-right-container/__tests__/index.spec.tsx @@ -153,10 +153,11 @@ describe('FloatRightContainer', () => { ) const dialog = await screen.findByRole('dialog') - expect(dialog).toHaveClass('custom-dialog-class') + expect(document.querySelector('.custom-dialog-class')).toBeInTheDocument() const panel = document.querySelector('.custom-panel-class') expect(panel).toBeInTheDocument() + expect(dialog).toHaveClass('custom-panel-class') }) }) diff --git a/web/app/components/base/fullscreen-modal/__tests__/index.spec.tsx b/web/app/components/base/fullscreen-modal/__tests__/index.spec.tsx deleted file mode 100644 index f238ea79f7..0000000000 --- a/web/app/components/base/fullscreen-modal/__tests__/index.spec.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' -import FullScreenModal from '../index' - -describe('FullScreenModal Component', () => { - it('should not render anything when open is false', () => { - render( - <FullScreenModal open={false}> - <div data-testid="modal-content">Content</div> - </FullScreenModal>, - ) - expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument() - }) - - it('should render content when open is true', async () => { - render( - <FullScreenModal open={true}> - <div data-testid="modal-content">Content</div> - </FullScreenModal>, - ) - expect(await screen.findByTestId('modal-content')).toBeInTheDocument() - }) - - it('should not crash when provided with title and description props', async () => { - await act(async () => { - render( - <FullScreenModal - open={true} - title="My Title" - description="My Description" - > - Content - </FullScreenModal>, - ) - }) - }) - - describe('Props Handling', () => { - it('should apply wrapperClassName to the dialog root', async () => { - render( - <FullScreenModal - open={true} - wrapperClassName="custom-wrapper-class" - > - Content - </FullScreenModal>, - ) - - await screen.findByRole('dialog') - const element = document.querySelector('.custom-wrapper-class') - expect(element).toBeInTheDocument() - expect(element).toHaveClass('relative', 'z-50') - }) - - it('should apply className to the inner panel', async () => { - await act(async () => { - render( - <FullScreenModal - open={true} - className="custom-panel-class" - > - Content - </FullScreenModal>, - ) - }) - const panel = document.querySelector('.custom-panel-class') - expect(panel).toBeInTheDocument() - expect(panel).toHaveClass('h-full') - }) - - it('should handle overflowVisible prop', async () => { - const { rerender } = await act(async () => { - return render( - <FullScreenModal - open={true} - overflowVisible={true} - className="target-panel" - > - Content - </FullScreenModal>, - ) - }) - let panel = document.querySelector('.target-panel') - expect(panel).toHaveClass('overflow-visible') - expect(panel).not.toHaveClass('overflow-hidden') - - await act(async () => { - rerender( - <FullScreenModal - open={true} - overflowVisible={false} - className="target-panel" - > - Content - </FullScreenModal>, - ) - }) - panel = document.querySelector('.target-panel') - expect(panel).toHaveClass('overflow-hidden') - expect(panel).not.toHaveClass('overflow-visible') - }) - - it('should render close button when closable is true', async () => { - await act(async () => { - render( - <FullScreenModal open={true} closable={true}> - Content - </FullScreenModal>, - ) - }) - const closeButton = document.querySelector('.bg-components-button-tertiary-bg') - expect(closeButton).toBeInTheDocument() - }) - - it('should not render close button when closable is false', async () => { - await act(async () => { - render( - <FullScreenModal open={true} closable={false}> - Content - </FullScreenModal>, - ) - }) - const closeButton = document.querySelector('.bg-components-button-tertiary-bg') - expect(closeButton).not.toBeInTheDocument() - }) - }) - - describe('Interactions', () => { - it('should call onClose when close button is clicked', async () => { - const user = userEvent.setup() - const onClose = vi.fn() - - render( - <FullScreenModal open={true} closable={true} onClose={onClose}> - Content - </FullScreenModal>, - ) - - const closeBtn = document.querySelector('.bg-components-button-tertiary-bg') - expect(closeBtn).toBeInTheDocument() - - await user.click(closeBtn!) - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should call onClose when clicking the backdrop', async () => { - const user = userEvent.setup() - const onClose = vi.fn() - - render( - <FullScreenModal open={true} onClose={onClose}> - <div data-testid="inner">Content</div> - </FullScreenModal>, - ) - - const dialog = document.querySelector('.relative.z-50') - if (dialog) { - await user.click(dialog) - expect(onClose).toHaveBeenCalled() - } - else { - throw new Error('Dialog root not found') - } - }) - - it('should call onClose when Escape key is pressed', async () => { - const user = userEvent.setup() - const onClose = vi.fn() - - render( - <FullScreenModal open={true} onClose={onClose}> - Content - </FullScreenModal>, - ) - - await user.keyboard('{Escape}') - expect(onClose).toHaveBeenCalled() - }) - - it('should not call onClose when clicking inside the content', async () => { - const user = userEvent.setup() - const onClose = vi.fn() - - render( - <FullScreenModal open={true} onClose={onClose}> - <div className="bg-background-default-subtle"> - <button>Action</button> - </div> - </FullScreenModal>, - ) - - const innerButton = screen.getByRole('button', { name: 'Action' }) - await user.click(innerButton) - expect(onClose).not.toHaveBeenCalled() - - const contentPanel = document.querySelector('.bg-background-default-subtle') - await act(async () => { - fireEvent.click(contentPanel!) - }) - expect(onClose).not.toHaveBeenCalled() - }) - }) - - describe('Default Props', () => { - it('should not throw if onClose is not provided', async () => { - const user = userEvent.setup() - render(<FullScreenModal open={true} closable={true}>Content</FullScreenModal>) - - const closeButton = document.querySelector('.bg-components-button-tertiary-bg') - await user.click(closeButton!) - }) - }) -}) diff --git a/web/app/components/base/fullscreen-modal/index.stories.tsx b/web/app/components/base/fullscreen-modal/index.stories.tsx deleted file mode 100644 index 3285b1c4ea..0000000000 --- a/web/app/components/base/fullscreen-modal/index.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useState } from 'react' -import FullScreenModal from '.' - -const meta = { - title: 'Base/Feedback/FullScreenModal', - component: FullScreenModal, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: 'Backdrop-blurred fullscreen modal. Supports close button, custom content, and optional overflow visibility.', - }, - }, - }, - tags: ['autodocs'], -} satisfies Meta<typeof FullScreenModal> - -export default meta -type Story = StoryObj<typeof meta> - -const ModalDemo = (props: React.ComponentProps<typeof FullScreenModal>) => { - const [open, setOpen] = useState(false) - - return ( - <div className="flex h-[360px] items-center justify-center bg-background-default-subtle"> - <button - type="button" - className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700" - onClick={() => setOpen(true)} - > - Launch full-screen modal - </button> - - <FullScreenModal - {...props} - open={open} - onClose={() => setOpen(false)} - closable - > - <div className="flex h-full flex-col bg-background-default-subtle"> - <div className="flex h-16 items-center justify-center border-b border-divider-subtle text-lg font-semibold text-text-primary"> - Full-screen experience - </div> - <div className="flex flex-1 items-center justify-center text-sm text-text-secondary"> - Place dashboards, flow builders, or immersive previews here. - </div> - </div> - </FullScreenModal> - </div> - ) -} - -export const Playground: Story = { - render: args => <ModalDemo {...args} />, - args: { - open: false, - }, -} diff --git a/web/app/components/base/fullscreen-modal/index.tsx b/web/app/components/base/fullscreen-modal/index.tsx deleted file mode 100644 index 95f593ec3d..0000000000 --- a/web/app/components/base/fullscreen-modal/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' -import { cn } from '@langgenius/dify-ui/cn' -import { RiCloseLargeLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' - -type IModal = { - className?: string - wrapperClassName?: string - open: boolean - onClose?: () => void - title?: React.ReactNode - description?: React.ReactNode - children?: React.ReactNode - closable?: boolean - overflowVisible?: boolean -} - -export default function FullScreenModal({ - className, - wrapperClassName, - open, - onClose = noop, - children, - closable = false, - overflowVisible = false, -}: IModal) { - return ( - <Transition show={open} appear> - <Dialog as="div" className={cn('relative z-50', wrapperClassName)} onClose={onClose}> - <TransitionChild> - <div className={cn('fixed inset-0 bg-background-overlay-backdrop backdrop-blur-[6px]', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')} /> - </TransitionChild> - - <div - className="fixed inset-0 h-screen w-screen p-4" - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - }} - > - <div className="relative h-full w-full rounded-2xl border border-effects-highlight bg-background-default-subtle"> - <TransitionChild> - <DialogPanel className={cn('h-full', overflowVisible ? 'overflow-visible' : 'overflow-hidden', 'duration-100 ease-in data-closed:scale-95 data-closed:opacity-0', 'data-enter:scale-100 data-enter:opacity-100', 'data-enter:scale-95 data-leave:opacity-0', className)}> - {closable - && ( - <div - className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center - rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover" - onClick={(e) => { - e.stopPropagation() - onClose() - }} - > - <RiCloseLargeLine className="h-3.5 w-3.5 text-components-button-tertiary-text" /> - </div> - )} - {children} - </DialogPanel> - </TransitionChild> - </div> - </div> - </Dialog> - </Transition> - ) -} diff --git a/web/app/components/base/image-gallery/__tests__/index.spec.tsx b/web/app/components/base/image-gallery/__tests__/index.spec.tsx index 4975ac8f1c..92ff807d75 100644 --- a/web/app/components/base/image-gallery/__tests__/index.spec.tsx +++ b/web/app/components/base/image-gallery/__tests__/index.spec.tsx @@ -113,7 +113,7 @@ describe('ImageGallery', () => { await user.click(getImages(container)[0]!) expect(screen.queryByTestId('image-preview-container'))!.toBeInTheDocument() - await user.keyboard('{Escape}') + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) await waitFor(() => { expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument() diff --git a/web/app/components/base/image-uploader/__tests__/audio-preview.spec.tsx b/web/app/components/base/image-uploader/__tests__/audio-preview.spec.tsx index 0c92afba0a..da15c1c339 100644 --- a/web/app/components/base/image-uploader/__tests__/audio-preview.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/audio-preview.spec.tsx @@ -43,7 +43,7 @@ describe('AudioPreview', () => { render(<AudioPreview {...defaultProps} />) const overlay = screen.getByTestId('audio-preview-overlay') expect(overlay).toBeInTheDocument() - expect(overlay.parentElement).toBe(document.body) + expect(overlay.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body) }) }) diff --git a/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx b/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx index b2a2952bf8..48db2df3d2 100644 --- a/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ImagePreview from '../image-preview' @@ -89,7 +89,7 @@ describe('ImagePreview', () => { const overlay = getOverlay() expect(overlay).toBeInTheDocument() - expect(overlay?.parentElement).toBe(document.body) + expect(overlay.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body) expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', 'https://example.com/image.png') }) @@ -108,7 +108,6 @@ describe('ImagePreview', () => { describe('Hotkeys', () => { it('should trigger esc/left/right handlers from keyboard', async () => { - const user = userEvent.setup() const onCancel = vi.fn() const onPrev = vi.fn() const onNext = vi.fn() @@ -122,7 +121,9 @@ describe('ImagePreview', () => { />, ) - await user.keyboard('{Escape}{ArrowLeft}{ArrowRight}') + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + fireEvent.keyDown(document, { key: 'ArrowLeft', code: 'ArrowLeft' }) + fireEvent.keyDown(document, { key: 'ArrowRight', code: 'ArrowRight' }) expect(onCancel).toHaveBeenCalledTimes(1) expect(onPrev).toHaveBeenCalledTimes(1) @@ -130,7 +131,6 @@ describe('ImagePreview', () => { }) it('should zoom in and out from keyboard up/down hotkeys', async () => { - const user = userEvent.setup() render( <ImagePreview url="https://example.com/image.png" @@ -140,12 +140,12 @@ describe('ImagePreview', () => { ) const image = screen.getByRole('img', { name: 'Preview Image' }) - await user.keyboard('{ArrowUp}') + fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' }) await waitFor(() => { expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' }) }) - await user.keyboard('{ArrowDown}') + fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown' }) await waitFor(() => { expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' }) }) diff --git a/web/app/components/base/image-uploader/__tests__/video-preview.spec.tsx b/web/app/components/base/image-uploader/__tests__/video-preview.spec.tsx index f56a4b82e4..90d7e1ea8f 100644 --- a/web/app/components/base/image-uploader/__tests__/video-preview.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/video-preview.spec.tsx @@ -51,7 +51,7 @@ describe('VideoPreview', () => { const overlay = getOverlay() expect(overlay).toBeInTheDocument() - expect(overlay.parentElement).toBe(document.body) + expect(overlay.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body) }) }) diff --git a/web/app/components/base/image-uploader/audio-preview.tsx b/web/app/components/base/image-uploader/audio-preview.tsx index aa87d9d04e..4f7dd83400 100644 --- a/web/app/components/base/image-uploader/audio-preview.tsx +++ b/web/app/components/base/image-uploader/audio-preview.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import { createPortal } from 'react-dom' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' type AudioPreviewProps = { url: string @@ -11,26 +11,42 @@ const AudioPreview: FC<AudioPreviewProps> = ({ title, onCancel, }) => { - return createPortal( - <div className="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8" onClick={e => e.stopPropagation()} data-testid="audio-preview-overlay"> - <div> - <audio controls title={title} autoPlay={false} preload="metadata" data-testid="audio-element"> - <source - type="audio/mpeg" - src={url} - className="max-h-full max-w-full" - /> - </audio> - </div> - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" - onClick={onCancel} - data-testid="close-preview" + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} + disablePointerDismissal + > + <DialogContent + className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!" + backdropClassName="bg-transparent!" > - <span className="i-ri-close-line h-4 w-4 text-gray-500" /> - </div> - </div>, - document.body, + <div + aria-label={title} + data-testid="audio-preview-overlay" + tabIndex={-1} + onClick={e => e.stopPropagation()} + > + <audio controls title={title} autoPlay={false} preload="metadata" data-testid="audio-element"> + <source + type="audio/mpeg" + src={url} + className="max-h-full max-w-full" + /> + </audio> + </div> + <div + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" + onClick={onCancel} + data-testid="close-preview" + > + <span className="i-ri-close-line h-4 w-4 text-gray-500" /> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index 354fb5ff2a..071188bf12 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { noop } from 'es-toolkit/function' -import { t } from 'i18next' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' import { useHotkeys } from 'react-hotkeys-hook' +import { useTranslation } from 'react-i18next' import { downloadUrl } from '@/utils/download' type ImagePreviewProps = { @@ -33,6 +33,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ onPrev, onNext, }) => { + const { t } = useTranslation() const [scale, setScale] = useState(1) const [position, setPosition] = useState({ x: 0, y: 0 }) const [isDragging, setIsDragging] = useState(false) @@ -119,7 +120,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ } } shareImage() - }, [title, url]) + }, [t, title, url]) const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => { if (e.deltaY < 0) @@ -167,131 +168,161 @@ const ImagePreview: FC<ImagePreviewProps> = ({ } }, [handleMouseUp]) - useHotkeys('esc', onCancel) useHotkeys('up', zoomIn) useHotkeys('down', zoomOut) useHotkeys('left', onPrev || noop) useHotkeys('right', onNext || noop) - return createPortal( - <div - className="image-preview-container fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8" - onClick={e => e.stopPropagation()} - onWheel={handleWheel} - onMouseDown={handleMouseDown} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} - style={{ cursor: scale > 1 ? 'move' : 'default' }} - tabIndex={-1} - data-testid="image-preview-container" + const copyImageLabel = t('operation.copyImage', { ns: 'common' }) + const zoomOutLabel = t('operation.zoomOut', { ns: 'common' }) + const zoomInLabel = t('operation.zoomIn', { ns: 'common' }) + const downloadLabel = t('operation.download', { ns: 'common' }) + const openInNewTabLabel = t('operation.openInNewTab', { ns: 'common' }) + const cancelLabel = t('operation.cancel', { ns: 'common' }) + + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} + disablePointerDismissal > - { } - { } - <img - ref={imgRef} - alt={title} - src={isBase64(url) ? `data:image/png;base64,${url}` : url} - className="max-h-full max-w-full" - style={{ - transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`, - transition: isDragging ? 'none' : 'transform 0.2s ease-in-out', - }} - data-testid="image-preview-image" - /> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-48 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" - onClick={imageCopy} - > - {isCopied - ? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" data-testid="image-preview-copied-icon" /> - : <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" data-testid="image-preview-copy-button" />} - </div> - )} - /> - <TooltipContent> - {t('operation.copyImage', { ns: 'common' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" - onClick={zoomOut} - > - <span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-out-button" /> - </div> - )} - /> - <TooltipContent> - {t('operation.zoomOut', { ns: 'common' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-32 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" - onClick={zoomIn} - > - <span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-in-button" /> - </div> - )} - /> - <TooltipContent> - {t('operation.zoomIn', { ns: 'common' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" - onClick={downloadImage} - > - <span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" data-testid="image-preview-download-button" /> - </div> - )} - /> - <TooltipContent> - {t('operation.download', { ns: 'common' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" - onClick={openInNewTab} - > - <span className="i-ri-add-box-line h-4 w-4 text-gray-500" data-testid="image-preview-open-in-tab-button" /> - </div> - )} - /> - <TooltipContent> - {t('operation.openInNewTab', { ns: 'common' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]" - onClick={onCancel} - > - <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" /> - </div> - )} - /> - <TooltipContent> - {t('operation.cancel', { ns: 'common' })} - </TooltipContent> - </Tooltip> - </div>, - document.body, + <DialogContent + className="image-preview-container inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!" + backdropClassName="bg-transparent!" + > + <div + aria-label={title} + data-testid="image-preview-container" + tabIndex={-1} + className="flex h-full w-full items-center justify-center" + onClick={e => e.stopPropagation()} + onWheel={handleWheel} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + style={{ cursor: scale > 1 ? 'move' : 'default' }} + > + <img + ref={imgRef} + alt={title} + src={isBase64(url) ? `data:image/png;base64,${url}` : url} + className="max-h-full max-w-full" + style={{ + transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`, + transition: isDragging ? 'none' : 'transform 0.2s ease-in-out', + }} + data-testid="image-preview-image" + /> + </div> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={copyImageLabel} + className="absolute top-6 right-48 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" + onClick={imageCopy} + > + {isCopied + ? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" data-testid="image-preview-copied-icon" /> + : <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" data-testid="image-preview-copy-button" />} + </button> + )} + /> + <TooltipContent> + {copyImageLabel} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={zoomOutLabel} + className="absolute top-6 right-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" + onClick={zoomOut} + > + <span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-out-button" /> + </button> + )} + /> + <TooltipContent> + {zoomOutLabel} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={zoomInLabel} + className="absolute top-6 right-32 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" + onClick={zoomIn} + > + <span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-in-button" /> + </button> + )} + /> + <TooltipContent> + {zoomInLabel} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={downloadLabel} + className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" + onClick={downloadImage} + > + <span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" data-testid="image-preview-download-button" /> + </button> + )} + /> + <TooltipContent> + {downloadLabel} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={openInNewTabLabel} + className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" + onClick={openInNewTab} + > + <span className="i-ri-add-box-line h-4 w-4 text-gray-500" data-testid="image-preview-open-in-tab-button" /> + </button> + )} + /> + <TooltipContent> + {openInNewTabLabel} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={( + <button + type="button" + aria-label={cancelLabel} + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]" + onClick={onCancel} + > + <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" /> + </button> + )} + /> + <TooltipContent> + {cancelLabel} + </TooltipContent> + </Tooltip> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/base/image-uploader/video-preview.tsx b/web/app/components/base/image-uploader/video-preview.tsx index d2a0179b57..8d5cb4dab3 100644 --- a/web/app/components/base/image-uploader/video-preview.tsx +++ b/web/app/components/base/image-uploader/video-preview.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import { createPortal } from 'react-dom' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' type VideoPreviewProps = { url: string @@ -11,25 +11,41 @@ const VideoPreview: FC<VideoPreviewProps> = ({ title, onCancel, }) => { - return createPortal( - <div className="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8" onClick={e => e.stopPropagation()} data-testid="video-preview"> - <div> - <video controls title={title} autoPlay={false} preload="metadata" data-testid="video-element"> - <source - type="video/mp4" - src={url} - className="max-h-full max-w-full" - /> - </video> - </div> - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" - onClick={onCancel} + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} + disablePointerDismissal + > + <DialogContent + className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!" + backdropClassName="bg-transparent!" > - <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-button" /> - </div> - </div>, - document.body, + <div + aria-label={title} + data-testid="video-preview" + tabIndex={-1} + onClick={e => e.stopPropagation()} + > + <video controls title={title} autoPlay={false} preload="metadata" data-testid="video-element"> + <source + type="video/mp4" + src={url} + className="max-h-full max-w-full" + /> + </video> + </div> + <div + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" + onClick={onCancel} + > + <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-button" /> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/base/modal-like-wrap/__tests__/index.spec.tsx b/web/app/components/base/modal-like-wrap/__tests__/index.spec.tsx deleted file mode 100644 index dc7b0758fa..0000000000 --- a/web/app/components/base/modal-like-wrap/__tests__/index.spec.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' -import ModalLikeWrap from '..' - -describe('ModalLikeWrap', () => { - const defaultProps = { - title: 'Test Title', - onClose: vi.fn(), - onConfirm: vi.fn(), - children: <div>Test Content</div>, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Render', () => { - it('renders title and content correctly', () => { - render(<ModalLikeWrap {...defaultProps} />) - - expect(screen.getByText('Test Title')).toBeInTheDocument() - expect(screen.getByText('Test Content')).toBeInTheDocument() - }) - - it('renders beforeHeader if provided', () => { - const beforeHeader = <div data-testid="before-header">Before Header</div> - render(<ModalLikeWrap {...defaultProps} beforeHeader={beforeHeader} />) - - expect(screen.getByTestId('before-header')).toBeInTheDocument() - expect(screen.getByText('Before Header')).toBeInTheDocument() - }) - }) - - describe('Interactions', () => { - it('calls onClose when close icon is clicked', async () => { - render(<ModalLikeWrap {...defaultProps} />) - - const closeBtn = screen.getByTestId('modal-close-btn') - expect(closeBtn).toBeInTheDocument() - - await act(async () => { - fireEvent.click(closeBtn) - }) - - expect(defaultProps.onClose).toHaveBeenCalledTimes(1) - }) - - it('calls onClose when Cancel button is clicked', async () => { - render(<ModalLikeWrap {...defaultProps} />) - - const cancelBtn = screen.getByText('common.operation.cancel') - await act(async () => { - fireEvent.click(cancelBtn) - }) - - expect(defaultProps.onClose).toHaveBeenCalled() - }) - - it('calls onConfirm when Save button is clicked', async () => { - render(<ModalLikeWrap {...defaultProps} />) - - const saveBtn = screen.getByText('common.operation.save') - await act(async () => { - fireEvent.click(saveBtn) - }) - - expect(defaultProps.onConfirm).toHaveBeenCalled() - }) - }) - - describe('Props', () => { - it('hides close icon when hideCloseBtn is true', () => { - render(<ModalLikeWrap {...defaultProps} hideCloseBtn={true} />) - - const closeBtn = document.querySelector('.remixicon') - expect(closeBtn).not.toBeInTheDocument() - }) - - it('applies custom className', () => { - const { container } = render(<ModalLikeWrap {...defaultProps} className="custom-class" />) - - expect(container.firstChild).toHaveClass('custom-class') - }) - }) -}) diff --git a/web/app/components/base/modal-like-wrap/index.stories.tsx b/web/app/components/base/modal-like-wrap/index.stories.tsx deleted file mode 100644 index 38d969b2ce..0000000000 --- a/web/app/components/base/modal-like-wrap/index.stories.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import ModalLikeWrap from '.' - -const meta = { - title: 'Base/Feedback/ModalLikeWrap', - component: ModalLikeWrap, - parameters: { - layout: 'centered', - docs: { - description: { - component: 'Compact “modal-like” card used in wizards. Provides header actions, optional back slot, and confirm/cancel buttons.', - }, - }, - }, - tags: ['autodocs'], - argTypes: { - title: { - control: 'text', - description: 'Header title text.', - }, - className: { - control: 'text', - description: 'Additional classes on the wrapper.', - }, - beforeHeader: { - control: false, - description: 'Slot rendered before the header (commonly a back link).', - }, - hideCloseBtn: { - control: 'boolean', - description: 'Hides the top-right close icon when true.', - }, - children: { - control: false, - }, - onClose: { - control: false, - }, - onConfirm: { - control: false, - }, - }, - args: { - title: 'Create dataset field', - hideCloseBtn: false, - onClose: () => console.log('close'), - onConfirm: () => console.log('confirm'), - children: null, - }, -} satisfies Meta<typeof ModalLikeWrap> - -export default meta -type Story = StoryObj<typeof meta> - -const BaseContent = () => ( - <div className="space-y-3 text-sm text-gray-600"> - <p> - Describe the new field your dataset should collect. Provide a clear label and optional helper text. - </p> - <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500"> - Form inputs would be placed here in the real flow. - </div> - </div> -) - -export const Default: Story = { - render: args => ( - <ModalLikeWrap {...args}> - <BaseContent /> - </ModalLikeWrap> - ), - args: { - children: null, - }, -} - -export const WithBackLink: Story = { - render: args => ( - <ModalLikeWrap - {...args} - hideCloseBtn - beforeHeader={( - <button - className="mb-1 flex items-center gap-1 text-xs font-medium text-text-accent uppercase" - onClick={() => console.log('back')} - > - <span className="inline-block h-4 w-4 rounded-sm bg-text-accent/10 text-center text-[10px] leading-4 text-text-accent">{'<'}</span> - Back - </button> - )} - > - <BaseContent /> - </ModalLikeWrap> - ), - args: { - title: 'Select metadata type', - children: null, - }, - parameters: { - docs: { - description: { - story: 'Demonstrates feeding content into `beforeHeader` while hiding the close button.', - }, - }, - }, -} - -export const CustomWidth: Story = { - render: args => ( - <ModalLikeWrap - {...args} - className="w-[420px]" - > - <BaseContent /> - <div className="mt-4 rounded-md bg-blue-50 p-3 text-xs text-blue-600"> - Tip: metadata keys may only include letters, numbers, and underscores. - </div> - </ModalLikeWrap> - ), - args: { - title: 'Advanced configuration', - children: null, - }, - parameters: { - docs: { - description: { - story: 'Applies extra width and helper messaging to emulate configuration panels.', - }, - }, - }, -} diff --git a/web/app/components/base/modal-like-wrap/index.tsx b/web/app/components/base/modal-like-wrap/index.tsx deleted file mode 100644 index 1c049eb5f4..0000000000 --- a/web/app/components/base/modal-like-wrap/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client' -import type { FC } from 'react' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useTranslation } from 'react-i18next' - -type Props = { - title: string - className?: string - beforeHeader?: React.ReactNode - onClose: () => void - hideCloseBtn?: boolean - onConfirm: () => void - children: React.ReactNode -} - -const ModalLikeWrap: FC<Props> = ({ - title, - className, - beforeHeader, - children, - onClose, - hideCloseBtn, - onConfirm, -}) => { - const { t } = useTranslation() - - return ( - <div className={cn('w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-3 pt-3.5 pb-4 shadow-xl', className)}> - {beforeHeader || null} - <div className="mb-1 flex h-6 items-center justify-between"> - <div className="system-xl-semibold text-text-primary">{title}</div> - {!hideCloseBtn && ( - <div - className="cursor-pointer p-1.5 text-text-tertiary" - onClick={onClose} - > - <span className="i-ri-close-line size-4" data-testid="modal-close-btn" /> - </div> - )} - </div> - <div className="mt-2">{children}</div> - <div className="mt-4 flex justify-end"> - <Button - className="mr-2" - onClick={onClose} - > - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button - onClick={onConfirm} - variant="primary" - > - {t('operation.save', { ns: 'common' })} - </Button> - </div> - </div> - ) -} - -export default React.memo(ModalLikeWrap) diff --git a/web/app/components/base/modal/__tests__/index.spec.tsx b/web/app/components/base/modal/__tests__/index.spec.tsx deleted file mode 100644 index 4705d0defd..0000000000 --- a/web/app/components/base/modal/__tests__/index.spec.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' -import Modal from '..' - -describe('Modal', () => { - describe('Render', () => { - it('should not render content when isShow is false', () => { - render( - <Modal isShow={false} title="Test Modal"> - <div>Modal Content</div> - </Modal>, - ) - - expect(screen.queryByText('Test Modal')).not.toBeInTheDocument() - expect(screen.queryByText('Modal Content')).not.toBeInTheDocument() - }) - - it('should render content when isShow is true', async () => { - await act(async () => { - render( - <Modal isShow={true} title="Test Modal"> - <div>Modal Content</div> - </Modal>, - ) - }) - - expect(screen.getByText('Test Modal')).toBeInTheDocument() - expect(screen.getByText('Modal Content')).toBeInTheDocument() - }) - - it('should render description when provided', async () => { - await act(async () => { - render( - <Modal isShow={true} title="Test Modal" description="Test Description"> - <div>Content</div> - </Modal>, - ) - }) - - expect(screen.getByText('Test Description')).toBeInTheDocument() - }) - }) - - describe('Interaction', () => { - it('should call onClose when close button is clicked', async () => { - const handleClose = vi.fn() - await act(async () => { - render( - <Modal isShow={true} title="Test Modal" closable={true} onClose={handleClose}> - <div>Content</div> - </Modal>, - ) - }) - - const closeButton = screen.getByTestId('modal-close-button') - expect(closeButton).toBeInTheDocument() - await act(async () => { - fireEvent.click(closeButton!) - }) - expect(handleClose).toHaveBeenCalledTimes(1) - }) - - it('should prevent propagation when clicking the scrollable container', async () => { - await act(async () => { - render( - <Modal isShow={true} title="Test Modal"> - <div>Content</div> - </Modal>, - ) - }) - - const wrapper = document.querySelector('.overflow-y-auto') - expect(wrapper).toBeInTheDocument() - - const event = new MouseEvent('click', { bubbles: true, cancelable: true }) - const stopPropagationSpy = vi.spyOn(event, 'stopPropagation') - const preventDefaultSpy = vi.spyOn(event, 'preventDefault') - - await act(async () => { - wrapper!.dispatchEvent(event) - }) - - expect(stopPropagationSpy).toHaveBeenCalled() - expect(preventDefaultSpy).toHaveBeenCalled() - }) - - it('should handle clickOutsideNotClose prop', async () => { - const handleClose = vi.fn() - await act(async () => { - render( - <Modal isShow={true} title="Test Modal" clickOutsideNotClose={true} onClose={handleClose}> - <div>Content</div> - </Modal>, - ) - }) - - await act(async () => { - fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' }) - }) - - expect(handleClose).not.toHaveBeenCalled() - }) - }) - - describe('Props', () => { - it('should apply custom className to the panel', async () => { - await act(async () => { - render( - <Modal isShow={true} title="Test Modal" className="custom-panel-class"> - <div>Content</div> - </Modal>, - ) - }) - - const panel = screen.getByText('Test Modal').parentElement - expect(panel).toHaveClass('custom-panel-class') - }) - - it('should apply wrapperClassName and containerClassName', async () => { - await act(async () => { - render( - <Modal - isShow={true} - title="Test Modal" - wrapperClassName="custom-wrapper" - containerClassName="custom-container" - > - <div>Content</div> - </Modal>, - ) - }) - - const dialog = document.querySelector('.custom-wrapper') - expect(dialog).toBeInTheDocument() - const container = document.querySelector('.custom-container') - expect(container).toBeInTheDocument() - }) - - it('should apply overlayOpacity background when overlayOpacity is true', async () => { - await act(async () => { - render( - <Modal isShow={true} title="Test Modal" overlayOpacity={true}> - <div>Content</div> - </Modal>, - ) - }) - - const overlay = document.querySelector('.bg-workflow-canvas-canvas-overlay') - expect(overlay).toBeInTheDocument() - }) - - it('should toggle overflow-visible class based on overflowVisible prop', async () => { - const { rerender } = render( - <Modal isShow={true} title="Test Modal" overflowVisible={true}> - <div>Content</div> - </Modal>, - ) - - let panel = screen.getByText('Test Modal').parentElement - expect(panel).toHaveClass('overflow-visible') - - await act(async () => { - rerender( - <Modal isShow={true} title="Test Modal" overflowVisible={false}> - <div>Content</div> - </Modal>, - ) - }) - panel = screen.getByText('Test Modal').parentElement - expect(panel).toHaveClass('overflow-hidden') - }) - }) -}) diff --git a/web/app/components/base/modal/index.stories.tsx b/web/app/components/base/modal/index.stories.tsx deleted file mode 100644 index 33d0366324..0000000000 --- a/web/app/components/base/modal/index.stories.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useEffect, useState } from 'react' -import Modal from '.' - -const meta = { - title: 'Base/Feedback/Modal', - component: Modal, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: 'Lightweight modal wrapper with optional header/description and close icon.', - }, - }, - }, - tags: ['autodocs'], - argTypes: { - className: { - control: 'text', - description: 'Extra classes applied to the modal panel.', - }, - wrapperClassName: { - control: 'text', - description: 'Additional wrapper classes for the dialog.', - }, - isShow: { - control: 'boolean', - description: 'Controls whether the modal is visible.', - }, - title: { - control: 'text', - description: 'Heading displayed at the top of the modal.', - }, - description: { - control: 'text', - description: 'Secondary text beneath the title.', - }, - closable: { - control: 'boolean', - description: 'Whether the close icon should be shown.', - }, - overflowVisible: { - control: 'boolean', - description: 'Allows content to overflow the modal panel.', - }, - onClose: { - control: false, - description: 'Callback invoked when the modal requests to close.', - }, - }, - args: { - isShow: false, - title: 'Create new API key', - description: 'Generate a scoped key for this workspace. You can revoke it at any time.', - closable: true, - }, -} satisfies Meta<typeof Modal> - -export default meta -type Story = StoryObj<typeof meta> - -const ModalDemo = (props: React.ComponentProps<typeof Modal>) => { - const [open, setOpen] = useState(props.isShow) - - useEffect(() => { - setOpen(props.isShow) - }, [props.isShow]) - - return ( - <div className="relative flex h-[480px] items-center justify-center bg-gray-100"> - <button - className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700" - onClick={() => setOpen(true)} - > - Show modal - </button> - - <Modal - {...props} - isShow={open} - onClose={() => { - props.onClose?.() - setOpen(false) - }} - > - <div className="mt-6 space-y-4 text-sm text-gray-600"> - <p> - Provide a descriptive name for this key so collaborators know its purpose. Restrict usage with scopes to limit access. - </p> - <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500"> - Form fields and validation messaging would appear here. This placeholder keeps the story lightweight. - </div> - </div> - <div className="mt-8 flex justify-end gap-3"> - <button - className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50" - onClick={() => setOpen(false)} - > - Cancel - </button> - <button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700"> - Create key - </button> - </div> - </Modal> - </div> - ) -} - -export const Default: Story = { - render: args => <ModalDemo {...args} />, -} - -export const OverflowVisible: Story = { - render: args => <ModalDemo {...args} />, - args: { - overflowVisible: true, - description: 'Demonstrates the modal configured to let the body content overflow.', - className: 'max-w-[540px]', - }, - parameters: { - docs: { - description: { - story: 'Shows the modal with `overflowVisible` enabled for content that needs to escape the panel bounds.', - }, - }, - }, -} diff --git a/web/app/components/base/modal/index.tsx b/web/app/components/base/modal/index.tsx deleted file mode 100644 index 8b4b6174a3..0000000000 --- a/web/app/components/base/modal/index.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @deprecated Use `@langgenius/dify-ui/dialog` instead. - * This component will be removed after migration is complete. - * See: https://github.com/langgenius/dify/issues/32767 - */ -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react' -import { cn } from '@langgenius/dify-ui/cn' -import { noop } from 'es-toolkit/function' -import { Fragment } from 'react' -// https://headlessui.com/react/dialog - -type IModal = { - className?: string - wrapperClassName?: string - containerClassName?: string - isShow: boolean - onClose?: () => void - title?: React.ReactNode - description?: React.ReactNode - children?: React.ReactNode - closable?: boolean - overflowVisible?: boolean - overlayOpacity?: boolean // For semi-transparent overlay instead of default - clickOutsideNotClose?: boolean // Prevent closing when clicking outside modal -} - -export default function Modal({ - className, - wrapperClassName, - containerClassName, - isShow, - onClose = noop, - title, - description, - children, - closable = false, - overflowVisible = false, - overlayOpacity = false, - clickOutsideNotClose = false, -}: IModal) { - return ( - <Transition appear show={isShow} as={Fragment}> - <Dialog as="div" className={cn('relative z-60', wrapperClassName)} onClose={clickOutsideNotClose ? noop : onClose}> - <TransitionChild> - <div className={cn('fixed inset-0', overlayOpacity ? 'bg-workflow-canvas-canvas-overlay' : 'bg-background-overlay', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')} /> - </TransitionChild> - <div - className="fixed inset-0 overflow-y-auto" - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - }} - > - <div className={cn('flex min-h-full items-center justify-center p-4 text-center', containerClassName)}> - <TransitionChild> - <DialogPanel className={cn('relative w-full max-w-[480px] rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all', overflowVisible ? 'overflow-visible' : 'overflow-hidden', 'duration-100 ease-in data-closed:scale-95 data-closed:opacity-0', 'data-enter:scale-100 data-enter:opacity-100', 'data-enter:scale-95 data-leave:opacity-0', className)}> - {!!title && ( - <DialogTitle - as="h3" - className="title-2xl-semi-bold text-text-primary" - > - {title} - </DialogTitle> - )} - {!!description && ( - <div className="mt-2 body-md-regular text-text-secondary"> - {description} - </div> - )} - {closable - && ( - <div className="absolute top-6 right-6 z-10 flex h-5 w-5 items-center justify-center rounded-2xl hover:cursor-pointer hover:bg-state-base-hover"> - <span - className="i-ri-close-line h-4 w-4 text-text-tertiary" - onClick={ - (e) => { - e.stopPropagation() - onClose() - } - } - data-testid="modal-close-button" - /> - </div> - )} - {children} - </DialogPanel> - </TransitionChild> - </div> - </div> - </Dialog> - </Transition> - ) -} diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx index 9a752062ea..f3fc1a605c 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx @@ -4,6 +4,7 @@ import type { WorkflowNodesMap } from '../workflow-variable-block/node' import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' import type { Type } from '@/app/components/workflow/nodes/llm/types' import type { ValueSelector, Var } from '@/app/components/workflow/types' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -11,7 +12,6 @@ import { useTranslation } from 'react-i18next' import { InputVarType } from '@/app/components/workflow/types' import ActionButton from '../../../action-button' import { VariableX } from '../../../icons/src/vender/workflow' -import Modal from '../../../modal' import InputField from './input-field' import VariableBlock from './variable-block' @@ -157,20 +157,24 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({ </div> {isShowEditModal && ( - <Modal - isShow - onClose={hideEditModal} - wrapperClassName="z-999" - className="max-w-[372px] p-0!" + <Dialog + open + onOpenChange={(open) => { + if (!open) + hideEditModal() + }} > - <InputField - nodeId={nodeId} - isEdit - payload={formInput} - onChange={handleChange} - onCancel={hideEditModal} - /> - </Modal> + <DialogContent className="w-full max-w-[372px] overflow-hidden! border-none p-0! text-left align-middle"> + + <InputField + nodeId={nodeId} + isEdit + payload={formInput} + onChange={handleChange} + onCancel={hideEditModal} + /> + </DialogContent> + </Dialog> )} </div> ) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx index 7511406718..e0fcd3bdba 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx @@ -82,7 +82,9 @@ const InputField: React.FC<InputFieldProps> = ({ }, [handleSave]) return ( - <div className="w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]"> + <div className="w-[372px] + rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]" + > <div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div> <div className="mt-3"> <div className="system-xs-medium text-text-secondary"> diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx index e3fba27cf2..114d1d7802 100644 --- a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { LexicalCommand } from 'lexical' +import type { ShortcutPopupInsertHandler } from '../index' import { LexicalComposer } from '@lexical/react/LexicalComposer' import { ContentEditable } from '@lexical/react/LexicalContentEditable' import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' @@ -50,7 +50,7 @@ const CONTENT_EDITABLE_ID = 'ce' type MinimalEditorProps = { withContainer?: boolean hotkey?: string | string[] | string[][] | ((e: KeyboardEvent) => boolean) - children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: unknown[]) => void) => React.ReactNode) + children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode) className?: string onOpen?: () => void onClose?: () => void @@ -316,7 +316,7 @@ describe('ShortcutsPopupPlugin', () => { it('renders children as render function and provides close/onInsert', async () => { const TEST_COMMAND = createCommand<unknown>('TEST_COMMAND') - const childrenFn = vi.fn((close: () => void, onInsert: (cmd: LexicalCommand<unknown>, params: unknown[]) => void) => ( + const childrenFn = vi.fn((close: () => void, onInsert: ShortcutPopupInsertHandler) => ( <div> <button type="button" data-testid="close-btn" onClick={close}>Close</button> <button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['param1'])}>Insert</button> @@ -346,7 +346,7 @@ describe('ShortcutsPopupPlugin', () => { const TEST_COMMAND = createCommand<unknown>('TEST_INSERT_COMMAND') render( <MinimalEditor> - {(close: () => void, onInsert: (cmd: LexicalCommand<unknown>, params: unknown[]) => void) => ( + {(close: () => void, onInsert: ShortcutPopupInsertHandler) => ( <div> <button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['value'])}>Insert</button> </div> diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx index 19a2efb5bc..ec03b2fc3d 100644 --- a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx @@ -23,6 +23,7 @@ import { import { createPortal } from 'react-dom' export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content' +export type ShortcutPopupInsertHandler = <Payload>(command: LexicalCommand<Payload>, params: Payload) => void // Hotkey can be: // - string: 'mod+/' @@ -33,7 +34,7 @@ export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boo type ShortcutPopupPluginProps = { hotkey?: Hotkey - children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void) => React.ReactNode) + children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode) className?: string container?: Element | null onOpen?: () => void @@ -158,8 +159,9 @@ export default function ShortcutsPopupPlugin({ apply({ availableWidth, availableHeight, elements }) { Object.assign(elements.floating.style, { maxWidth: `${Math.min(400, availableWidth)}px`, - maxHeight: `${Math.min(300, availableHeight)}px`, - overflow: 'auto', + maxHeight: `${Math.max(0, availableHeight)}px`, + overflowX: 'hidden', + overflowY: 'auto', }) }, padding: 8, @@ -236,7 +238,7 @@ export default function ShortcutsPopupPlugin({ setOpen(true) onOpen?.() - }, [onOpen]) + }, [editor, onOpen, refs]) const closePortal = useCallback(() => { setOpen(false) @@ -280,7 +282,7 @@ export default function ShortcutsPopupPlugin({ return () => document.removeEventListener('mousedown', onMouseDown, false) }, [open, closePortal]) - const handleInsert = useCallback((command: LexicalCommand<unknown>, params: any) => { + const handleInsert = useCallback(<Payload,>(command: LexicalCommand<Payload>, params: Payload) => { editor.dispatchCommand(command, params) closePortal() }, [editor, closePortal]) diff --git a/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx b/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx index 0033ced4ce..3f7862b24b 100644 --- a/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx +++ b/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx @@ -29,26 +29,26 @@ type ModalSnapshot = { className?: string } let mockModalProps: ModalSnapshot | null = null -vi.mock('../../../base/modal', () => ({ - default: ({ isShow, children, onClose, closable, className }: { isShow: boolean, children: React.ReactNode, onClose: () => void, closable?: boolean, className?: string }) => { +let mockOnOpenChange: ((open: boolean) => void) | undefined +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ open, onOpenChange, children }: { open?: boolean, onOpenChange?: (open: boolean) => void, children: React.ReactNode }) => { + mockOnOpenChange = onOpenChange + mockModalProps = { isShow: open !== false } + return open === false ? null : <>{children}</> + }, + DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { mockModalProps = { - isShow, - closable, + isShow: true, + closable: true, className, } - if (!isShow) - return null return ( <div data-testid="annotation-full-modal" data-classname={className ?? ''}> - {closable && ( - <button type="button" data-testid="mock-modal-close" onClick={onClose}> - close - </button> - )} {children} </div> ) }, + DialogCloseButton: () => <button type="button" data-testid="mock-modal-close" onClick={() => mockOnOpenChange?.(false)}>close</button>, })) describe('AnnotationFullModal', () => { @@ -56,6 +56,7 @@ describe('AnnotationFullModal', () => { vi.clearAllMocks() mockUpgradeBtnProps = null mockModalProps = null + mockOnOpenChange = undefined }) // Rendering marketing copy inside modal @@ -71,8 +72,9 @@ describe('AnnotationFullModal', () => { expect(mockModalProps).toEqual(expect.objectContaining({ isShow: true, closable: true, - className: 'p-0!', + className: expect.stringContaining('p-0!'), })) + expect(mockModalProps?.className).toContain('w-full') }) }) diff --git a/web/app/components/billing/annotation-full/modal.tsx b/web/app/components/billing/annotation-full/modal.tsx index 4308cad160..c3c6aab2ce 100644 --- a/web/app/components/billing/annotation-full/modal.tsx +++ b/web/app/components/billing/annotation-full/modal.tsx @@ -1,10 +1,10 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' import GridMask from '@/app/components/base/grid-mask' -import Modal from '../../base/modal' import UpgradeBtn from '../upgrade-btn' import s from './style.module.css' import Usage from './usage' @@ -20,28 +20,33 @@ const AnnotationFullModal: FC<Props> = ({ const { t } = useTranslation() return ( - <Modal - isShow={show} - onClose={onHide} - closable - className="p-0!" + <Dialog + open={show} + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <GridMask wrapperClassName="rounded-lg" canvasClassName="rounded-lg" gradientClassName="rounded-lg"> - <div className="mt-6 flex cursor-pointer flex-col rounded-lg border-2 border-solid border-transparent px-7 py-6 shadow-md transition-all duration-200 ease-in-out"> - <div className="flex items-center justify-between"> - <div className={cn(s.textGradient, 'text-[18px] leading-[27px] font-semibold')}> - <div>{t('annotatedResponse.fullTipLine1', { ns: 'billing' })}</div> - <div>{t('annotatedResponse.fullTipLine2', { ns: 'billing' })}</div> - </div> + <DialogContent className="w-full overflow-hidden! border-none p-0! text-left align-middle"> + <DialogCloseButton data-testid="modal-close-button" /> + <GridMask wrapperClassName="rounded-lg" canvasClassName="rounded-lg" gradientClassName="rounded-lg"> + <div className="mt-6 flex cursor-pointer flex-col rounded-lg border-2 border-solid border-transparent px-7 py-6 shadow-md transition-all duration-200 ease-in-out"> + <div className="flex items-center justify-between"> + <div className={cn(s.textGradient, 'text-[18px] leading-[27px] font-semibold')}> + <div>{t('annotatedResponse.fullTipLine1', { ns: 'billing' })}</div> + <div>{t('annotatedResponse.fullTipLine2', { ns: 'billing' })}</div> + </div> + + </div> + <Usage className="mt-4" /> + <div className="mt-7 flex justify-end"> + <UpgradeBtn loc="annotation-create" /> + </div> </div> - <Usage className="mt-4" /> - <div className="mt-7 flex justify-end"> - <UpgradeBtn loc="annotation-create" /> - </div> - </div> - </GridMask> - </Modal> + </GridMask> + </DialogContent> + </Dialog> ) } export default React.memo(AnnotationFullModal) diff --git a/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx index e1fde328bb..cc60160595 100644 --- a/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx @@ -42,7 +42,7 @@ type ImageInfo = { size: number } -// Mock ImagePreviewer since it uses createPortal +// Mock ImagePreviewer since it renders through a Dialog portal vi.mock('../../image-previewer', () => ({ default: ({ images, initialIndex, onClose }: ImagePreviewerProps) => ( <div data-testid="image-previewer"> diff --git a/web/app/components/datasets/common/image-previewer/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-previewer/__tests__/index.spec.tsx index 1ff5cb33f8..aea1a181ff 100644 --- a/web/app/components/datasets/common/image-previewer/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-previewer/__tests__/index.spec.tsx @@ -474,23 +474,18 @@ describe('ImagePreviewer', () => { expect(nextButton)!.toBeDisabled() }) - it('should stop event propagation on container click', async () => { + it('should not close on container click', async () => { const onClose = vi.fn() - const parentClick = vi.fn() const images = createMockImages() await act(async () => { - render( - <div onClick={parentClick}> - <ImagePreviewer images={images} onClose={onClose} /> - </div>, - ) + render(<ImagePreviewer images={images} onClose={onClose} />) }) const container = document.querySelector('.image-previewer') if (container) { fireEvent.click(container) - expect(parentClick).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() } }) diff --git a/web/app/components/datasets/common/image-previewer/index.tsx b/web/app/components/datasets/common/image-previewer/index.tsx index 42b4ce33a9..7520f62f81 100644 --- a/web/app/components/datasets/common/image-previewer/index.tsx +++ b/web/app/components/datasets/common/image-previewer/index.tsx @@ -1,7 +1,7 @@ import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { RiArrowLeftLine, RiArrowRightLine, RiCloseLine, RiRefreshLine } from '@remixicon/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' import { useHotkeys } from 'react-hotkeys-hook' import Loading from '@/app/components/base/loading' import { formatFileSize } from '@/utils/format' @@ -145,81 +145,87 @@ const ImagePreviewer = ({ fetchImage(image) }, [fetchImage]) - useHotkeys('esc', onClose) useHotkeys('left', prevImage) useHotkeys('right', nextImage) - return createPortal( - <div - className="image-previewer fixed inset-0 z-10000 flex items-center justify-center bg-background-overlay-fullscreen p-5 pb-4 backdrop-blur-[6px]" - onClick={e => e.stopPropagation()} - tabIndex={-1} + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) + onClose() + }} + disablePointerDismissal > - <div className="absolute top-6 right-6 z-10 flex cursor-pointer flex-col items-center gap-y-1"> - <Button - variant="tertiary" - onClick={onClose} - className="size-9 rounded-[10px] p-0" - size="large" - > - <RiCloseLine className="size-5" /> - </Button> - <span className="system-2xs-medium-uppercase text-text-tertiary"> - Esc - </span> - </div> - {cachedImages[currentImage!.url]!.status === 'loading' && ( - <Loading type="app" /> - )} - {cachedImages[currentImage!.url]!.status === 'error' && ( - <div className="flex max-w-sm flex-col items-center gap-y-2 system-sm-regular text-text-tertiary"> - <span>{`Failed to load image: ${currentImage!.url}. Please try again.`}</span> + <DialogContent + className="image-previewer inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-background-overlay-fullscreen p-5! pb-4! shadow-none! backdrop-blur-[6px]" + backdropClassName="bg-transparent!" + > + <div className="absolute top-6 right-6 z-10 flex cursor-pointer flex-col items-center gap-y-1"> <Button - variant="secondary" - onClick={() => retryImage(currentImage!)} - className="size-9 rounded-full p-0" + variant="tertiary" + onClick={onClose} + className="size-9 rounded-[10px] p-0" size="large" > - <RiRefreshLine className="size-5" /> + <RiCloseLine className="size-5" /> </Button> + <span className="system-2xs-medium-uppercase text-text-tertiary"> + Esc + </span> </div> - )} - {cachedImages[currentImage!.url]!.status === 'loaded' && ( - <div className="flex size-full flex-col items-center justify-center gap-y-2"> - <img - alt={currentImage!.name} - src={cachedImages[currentImage!.url]!.blobUrl} - className="max-h-[calc(100%-2.5rem)] max-w-full object-contain shadow-lg ring-8 ring-effects-image-frame backdrop-blur-[5px]" - /> - <div className="flex shrink-0 gap-x-2 pt-3 pb-1 system-sm-regular text-text-tertiary"> - <span>{currentImage!.name}</span> - <span>·</span> - <span>{`${cachedImages[currentImage!.url]!.width} ×  ${cachedImages[currentImage!.url]!.height}`}</span> - <span>·</span> - <span>{formatFileSize(currentImage!.size)}</span> + {cachedImages[currentImage!.url]!.status === 'loading' && ( + <Loading type="app" /> + )} + {cachedImages[currentImage!.url]!.status === 'error' && ( + <div className="flex max-w-sm flex-col items-center gap-y-2 system-sm-regular text-text-tertiary"> + <span>{`Failed to load image: ${currentImage!.url}. Please try again.`}</span> + <Button + variant="secondary" + onClick={() => retryImage(currentImage!)} + className="size-9 rounded-full p-0" + size="large" + > + <RiRefreshLine className="size-5" /> + </Button> </div> - </div> - )} - <Button - variant="secondary" - onClick={prevImage} - className="absolute top-1/2 left-8 z-10 size-9 -translate-y-1/2 rounded-full p-0" - disabled={currentIndex === 0} - size="large" - > - <RiArrowLeftLine className="size-5" /> - </Button> - <Button - variant="secondary" - onClick={nextImage} - className="absolute top-1/2 right-8 z-10 size-9 -translate-y-1/2 rounded-full p-0" - disabled={currentIndex === images.length - 1} - size="large" - > - <RiArrowRightLine className="size-5" /> - </Button> - </div>, - document.body, + )} + {cachedImages[currentImage!.url]!.status === 'loaded' && ( + <div className="flex size-full flex-col items-center justify-center gap-y-2"> + <img + alt={currentImage!.name} + src={cachedImages[currentImage!.url]!.blobUrl} + className="max-h-[calc(100%-2.5rem)] max-w-full object-contain shadow-lg ring-8 ring-effects-image-frame backdrop-blur-[5px]" + /> + <div className="flex shrink-0 gap-x-2 pt-3 pb-1 system-sm-regular text-text-tertiary"> + <span>{currentImage!.name}</span> + <span>·</span> + <span>{`${cachedImages[currentImage!.url]!.width} ×  ${cachedImages[currentImage!.url]!.height}`}</span> + <span>·</span> + <span>{formatFileSize(currentImage!.size)}</span> + </div> + </div> + )} + <Button + variant="secondary" + onClick={prevImage} + className="absolute top-1/2 left-8 z-10 size-9 -translate-y-1/2 rounded-full p-0" + disabled={currentIndex === 0} + size="large" + > + <RiArrowLeftLine className="size-5" /> + </Button> + <Button + variant="secondary" + onClick={nextImage} + className="absolute top-1/2 right-8 z-10 size-9 -translate-y-1/2 rounded-full p-0" + disabled={currentIndex === images.length - 1} + size="large" + > + <RiArrowRightLine className="size-5" /> + </Button> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx index d0c97e185c..849d36807f 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx @@ -1,6 +1,13 @@ -import { Button } from '@langgenius/dify-ui/button' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' type DSLConfirmModalProps = { versions?: { @@ -20,32 +27,36 @@ const DSLConfirmModal = ({ const { t } = useTranslation() return ( - <Modal - isShow - onClose={() => onCancel()} - className="w-[480px]" + <AlertDialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} > - <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div> - <div className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> - <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> - <br /> - <div> - {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - <span className="system-md-medium">{versions.importedVersion}</span> - </div> - <div> - {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - <span className="system-md-medium">{versions.systemVersion}</span> - </div> + <AlertDialogContent className="w-[480px] max-w-none! overflow-hidden! border-none p-6 text-left align-middle shadow-xl"> + <div className="flex flex-col items-start gap-2 self-stretch pb-4"> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</AlertDialogTitle> + <AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary"> + <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> + <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> + <br /> + <div> + {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} + <span className="system-md-medium">{versions.importedVersion}</span> + </div> + <div> + {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} + <span className="system-md-medium">{versions.systemVersion}</span> + </div> + </AlertDialogDescription> </div> - </div> - <div className="flex items-start justify-end gap-2 self-stretch pt-6"> - <Button variant="secondary" onClick={() => onCancel()}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button variant="primary" tone="destructive" onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</Button> - </div> - </Modal> + <AlertDialogActions className="items-start p-0 pt-6"> + <AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> ) } diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx index b95fa4d5db..a00cdf6098 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx @@ -418,7 +418,10 @@ describe('useDSLImport', () => { it('should handle FAILED status', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'failed' })) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'failed', + error: 'Missing rag_pipeline data in YAML content', + })) const { result } = renderHook( () => useDSLImport({ @@ -434,9 +437,42 @@ describe('useDSLImport', () => { }) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', - })) + message: 'Missing rag_pipeline data in YAML content', + }) + }) + + vi.useRealTimers() + }) + + it('should show response error when import request rejects with a response body', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockRejectedValue(new Response(JSON.stringify({ + error: 'Missing rag_pipeline data in YAML content', + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'Missing rag_pipeline data in YAML content', + }) }) vi.useRealTimers() @@ -692,6 +728,7 @@ describe('useDSLImport', () => { status: 'failed', pipeline_id: 'pipeline-456', dataset_id: 'dataset-789', + error: 'Import information expired or does not exist', }) const { result } = renderHook( @@ -718,9 +755,56 @@ describe('useDSLImport', () => { }) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', - })) + message: 'Import information expired or does not exist', + }) + }) + + vi.useRealTimers() + }) + + it('should show response error when confirm request rejects with a response body', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockRejectedValue(new Response(JSON.stringify({ + error: 'Import information expired or does not exist', + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'Import information expired or does not exist', + }) }) vi.useRealTimers() diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts index e085ffe1bc..0dbfdf2156 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts @@ -22,6 +22,31 @@ type DSLVersions = { importedVersion: string systemVersion: string } +type ImportErrorResponse = { + message?: unknown + error?: unknown +} +const getNonEmptyString = (value: unknown): string | undefined => { + if (typeof value !== 'string') + return undefined + + const trimmedValue = value.trim() + return trimmedValue || undefined +} +const getImportErrorMessage = async (error: unknown): Promise<string | undefined> => { + if (error instanceof Response && !error.bodyUsed) { + try { + const errorData = await error.clone().json() as ImportErrorResponse + return getNonEmptyString(errorData.message) ?? getNonEmptyString(errorData.error) + } + catch {} + } + + if (error instanceof Error) + return getNonEmptyString(error.message) + + return undefined +} export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', onSuccess, onClose }: UseDSLImportOptions) => { const { push } = useRouter() const { t } = useTranslation() @@ -37,6 +62,9 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU const isCreatingRef = useRef(false) const { mutateAsync: importDSL } = useImportPipelineDSL() const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() + const notifyError = useCallback((message?: string) => { + toast.error(message || t('creation.errorTip', { ns: 'datasetPipeline' })) + }, [t]) const readFile = useCallback((file: File) => { const reader = new FileReader() reader.onload = (event) => { @@ -60,51 +88,55 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU if (isCreatingRef.current) return isCreatingRef.current = true - let response - if (currentTab === CreateFromDSLModalTab.FROM_FILE) { - response = await importDSL({ - mode: DSLImportMode.YAML_CONTENT, - yaml_content: fileContent || '', - }) + try { + let response + if (currentTab === CreateFromDSLModalTab.FROM_FILE) { + response = await importDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: fileContent || '', + }) + } + if (currentTab === CreateFromDSLModalTab.FROM_URL) { + response = await importDSL({ + mode: DSLImportMode.YAML_URL, + yaml_url: dslUrlValue || '', + }) + } + if (!response) { + notifyError() + return + } + const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response + if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { + onSuccess?.() + onClose?.() + toast(t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }), { + type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', + description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), + }) + if (pipeline_id) + await handleCheckPluginDependencies(pipeline_id, true) + push(`/datasets/${dataset_id}/pipeline`) + } + else if (status === DSLImportStatus.PENDING) { + setVersions({ + importedVersion: imported_dsl_version ?? '', + systemVersion: current_dsl_version ?? '', + }) + onClose?.() + setTimeout(() => { + setShowConfirmModal(true) + }, 300) + setImportId(id) + } + else { + notifyError(response.error) + } } - if (currentTab === CreateFromDSLModalTab.FROM_URL) { - response = await importDSL({ - mode: DSLImportMode.YAML_URL, - yaml_url: dslUrlValue || '', - }) + catch (error) { + notifyError(await getImportErrorMessage(error)) } - if (!response) { - toast.error(t('creation.errorTip', { ns: 'datasetPipeline' })) - isCreatingRef.current = false - return - } - const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response - if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - onSuccess?.() - onClose?.() - toast(t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }), { - type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', - description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), - }) - if (pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) - push(`/datasets/${dataset_id}/pipeline`) - isCreatingRef.current = false - } - else if (status === DSLImportStatus.PENDING) { - setVersions({ - importedVersion: imported_dsl_version ?? '', - systemVersion: current_dsl_version ?? '', - }) - onClose?.() - setTimeout(() => { - setShowConfirmModal(true) - }, 300) - setImportId(id) - isCreatingRef.current = false - } - else { - toast.error(t('creation.errorTip', { ns: 'datasetPipeline' })) + finally { isCreatingRef.current = false } }, [ @@ -114,6 +146,7 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU fileContent, importDSL, t, + notifyError, onSuccess, onClose, handleCheckPluginDependencies, @@ -124,25 +157,32 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU if (!importId) return setIsConfirming(true) - const response = await importDSLConfirm(importId) - setIsConfirming(false) - if (!response) { - toast.error(t('creation.errorTip', { ns: 'datasetPipeline' })) - return + try { + const response = await importDSLConfirm(importId) + if (!response) { + notifyError() + return + } + const { status, pipeline_id, dataset_id, error } = response + if (status === DSLImportStatus.COMPLETED) { + onSuccess?.() + setShowConfirmModal(false) + toast.success(t('creation.successTip', { ns: 'datasetPipeline' })) + if (pipeline_id) + await handleCheckPluginDependencies(pipeline_id, true) + push(`/datasets/${dataset_id}/pipeline`) + } + else if (status === DSLImportStatus.FAILED) { + notifyError(error) + } } - const { status, pipeline_id, dataset_id } = response - if (status === DSLImportStatus.COMPLETED) { - onSuccess?.() - setShowConfirmModal(false) - toast.success(t('creation.successTip', { ns: 'datasetPipeline' })) - if (pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) - push(`/datasets/${dataset_id}/pipeline`) + catch (error) { + notifyError(await getImportErrorMessage(error)) } - else if (status === DSLImportStatus.FAILED) { - toast.error(t('creation.errorTip', { ns: 'datasetPipeline' })) + finally { + setIsConfirming(false) } - }, [importId, importDSLConfirm, t, onSuccess, handleCheckPluginDependencies, push]) + }, [importId, importDSLConfirm, notifyError, t, onSuccess, handleCheckPluginDependencies, push]) const handleCancelConfirm = useCallback(() => { setShowConfirmModal(false) }, []) diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx index 4f5b5c23fd..8aeaed3ebd 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx @@ -1,10 +1,9 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { useKeyPress } from 'ahooks' -import { noop } from 'es-toolkit/function' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import DSLConfirmModal from './dsl-confirm-modal' import Header from './header' import { CreateFromDSLModalTab, useDSLImport } from './hooks/use-dsl-import' @@ -54,55 +53,54 @@ const CreateFromDSLModal = ({ useKeyPress('esc', () => { if (show && !showConfirmModal) onClose() - }) + }, { target: () => document }) return ( <> - <Modal - className="w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl" - isShow={show} - onClose={noop} - > - <Header onClose={onClose} /> - <Tab - currentTab={currentTab} - setCurrentTab={setCurrentTab} - /> - <div className="px-6 py-4"> - {currentTab === CreateFromDSLModalTab.FROM_FILE && ( - <Uploader - className="mt-0" - file={currentFile} - updateFile={handleFile} - /> - )} - {currentTab === CreateFromDSLModalTab.FROM_URL && ( - <div> - <div className="leading6 mb-1 system-md-semibold text-text-secondary"> - DSL URL - </div> - <Input - placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''} - value={dslUrlValue} - onChange={e => setDslUrlValue(e.target.value)} + <Dialog open={show}> + <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl"> + + <Header onClose={onClose} /> + <Tab + currentTab={currentTab} + setCurrentTab={setCurrentTab} + /> + <div className="px-6 py-4"> + {currentTab === CreateFromDSLModalTab.FROM_FILE && ( + <Uploader + className="mt-0" + file={currentFile} + updateFile={handleFile} /> - </div> - )} - </div> - <div className="flex justify-end gap-x-2 p-6 pt-5"> - <Button onClick={onClose}> - {t('newApp.Cancel', { ns: 'app' })} - </Button> - <Button - disabled={buttonDisabled} - variant="primary" - onClick={handleCreateApp} - className="gap-1" - > - <span>{t('newApp.import', { ns: 'app' })}</span> - </Button> - </div> - </Modal> + )} + {currentTab === CreateFromDSLModalTab.FROM_URL && ( + <div> + <div className="leading6 mb-1 system-md-semibold text-text-secondary"> + DSL URL + </div> + <Input + placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''} + value={dslUrlValue} + onChange={e => setDslUrlValue(e.target.value)} + /> + </div> + )} + </div> + <div className="flex justify-end gap-x-2 p-6 pt-5"> + <Button onClick={onClose}> + {t('newApp.Cancel', { ns: 'app' })} + </Button> + <Button + disabled={buttonDisabled} + variant="primary" + onClick={handleCreateApp} + className="gap-1" + > + <span>{t('newApp.import', { ns: 'app' })}</span> + </Button> + </div> + </DialogContent> + </Dialog> {showConfirmModal && ( <DSLConfirmModal versions={versions} diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx index bcf2c068ef..8f28f75045 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx @@ -323,6 +323,21 @@ describe('TemplateCard', () => { }) }) + it('should close details modal when dialog requests close', async () => { + render(<TemplateCard {...defaultProps} />) + fireEvent.click(screen.getByTestId('action-details')) + + await waitFor(() => { + expect(screen.getByTestId('details-component')).toBeInTheDocument() + }) + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + await waitFor(() => { + expect(screen.queryByTestId('details-component')).not.toBeInTheDocument() + }) + }) + it('should trigger use template from details modal', async () => { mockCreateDataset.mockImplementation((_data, callbacks) => { callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' }) @@ -593,6 +608,21 @@ describe('TemplateCard', () => { expect(screen.queryByTestId('edit-pipeline-info')).not.toBeInTheDocument() }) }) + + it('should close edit modal when dialog requests close', async () => { + render(<TemplateCard {...defaultProps} />) + fireEvent.click(screen.getByTestId('action-edit')) + + await waitFor(() => { + expect(screen.getByTestId('edit-pipeline-info')).toBeInTheDocument() + }) + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + await waitFor(() => { + expect(screen.queryByTestId('edit-pipeline-info')).not.toBeInTheDocument() + }) + }) }) // Props Tests diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx index 03bda31f1f..4663c55e38 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx @@ -8,12 +8,12 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' -import Modal from '@/app/components/base/modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { useRouter } from '@/next/navigation' import { useCreatePipelineDatasetFromCustomized } from '@/service/knowledge/use-create-dataset' @@ -153,16 +153,21 @@ const TemplateCard = ({ handleDelete={handleDelete} /> {showEditModal && ( - <Modal - isShow={showEditModal} - onClose={closeEditModal} - className="max-w-[520px] p-0" + <Dialog + open={showEditModal} + onOpenChange={(open) => { + if (!open) + closeEditModal() + }} > - <EditPipelineInfo - pipeline={pipeline} - onClose={closeEditModal} - /> - </Modal> + <DialogContent className="w-[calc(100vw-2rem)] max-w-[520px]! overflow-hidden! border-none p-0 text-left align-middle"> + + <EditPipelineInfo + pipeline={pipeline} + onClose={closeEditModal} + /> + </DialogContent> + </Dialog> )} <AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && onCancelDelete()}> <AlertDialogContent> @@ -183,18 +188,23 @@ const TemplateCard = ({ </AlertDialogContent> </AlertDialog> {showDetailModal && ( - <Modal - isShow={showDetailModal} - onClose={closeDetailsModal} - className="h-[calc(100vh-64px)] max-w-[1680px] rounded-3xl p-0" + <Dialog + open={showDetailModal} + onOpenChange={(open) => { + if (!open) + closeDetailsModal() + }} > - <Details - id={pipeline.id} - type={type} - onClose={closeDetailsModal} - onApplyTemplate={handleUseTemplate} - /> - </Modal> + <DialogContent className="h-[calc(100vh-64px)] max-h-none w-[calc(100vw-2rem)] max-w-[1680px]! overflow-hidden! rounded-3xl border-none p-0 text-left align-middle"> + + <Details + id={pipeline.id} + type={type} + onClose={closeDetailsModal} + onApplyTemplate={handleUseTemplate} + /> + </DialogContent> + </Dialog> )} </div> ) diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx index fdb032b1ef..0de10a8ecd 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx @@ -1,13 +1,13 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import { useRouter } from '@/next/navigation' import { createEmptyDataset } from '@/service/datasets' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' @@ -46,21 +46,30 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => { } } return ( - <Modal isShow={show} onClose={onHide} className={cn(s.modal, '!max-w-[520px]', 'px-8')}> - <div className={s.modalHeader}> - <div className={s.title}>{t('stepOne.modal.title', { ns: 'datasetCreation' })}</div> - <span className={s.close} onClick={onHide} /> - </div> - <div className={s.tip}>{t('stepOne.modal.tip', { ns: 'datasetCreation' })}</div> - <div className={s.form}> - <div className={s.label}>{t('stepOne.modal.input', { ns: 'datasetCreation' })}</div> - <Input value={inputValue} placeholder={t('stepOne.modal.placeholder', { ns: 'datasetCreation' }) || ''} onChange={e => setInputValue(e.target.value)} /> - </div> - <div className="flex flex-row-reverse"> - <Button className="ml-2 w-24" variant="primary" onClick={submit}>{t('stepOne.modal.confirmButton', { ns: 'datasetCreation' })}</Button> - <Button className="w-24" onClick={onHide}>{t('stepOne.modal.cancelButton', { ns: 'datasetCreation' })}</Button> - </div> - </Modal> + <Dialog + open={show} + onOpenChange={(open) => { + if (!open) + onHide() + }} + > + <DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn(s.modal, '!max-w-[520px]', 'px-8'))}> + + <div className={s.modalHeader}> + <div className={s.title}>{t('stepOne.modal.title', { ns: 'datasetCreation' })}</div> + <span className={s.close} onClick={onHide} /> + </div> + <div className={s.tip}>{t('stepOne.modal.tip', { ns: 'datasetCreation' })}</div> + <div className={s.form}> + <div className={s.label}>{t('stepOne.modal.input', { ns: 'datasetCreation' })}</div> + <Input value={inputValue} placeholder={t('stepOne.modal.placeholder', { ns: 'datasetCreation' }) || ''} onChange={e => setInputValue(e.target.value)} /> + </div> + <div className="flex flex-row-reverse"> + <Button className="ml-2 w-24" variant="primary" onClick={submit}>{t('stepOne.modal.confirmButton', { ns: 'datasetCreation' })}</Button> + <Button className="w-24" onClick={onHide}>{t('stepOne.modal.cancelButton', { ns: 'datasetCreation' })}</Button> + </div> + </DialogContent> + </Dialog> ) } export default EmptyDatasetCreationModal diff --git a/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx index 1b3abfeadc..840c5cc0e5 100644 --- a/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx @@ -25,8 +25,7 @@ const renderStopEmbeddingModal = (props: Partial<StopEmbeddingModalProps> = {}) // StopEmbeddingModal Component Tests describe('StopEmbeddingModal', () => { - // Suppress Headless UI warnings in tests - // These warnings are from the library's internal behavior, not our code + // Suppress expected modal warnings in tests. let consoleWarnSpy: MockInstance let consoleErrorSpy: MockInstance diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.tsx b/web/app/components/datasets/create/stop-embedding-modal/index.tsx index 93df58d276..41faf58b09 100644 --- a/web/app/components/datasets/create/stop-embedding-modal/index.tsx +++ b/web/app/components/datasets/create/stop-embedding-modal/index.tsx @@ -1,9 +1,16 @@ 'use client' -import { Button } from '@langgenius/dify-ui/button' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import s from './index.module.css' type IProps = { @@ -25,20 +32,24 @@ const StopEmbeddingModal = ({ } return ( - <Modal - isShow={show} - onClose={onHide} - className={cn(s.modal, 'max-w-[480px]!', 'px-8')} + <AlertDialog + open={show} + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <div className={s.icon} /> - <span className={s.close} onClick={onHide} /> - <div className={s.title}>{t('stepThree.modelTitle', { ns: 'datasetCreation' })}</div> - <div className={s.content}>{t('stepThree.modelContent', { ns: 'datasetCreation' })}</div> - <div className="flex flex-row-reverse"> - <Button className="ml-2 w-24" variant="primary" onClick={submit}>{t('stepThree.modelButtonConfirm', { ns: 'datasetCreation' })}</Button> - <Button className="w-24" onClick={onHide}>{t('stepThree.modelButtonCancel', { ns: 'datasetCreation' })}</Button> - </div> - </Modal> + <AlertDialogContent className={cn(s.modal, 'max-w-[480px]! overflow-hidden! border-none px-8 py-6 text-left align-middle shadow-xl')}> + <div className={s.icon} /> + <span className={s.close} onClick={onHide} /> + <AlertDialogTitle className={s.title}>{t('stepThree.modelTitle', { ns: 'datasetCreation' })}</AlertDialogTitle> + <AlertDialogDescription className={s.content}>{t('stepThree.modelContent', { ns: 'datasetCreation' })}</AlertDialogDescription> + <AlertDialogActions className="flex-row-reverse gap-0 p-0"> + <AlertDialogConfirmButton className="ml-2 w-24" tone="default" onClick={submit}>{t('stepThree.modelButtonConfirm', { ns: 'datasetCreation' })}</AlertDialogConfirmButton> + <AlertDialogCancelButton className="w-24" variant="secondary">{t('stepThree.modelButtonCancel', { ns: 'datasetCreation' })}</AlertDialogCancelButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> ) } diff --git a/web/app/components/datasets/documents/components/rename-modal.tsx b/web/app/components/datasets/documents/components/rename-modal.tsx index fc4626676b..ac4fc7fc84 100644 --- a/web/app/components/datasets/documents/components/rename-modal.tsx +++ b/web/app/components/datasets/documents/components/rename-modal.tsx @@ -1,13 +1,13 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useBoolean } from 'ahooks' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import { renameDocumentName } from '@/service/datasets' type Props = { @@ -55,23 +55,31 @@ const RenameModal: FC<Props> = ({ } return ( - <Modal - title={t('list.table.rename', { ns: 'datasetDocuments' })} - isShow - onClose={onClose} + <Dialog + open + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('list.table.name', { ns: 'datasetDocuments' })}</div> - <Input - className="mt-2 h-10" - value={newName} - onChange={e => setNewName(e.target.value)} - /> + <DialogContent className="overflow-hidden! border-none text-left align-middle"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t('list.table.rename', { ns: 'datasetDocuments' })} + </DialogTitle> - <div className="mt-10 flex justify-end"> - <Button className="mr-2 shrink-0" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button variant="primary" className="shrink-0" onClick={handleSave} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button> - </div> - </Modal> + <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('list.table.name', { ns: 'datasetDocuments' })}</div> + <Input + className="mt-2 h-10" + value={newName} + onChange={e => setNewName(e.target.value)} + /> + + <div className="mt-10 flex justify-end"> + <Button className="mr-2 shrink-0" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button variant="primary" className="shrink-0" onClick={handleSave} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button> + </div> + </DialogContent> + </Dialog> ) } export default React.memo(RenameModal) diff --git a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx index 9b2a2e36ee..3999e113af 100644 --- a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx @@ -1,12 +1,16 @@ import type { FC } from 'react' +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { RiLoader2Line } from '@remixicon/react' import { useCountDown } from 'ahooks' -import { noop } from 'es-toolkit/function' import * as React from 'react' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import { useEventEmitterContextContext } from '@/context/event-emitter' type IDefaultContentProps = { @@ -23,8 +27,8 @@ const DefaultContent: FC<IDefaultContentProps> = React.memo(({ return ( <> <div className="pb-4"> - <span className="title-2xl-semi-bold text-text-primary">{t('segment.regenerationConfirmTitle', { ns: 'datasetDocuments' })}</span> - <p className="system-md-regular text-text-secondary">{t('segment.regenerationConfirmMessage', { ns: 'datasetDocuments' })}</p> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t('segment.regenerationConfirmTitle', { ns: 'datasetDocuments' })}</AlertDialogTitle> + <AlertDialogDescription className="system-md-regular text-text-secondary">{t('segment.regenerationConfirmMessage', { ns: 'datasetDocuments' })}</AlertDialogDescription> </div> <div className="flex justify-end gap-x-2 pt-6"> <Button onClick={onCancel}> @@ -123,11 +127,13 @@ const RegenerationModal: FC<IRegenerationModalProps> = ({ }) return ( - <Modal isShow={isShow} onClose={noop} className="max-w-[480px]! rounded-2xl!" wrapperClassName="z-10000!"> - {!loading && !updateSucceeded && <DefaultContent onCancel={onCancel} onConfirm={onConfirm} />} - {loading && !updateSucceeded && <RegeneratingContent />} - {!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />} - </Modal> + <AlertDialog open={isShow}> + <AlertDialogContent className="w-[calc(100vw-2rem)] max-w-[480px]! overflow-hidden! rounded-2xl! border-none p-6 text-left align-middle shadow-xl"> + {!loading && !updateSucceeded && <DefaultContent onCancel={onCancel} onConfirm={onConfirm} />} + {loading && !updateSucceeded && <RegeneratingContent />} + {!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />} + </AlertDialogContent> + </AlertDialog> ) } diff --git a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx index 109d2f9cfe..b4d017d0df 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx @@ -1,5 +1,5 @@ import type { HitTesting } from '@/models/datasets' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import ChunkDetailModal from '../chunk-detail-modal' @@ -11,16 +11,6 @@ vi.mock('@/app/components/base/markdown', () => ({ Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>, })) -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, title, onClose }: { children: React.ReactNode, title: string, onClose: () => void }) => ( - <div data-testid="modal"> - <div data-testid="modal-title">{title}</div> - <button data-testid="modal-close" onClick={onClose}>close</button> - {children} - </div> - ), -})) - vi.mock('../../../common/image-list', () => ({ default: () => <div data-testid="image-list" />, })) @@ -85,7 +75,7 @@ describe('ChunkDetailModal', () => { it('should render modal with title', () => { render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) - expect(screen.getByTestId('modal-title')).toHaveTextContent('chunkDetail') + expect(screen.getByRole('dialog')).toHaveTextContent('chunkDetail') }) it('should render segment index tag and score', () => { @@ -134,4 +124,18 @@ describe('ChunkDetailModal', () => { render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) expect(screen.getByTestId('mask')).toBeInTheDocument() }) + + it('should call onHide when close button is clicked', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + fireEvent.click(screen.getByTestId('modal-close-button')) + expect(onHide).toHaveBeenCalled() + }) + + it('should call onHide when the dialog requests close', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + expect(onHide).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx b/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx index ee0d949a7e..6f789a81ce 100644 --- a/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx +++ b/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx @@ -2,12 +2,12 @@ import type { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' import type { HitTesting } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import FileIcon from '@/app/components/base/file-uploader/file-type-icon' import { Markdown } from '@/app/components/base/markdown' -import Modal from '@/app/components/base/modal' import Tag from '@/app/components/datasets/documents/detail/completed/common/tag' import ImageList from '../../common/image-list' import Dot from '../../documents/detail/completed/common/dot' @@ -52,93 +52,106 @@ const ChunkDetailModal = ({ const showKeywords = !isParentChildRetrieval && keywords && keywords.length > 0 return ( - <Modal - title={t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })} - isShow - closable - onClose={onHide} - className={cn(isParentChildRetrieval ? 'min-w-[1200px]!' : 'min-w-[800px]!')} + <Dialog + open + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <div className="mt-4 flex"> - <div className={cn('flex-1', isParentChildRetrieval && 'pr-6')}> - {/* Meta info */} - <div className="flex items-center justify-between"> - <div className="flex grow items-center space-x-2"> - <SegmentIndexTag - labelPrefix={labelPrefix} - positionId={position} - className={cn('w-fit group-hover:opacity-100')} - /> - <Dot /> - <div className="flex grow items-center space-x-1"> - <FileIcon type={extension} size="sm" /> - <span className="w-0 grow truncate text-[13px] font-normal text-text-secondary">{document.name}</span> + <DialogContent className={cn('max-h-none overflow-hidden! border-none p-6 text-left align-middle', isParentChildRetrieval ? 'w-[1200px] max-w-none! min-w-[1200px]!' : 'w-[800px] max-w-none! min-w-[800px]!')}> + <DialogCloseButton + data-testid="modal-close-button" + onClick={(e) => { + e.stopPropagation() + onHide() + }} + /> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })} + </DialogTitle> + + <div className="mt-4 flex"> + <div className={cn('flex-1', isParentChildRetrieval && 'pr-6')}> + {/* Meta info */} + <div className="flex items-center justify-between"> + <div className="flex grow items-center space-x-2"> + <SegmentIndexTag + labelPrefix={labelPrefix} + positionId={position} + className={cn('w-fit group-hover:opacity-100')} + /> + <Dot /> + <div className="flex grow items-center space-x-1"> + <FileIcon type={extension} size="sm" /> + <span className="w-0 grow truncate text-[13px] font-normal text-text-secondary">{document.name}</span> + </div> </div> + <Score value={score} /> </div> - <Score value={score} /> - </div> - {/* Content */} - <div className="relative"> - {!answer && ( - <Markdown - className={cn('mt-2! text-text-secondary!', heighClassName)} - content={sign_content || content} - customDisallowedElements={['input']} - /> - )} - {answer && ( - <div className="break-all"> - <div className="flex gap-x-1"> - <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">Q</div> - <div className={cn('line-clamp-20 body-md-regular text-text-secondary')}> - {content} + {/* Content */} + <div className="relative"> + {!answer && ( + <Markdown + className={cn('mt-2! text-text-secondary!', heighClassName)} + content={sign_content || content} + customDisallowedElements={['input']} + /> + )} + {answer && ( + <div className="break-all"> + <div className="flex gap-x-1"> + <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">Q</div> + <div className={cn('line-clamp-20 body-md-regular text-text-secondary')}> + {content} + </div> + </div> + <div className="flex gap-x-1"> + <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">A</div> + <div className={cn('line-clamp-20 body-md-regular text-text-secondary')}> + {answer} + </div> </div> </div> - <div className="flex gap-x-1"> - <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">A</div> - <div className={cn('line-clamp-20 body-md-regular text-text-secondary')}> - {answer} + )} + {/* Mask */} + <Mask className="absolute inset-x-0 bottom-0" /> + </div> + {(showImages || showKeywords || !!summary) && ( + <div className="flex flex-col gap-y-3 pt-3"> + {showImages && ( + <ImageList images={images} size="md" className="py-1" /> + )} + {!!summary && ( + <SummaryText value={summary} disabled /> + )} + {showKeywords && ( + <div className="flex flex-col gap-y-1"> + <div className="text-xs font-medium text-text-tertiary uppercase">{t(`${i18nPrefix}keyword`, { ns: 'datasetHitTesting' })}</div> + <div className="flex flex-wrap gap-x-2"> + {keywords.map(keyword => ( + <Tag key={keyword} text={keyword} /> + ))} + </div> </div> - </div> + )} </div> )} - {/* Mask */} - <Mask className="absolute inset-x-0 bottom-0" /> </div> - {(showImages || showKeywords || !!summary) && ( - <div className="flex flex-col gap-y-3 pt-3"> - {showImages && ( - <ImageList images={images} size="md" className="py-1" /> - )} - {!!summary && ( - <SummaryText value={summary} disabled /> - )} - {showKeywords && ( - <div className="flex flex-col gap-y-1"> - <div className="text-xs font-medium text-text-tertiary uppercase">{t(`${i18nPrefix}keyword`, { ns: 'datasetHitTesting' })}</div> - <div className="flex flex-wrap gap-x-2"> - {keywords.map(keyword => ( - <Tag key={keyword} text={keyword} /> - ))} - </div> - </div> - )} + + {isParentChildRetrieval && ( + <div className="flex-1 pb-6 pl-6"> + <div className="system-xs-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}hitChunks`, { ns: 'datasetHitTesting', num: child_chunks.length })}</div> + <div className={cn('mt-1 space-y-2', heighClassName)}> + {child_chunks.map(item => ( + <ChildChunksItem key={item.id} payload={item} isShowAll /> + ))} + </div> </div> )} </div> - - {isParentChildRetrieval && ( - <div className="flex-1 pb-6 pl-6"> - <div className="system-xs-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}hitChunks`, { ns: 'datasetHitTesting', num: child_chunks.length })}</div> - <div className={cn('mt-1 space-y-2', heighClassName)}> - {child_chunks.map(item => ( - <ChildChunksItem key={item.id} payload={item} isShowAll /> - ))} - </div> - </div> - )} - </div> - </Modal> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/datasets/hit-testing/components/result-item-external.tsx b/web/app/components/datasets/hit-testing/components/result-item-external.tsx index 68e9930665..9f68e054d1 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-external.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-external.tsx @@ -2,11 +2,11 @@ import type { FC } from 'react' import type { ExternalKnowledgeBaseHitTesting } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { useBoolean } from 'ahooks' import * as React from 'react' import { useTranslation } from 'react-i18next' import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' -import Modal from '@/app/components/base/modal' import ResultItemFooter from './result-item-footer' import ResultItemMeta from './result-item-meta' @@ -38,20 +38,27 @@ const ResultItemExternal: FC<Props> = ({ payload, positionId }) => { <ResultItemFooter docType={FileAppearanceTypeEnum.custom} docTitle={title} showDetailModal={showDetailModal} /> {isShowDetailModal && ( - <Modal - title={t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })} - className="min-w-[800px]!" - closable - onClose={hideDetailModal} - isShow={isShowDetailModal} + <Dialog + open={isShowDetailModal} + onOpenChange={(open) => { + if (!open) + hideDetailModal() + }} > - <div className="mt-4 flex-1"> - <ResultItemMeta labelPrefix="Chunk" positionId={positionId} wordCount={content.length} score={score} /> - <div className={cn('mt-2 body-md-regular break-all text-text-secondary', 'h-[min(539px,80vh)] overflow-y-auto')}> - {content} + <DialogContent className="w-full min-w-[800px]! overflow-hidden! border-none text-left align-middle"> + <DialogCloseButton data-testid="modal-close-button" /> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })} + </DialogTitle> + + <div className="mt-4 flex-1"> + <ResultItemMeta labelPrefix="Chunk" positionId={positionId} wordCount={content.length} score={score} /> + <div className={cn('mt-2 body-md-regular break-all text-text-secondary', 'h-[min(539px,80vh)] overflow-y-auto')}> + {content} + </div> </div> - </div> - </Modal> + </DialogContent> + </Dialog> )} </div> ) diff --git a/web/app/components/datasets/metadata/base/date-picker.tsx b/web/app/components/datasets/metadata/base/date-picker.tsx index 79c61469de..ed583b8411 100644 --- a/web/app/components/datasets/metadata/base/date-picker.tsx +++ b/web/app/components/datasets/metadata/base/date-picker.tsx @@ -70,7 +70,6 @@ const WrappedDatePicker = ({ onClear={handleDateChange} renderTrigger={renderTrigger} triggerWrapClassName="w-full" - popupZIndexClassname="z-1000" /> ) } diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx index f76284b36f..a8b5875541 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { BuiltInMetadataItem, MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { produce } from 'immer' import * as React from 'react' @@ -11,7 +12,6 @@ import Divider from '@/app/components/base/divider' import { useCreateMetaData } from '@/service/knowledge/use-metadata' import Checkbox from '../../../base/checkbox' import { Infotip } from '../../../base/infotip' -import Modal from '../../../base/modal' import AddMetadataButton from '../add-metadata-button' import useCheckMetadataName from '../hooks/use-check-metadata-name' import SelectMetadataModal from '../metadata-dataset/select-metadata-modal' @@ -90,49 +90,62 @@ const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSav onSave(templeList.filter(item => item.updateType !== UpdateType.delete), addedList, isApplyToAllSelectDocument) }, [templeList, addedList, isApplyToAllSelectDocument, onSave]) return ( - <Modal title={t(`${i18nPrefix}.editMetadata`, { ns: 'dataset' })} isShow closable onClose={onHide} className="!max-w-[640px]"> - <div className="mt-1 system-xs-medium text-text-accent">{t(`${i18nPrefix}.editDocumentsNum`, { ns: 'dataset', num: documentNum })}</div> - <div className="max-h-[305px] overflow-x-hidden overflow-y-auto"> - <div className="mt-4 space-y-2"> - {templeList.map(item => (<EditMetadataBatchItem key={item.id} payload={item} onChange={handleTemplesChange} onRemove={handleTempleItemRemove} onReset={handleItemReset} />))} - </div> - <div className="mt-4 pl-[18px]"> - <div className="flex items-center"> - <div className="mr-2 shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('metadata.createMetadata.title', { ns: 'dataset' })}</div> - <Divider bgStyle="gradient" /> - </div> - <div className="mt-2 space-y-2"> - {addedList.map((item, i) => (<AddedMetadataItem key={i} payload={item} onChange={handleAddedListChange} onRemove={handleAddedItemRemove(i)} />))} - </div> - <div className="mt-3"> - <SelectMetadataModal datasetId={datasetId} popupPlacement="top-start" popupOffset={{ mainAxis: 4, crossAxis: 0 }} trigger={<AddMetadataButton />} onSave={handleAddMetaData} onSelect={data => setAddedList([...addedList, data as MetadataItemWithEdit])} onManage={onShowManage} /> - </div> - </div> - </div> + <Dialog + open + onOpenChange={(open) => { + if (!open) + onHide() + }} + > + <DialogContent className="w-full !max-w-[640px] overflow-hidden! border-none text-left align-middle"> + <DialogCloseButton data-testid="modal-close-button" /> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(`${i18nPrefix}.editMetadata`, { ns: 'dataset' })} + </DialogTitle> - <div className="mt-4 flex items-center justify-between"> - <div className="flex items-center select-none"> - <Checkbox checked={isApplyToAllSelectDocument} onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)} id="apply-to-all" /> - <div className="mr-1 ml-2 system-xs-medium text-text-secondary">{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}</div> - <Infotip - aria-label={t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })} - className="p-px" - iconClassName="size-3.5 text-text-tertiary" - popupClassName="max-w-[240px]" - > - {t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })} - </Infotip> + <div className="mt-1 system-xs-medium text-text-accent">{t(`${i18nPrefix}.editDocumentsNum`, { ns: 'dataset', num: documentNum })}</div> + <div className="max-h-[305px] overflow-x-hidden overflow-y-auto"> + <div className="mt-4 space-y-2"> + {templeList.map(item => (<EditMetadataBatchItem key={item.id} payload={item} onChange={handleTemplesChange} onRemove={handleTempleItemRemove} onReset={handleItemReset} />))} + </div> + <div className="mt-4 pl-[18px]"> + <div className="flex items-center"> + <div className="mr-2 shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('metadata.createMetadata.title', { ns: 'dataset' })}</div> + <Divider bgStyle="gradient" /> + </div> + <div className="mt-2 space-y-2"> + {addedList.map((item, i) => (<AddedMetadataItem key={i} payload={item} onChange={handleAddedListChange} onRemove={handleAddedItemRemove(i)} />))} + </div> + <div className="mt-3"> + <SelectMetadataModal datasetId={datasetId} popupPlacement="top-start" popupOffset={{ mainAxis: 4, crossAxis: 0 }} trigger={<AddMetadataButton />} onSave={handleAddMetaData} onSelect={data => setAddedList([...addedList, data as MetadataItemWithEdit])} onManage={onShowManage} /> + </div> + </div> </div> - <div className="flex items-center space-x-2"> - <Button onClick={onHide}> - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button onClick={handleSave} variant="primary"> - {t('operation.save', { ns: 'common' })} - </Button> + + <div className="mt-4 flex items-center justify-between"> + <div className="flex items-center select-none"> + <Checkbox checked={isApplyToAllSelectDocument} onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)} id="apply-to-all" /> + <div className="mr-1 ml-2 system-xs-medium text-text-secondary">{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}</div> + <Infotip + aria-label={t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })} + className="p-px" + iconClassName="size-3.5 text-text-tertiary" + popupClassName="max-w-[240px]" + > + {t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })} + </Infotip> + </div> + <div className="flex items-center space-x-2"> + <Button onClick={onHide}> + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button onClick={handleSave} variant="primary"> + {t('operation.save', { ns: 'common' })} + </Button> + </div> </div> - </div> - </Modal> + </DialogContent> + </Dialog> ) } export default React.memo(EditMetadataBatchModal) diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx index 8070061776..da77a50419 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx @@ -1,148 +1,117 @@ +import type { Props as CreateContentProps } from '../create-content' +import { Popover } from '@langgenius/dify-ui/popover' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { DataType } from '../../types' import CreateContent from '../create-content' -type ModalLikeWrapProps = { - children: React.ReactNode - title: string - onClose?: () => void - onConfirm: () => void - beforeHeader?: React.ReactNode +const renderCreateContent = (props: CreateContentProps) => { + return render( + <Popover open> + <CreateContent {...props} /> + </Popover>, + ) } -type OptionCardProps = { - title: string - selected: boolean - onSelect: () => void -} - -type FieldProps = { - label: string - children: React.ReactNode -} - -// Mock ModalLikeWrap -vi.mock('../../../../base/modal-like-wrap', () => ({ - default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => ( - <div data-testid="modal-wrap"> - <div data-testid="modal-title">{title}</div> - {!!beforeHeader && <div data-testid="before-header">{beforeHeader}</div>} - <div data-testid="modal-content">{children}</div> - <button data-testid="close-btn" onClick={onClose}>Close</button> - <button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button> - </div> - ), -})) - -// Mock OptionCard -vi.mock('../../../../workflow/nodes/_base/components/option-card', () => ({ - default: ({ title, selected, onSelect }: OptionCardProps) => ( - <button - data-testid={`option-${title.toLowerCase()}`} - data-selected={selected} - onClick={onSelect} - > - {title} - </button> - ), -})) - -// Mock Field -vi.mock('../field', () => ({ - default: ({ label, children }: FieldProps) => ( - <div data-testid="field"> - <label data-testid="field-label">{label}</label> - {children} - </div> - ), -})) - describe('CreateContent', () => { describe('Rendering', () => { it('should render without crashing', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) - expect(screen.getByTestId('modal-wrap')).toBeInTheDocument() + renderCreateContent({ onSave: handleSave }) + expect(screen.getByText('dataset.metadata.createMetadata.title')).toBeInTheDocument() }) it('should render modal title', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) - expect(screen.getByTestId('modal-title')).toBeInTheDocument() + renderCreateContent({ onSave: handleSave }) + expect(screen.getByText('dataset.metadata.createMetadata.title')).toBeInTheDocument() }) it('should render type selection options', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) - expect(screen.getByTestId('option-string')).toBeInTheDocument() - expect(screen.getByTestId('option-number')).toBeInTheDocument() - expect(screen.getByTestId('option-time')).toBeInTheDocument() + renderCreateContent({ onSave: handleSave }) + expect(screen.getByText('String')).toBeInTheDocument() + expect(screen.getByText('Number')).toBeInTheDocument() + expect(screen.getByText('Time')).toBeInTheDocument() }) it('should render name input field', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render confirm button', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) - expect(screen.getByTestId('confirm-btn')).toBeInTheDocument() + renderCreateContent({ onSave: handleSave }) + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() }) it('should render close button', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) - expect(screen.getByTestId('close-btn')).toBeInTheDocument() + renderCreateContent({ onSave: handleSave }) + expect(screen.getByTestId('modal-close-btn')).toBeInTheDocument() }) }) describe('Type Selection', () => { - it('should default to string type', () => { + it('should save string type by default', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) - expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true') + renderCreateContent({ onSave: handleSave }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(handleSave).toHaveBeenCalledWith({ + type: DataType.string, + name: '', + }) }) - it('should select number type when clicked', () => { + it('should save number type when number is clicked', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) - fireEvent.click(screen.getByTestId('option-number')) + fireEvent.click(screen.getByText('Number')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true') + expect(handleSave).toHaveBeenCalledWith({ + type: DataType.number, + name: '', + }) }) - it('should select time type when clicked', () => { + it('should save time type when time is clicked', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) - fireEvent.click(screen.getByTestId('option-time')) + fireEvent.click(screen.getByText('Time')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(screen.getByTestId('option-time')).toHaveAttribute('data-selected', 'true') + expect(handleSave).toHaveBeenCalledWith({ + type: DataType.time, + name: '', + }) }) - it('should deselect previous type when new type is selected', () => { + it('should use the latest selected type when type changes', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) - // Initially string is selected - expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true') + fireEvent.click(screen.getByText('Number')) + fireEvent.click(screen.getByText('Time')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - // Select number - fireEvent.click(screen.getByTestId('option-number')) - - expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'false') - expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true') + expect(handleSave).toHaveBeenCalledWith({ + type: DataType.time, + name: '', + }) }) }) describe('Name Input', () => { it('should update name when typing', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new_field' } }) @@ -152,7 +121,7 @@ describe('CreateContent', () => { it('should start with empty name', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) expect(screen.getByRole('textbox')).toHaveValue('') }) @@ -161,11 +130,11 @@ describe('CreateContent', () => { describe('User Interactions', () => { it('should call onSave with type and name when confirmed', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'test_field' } }) - fireEvent.click(screen.getByTestId('confirm-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(handleSave).toHaveBeenCalledWith({ type: DataType.string, @@ -175,12 +144,12 @@ describe('CreateContent', () => { it('should call onSave with correct type after changing type', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) - fireEvent.click(screen.getByTestId('option-number')) + fireEvent.click(screen.getByText('Number')) const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'num_field' } }) - fireEvent.click(screen.getByTestId('confirm-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(handleSave).toHaveBeenCalledWith({ type: DataType.number, @@ -191,9 +160,9 @@ describe('CreateContent', () => { it('should call onClose when close button is clicked', () => { const handleSave = vi.fn() const handleClose = vi.fn() - render(<CreateContent onSave={handleSave} onClose={handleClose} />) + renderCreateContent({ onSave: handleSave, onClose: handleClose }) - fireEvent.click(screen.getByTestId('close-btn')) + fireEvent.click(screen.getByTestId('modal-close-btn')) expect(handleClose).toHaveBeenCalled() }) @@ -202,40 +171,35 @@ describe('CreateContent', () => { describe('Back Button', () => { it('should show back button when hasBack is true', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} hasBack />) + renderCreateContent({ onSave: handleSave, hasBack: true }) - expect(screen.getByTestId('before-header')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ })).toBeInTheDocument() }) it('should not show back button when hasBack is false', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} hasBack={false} />) + renderCreateContent({ onSave: handleSave, hasBack: false }) - expect(screen.queryByTestId('before-header')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ })).not.toBeInTheDocument() }) it('should call onBack when back button is clicked', () => { const handleSave = vi.fn() const handleBack = vi.fn() - render(<CreateContent onSave={handleSave} hasBack onBack={handleBack} />) + renderCreateContent({ onSave: handleSave, hasBack: true, onBack: handleBack }) - const backButton = screen.getByTestId('before-header') - // Find the clickable element inside - const clickable = backButton.querySelector('.cursor-pointer') || backButton.firstChild - if (clickable) - fireEvent.click(clickable) + fireEvent.click(screen.getByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ })) - // The back functionality is tested through the actual implementation - expect(screen.getByTestId('before-header')).toBeInTheDocument() + expect(handleBack).toHaveBeenCalled() }) }) describe('Edge Cases', () => { it('should handle empty name submission', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) - fireEvent.click(screen.getByTestId('confirm-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(handleSave).toHaveBeenCalledWith({ type: DataType.string, @@ -245,19 +209,23 @@ describe('CreateContent', () => { it('should handle type cycling', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) // Cycle through all types - fireEvent.click(screen.getByTestId('option-number')) - fireEvent.click(screen.getByTestId('option-time')) - fireEvent.click(screen.getByTestId('option-string')) + fireEvent.click(screen.getByText('Number')) + fireEvent.click(screen.getByText('Time')) + fireEvent.click(screen.getByText('String')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true') + expect(handleSave).toHaveBeenCalledWith({ + type: DataType.string, + name: '', + }) }) it('should handle special characters in name', () => { const handleSave = vi.fn() - render(<CreateContent onSave={handleSave} />) + renderCreateContent({ onSave: handleSave }) const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'test_field_123' } }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index 55e5f9de65..f05bd53b7f 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -135,6 +135,19 @@ describe('DatasetMetadataDrawer', () => { }) describe('User Interactions', () => { + it('should call onClose when drawer close button is clicked', async () => { + const onClose = vi.fn() + render(<DatasetMetadataDrawer {...defaultProps} onClose={onClose} />) + + await waitFor(() => { + expect(screen.getByRole('dialog'))!.toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('close-icon')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + it('should call onIsBuiltInEnabledChange when switch is toggled', async () => { const onIsBuiltInEnabledChange = vi.fn() render( @@ -280,8 +293,8 @@ describe('DatasetMetadataDrawer', () => { expect(inputs.length).toBeGreaterThan(0) }) - const inputs = document.querySelectorAll('input') - fireEvent.change(inputs[0]!, { target: { value: 'renamed_field' } }) + const input = screen.getByPlaceholderText('dataset.metadata.datasetMetadata.namePlaceholder') + fireEvent.change(input, { target: { value: 'renamed_field' } }) // Find and click save button fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) @@ -322,8 +335,8 @@ describe('DatasetMetadataDrawer', () => { }) // Change name first - const inputs = document.querySelectorAll('input') - fireEvent.change(inputs[0]!, { target: { value: 'changed_name' } }) + const input = screen.getByPlaceholderText('dataset.metadata.datasetMetadata.namePlaceholder') + fireEvent.change(input, { target: { value: 'changed_name' } }) // Find and click cancel button fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) @@ -335,7 +348,7 @@ describe('DatasetMetadataDrawer', () => { }) }) - it('should close rename modal when modal close button is clicked', async () => { + it('should close rename modal when dialog requests close', async () => { render(<DatasetMetadataDrawer {...defaultProps} />) await waitFor(() => { @@ -357,23 +370,12 @@ describe('DatasetMetadataDrawer', () => { expect(inputs.length).toBeGreaterThan(0) }) - // Find and click the modal close button (X button) - // The Modal component has a close button in the header - const dialogs = screen.getAllByRole('dialog') - const renameModal = dialogs.find(d => d.querySelector('input')) - if (renameModal) { - // Find close button by looking for a button with close-related class or X icon - const closeButtons = renameModal.querySelectorAll('button') - for (const btn of Array.from(closeButtons)) { - // Skip cancel/save buttons - if (!btn.textContent?.toLowerCase().includes('cancel') - && !btn.textContent?.toLowerCase().includes('save') - && btn.querySelector('svg')) { - fireEvent.click(btn) - break - } - } - } + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'dataset.metadata.datasetMetadata.rename' })).not.toBeInTheDocument() + expect(screen.getAllByRole('dialog')).toHaveLength(1) + }) }) }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx index ee1b9cbcdc..b0824d14a9 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx @@ -1,12 +1,13 @@ 'use client' import type { FC } from 'react' -import { RiArrowLeftLine } from '@remixicon/react' +import type { BuiltInMetadataItem } from '../types' +import { Button } from '@langgenius/dify-ui/button' +import { PopoverTitle } from '@langgenius/dify-ui/popover' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import ModalLikeWrap from '../../../base/modal-like-wrap' import OptionCard from '../../../workflow/nodes/_base/components/option-card' import { DataType } from '../types' import Field from './field' @@ -15,7 +16,7 @@ const i18nPrefix = 'metadata.createMetadata' export type Props = { onClose?: () => void - onSave: (data: any) => void + onSave: (data: BuiltInMetadataItem) => void hasBack?: boolean onBack?: () => void } @@ -45,47 +46,77 @@ const CreateContent: FC<Props> = ({ }, [onSave, type, name]) return ( - <ModalLikeWrap - title={t(`${i18nPrefix}.title`, { ns: 'dataset' })} - onClose={onClose} - onConfirm={handleSave} - hideCloseBtn={hasBack} - beforeHeader={hasBack && ( - <div className="relative left-[-4px] mb-1 flex cursor-pointer items-center space-x-1 py-1 text-text-accent" onClick={onBack}> - <RiArrowLeftLine className="size-4" /> - <div className="system-xs-semibold-uppercase">{t(`${i18nPrefix}.back`, { ns: 'dataset' })}</div> - </div> + <div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-3 pt-3.5 pb-4 shadow-xl"> + {hasBack && ( + <button + type="button" + className="relative left-[-4px] mb-1 flex cursor-pointer items-center space-x-1 py-1 text-text-accent" + onClick={onBack} + > + <span className="i-ri-arrow-left-line size-4" /> + <span className="system-xs-semibold-uppercase">{t(`${i18nPrefix}.back`, { ns: 'dataset' })}</span> + </button> )} - > - <div className="space-y-3"> - <Field label={t(`${i18nPrefix}.type`, { ns: 'dataset' })}> - <div className="grid grid-cols-3 gap-2"> - <OptionCard - title="String" - selected={type === DataType.string} - onSelect={handleTypeChange(DataType.string)} - /> - <OptionCard - title="Number" - selected={type === DataType.number} - onSelect={handleTypeChange(DataType.number)} - /> - <OptionCard - title="Time" - selected={type === DataType.time} - onSelect={handleTypeChange(DataType.time)} - /> - </div> - </Field> - <Field label={t(`${i18nPrefix}.name`, { ns: 'dataset' })}> - <Input - value={name} - onChange={handleNameChange} - placeholder={t(`${i18nPrefix}.namePlaceholder`, { ns: 'dataset' })} - /> - </Field> + <div className="mb-1 flex h-6 items-center justify-between"> + <PopoverTitle className="system-xl-semibold text-text-primary"> + {t(`${i18nPrefix}.title`, { ns: 'dataset' })} + </PopoverTitle> + {!hasBack && ( + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="cursor-pointer p-1.5 text-text-tertiary" + onClick={onClose} + > + <span className="i-ri-close-line size-4" data-testid="modal-close-btn" /> + </button> + )} </div> - </ModalLikeWrap> + <div className="mt-2"> + <div className="space-y-3"> + <Field label={t(`${i18nPrefix}.type`, { ns: 'dataset' })}> + <div className="grid grid-cols-3 gap-2"> + <OptionCard + title="String" + selected={type === DataType.string} + onSelect={handleTypeChange(DataType.string)} + /> + <OptionCard + title="Number" + selected={type === DataType.number} + onSelect={handleTypeChange(DataType.number)} + /> + <OptionCard + title="Time" + selected={type === DataType.time} + onSelect={handleTypeChange(DataType.time)} + /> + </div> + </Field> + <Field label={t(`${i18nPrefix}.name`, { ns: 'dataset' })}> + <Input + value={name} + onChange={handleNameChange} + placeholder={t(`${i18nPrefix}.namePlaceholder`, { ns: 'dataset' })} + /> + </Field> + </div> + </div> + <div className="mt-4 flex justify-end"> + <Button + className="mr-2" + onClick={onClose} + > + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button + onClick={handleSave} + variant="primary" + > + {t('operation.save', { ns: 'common' })} + </Button> + </div> + </div> ) } export default React.memo(CreateContent) diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx index 687266eed9..fad6fd6bb4 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx @@ -8,7 +8,6 @@ import CreateContent from './create-content' type Props = { open: boolean setOpen: (open: boolean) => void - onSave: (data: any) => void trigger: React.ReactNode popupLeft?: number } & CreateContentProps diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx index 69a8712c67..b6597e5f51 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx @@ -12,6 +12,17 @@ import { } from '@langgenius/dify-ui/alert-dialog' 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 { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' import { RiAddLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react' @@ -19,10 +30,8 @@ import { useBoolean, useHover } from 'ahooks' import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import Drawer from '@/app/components/base/drawer' import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import CreateModal from '@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal' import { getIcon } from '../utils/get-icon' import Field from './field' @@ -149,7 +158,7 @@ const DatasetMetadataDrawer: FC<Props> = ({ }, [setCurrPayload, setIsShowRenameModal]) const [open, setOpen] = useState(false) - const handleAdd = useCallback(async (data: MetadataItemWithValueLength) => { + const handleAdd = useCallback(async (data: BuiltInMetadataItem) => { await onAdd(data) toast.success(t('api.actionSuccess', { ns: 'common' })) setOpen(false) @@ -176,91 +185,123 @@ const DatasetMetadataDrawer: FC<Props> = ({ return ( <Drawer - isOpen={true} - onClose={onClose} - showClose - title={t('metadata.metadata', { ns: 'dataset' })} - footer={null} - panelClassName="px-4 block max-w-[420px]! my-2 rounded-l-2xl" + open + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="h-full overflow-y-auto"> - <div className="system-sm-regular text-text-tertiary">{t(`${i18nPrefix}.description`, { ns: 'dataset' })}</div> - <CreateModal - open={open} - setOpen={setOpen} - trigger={( - <Button variant="primary" className="mt-3"> - <RiAddLine className="mr-1" /> - {t(`${i18nPrefix}.addMetaData`, { ns: 'dataset' })} - </Button> - )} - hasBack - onSave={handleAdd} - /> + <DrawerPortal> + <DrawerBackdrop /> + <DrawerViewport> + <DrawerPopup className="data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[420px]"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="flex shrink-0 justify-between px-4 pt-6 pb-4"> + <DrawerTitle className="text-lg leading-6 font-medium text-text-primary"> + {t('metadata.metadata', { ns: 'dataset' })} + </DrawerTitle> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + data-testid="close-icon" + /> + </div> + <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6"> + <div className="system-sm-regular text-text-tertiary">{t(`${i18nPrefix}.description`, { ns: 'dataset' })}</div> + <CreateModal + open={open} + setOpen={setOpen} + trigger={( + <Button variant="primary" className="mt-3"> + <RiAddLine className="mr-1" /> + {t(`${i18nPrefix}.addMetaData`, { ns: 'dataset' })} + </Button> + )} + hasBack + onSave={handleAdd} + /> - <div className="mt-3 space-y-1"> - {userMetadata.map(payload => ( - <Item - key={payload.id} - payload={payload} - onRename={handleRename(payload)} - onDelete={handleDelete(payload)} - /> - ))} - </div> + <div className="mt-3 space-y-1"> + {userMetadata.map(payload => ( + <Item + key={payload.id} + payload={payload} + onRename={handleRename(payload)} + onDelete={handleDelete(payload)} + /> + ))} + </div> - <div className="mt-3 flex h-6 items-center"> - <Switch - checked={isBuiltInEnabled} - onCheckedChange={onIsBuiltInEnabledChange} - /> - <div className="mr-0.5 ml-2 system-sm-semibold text-text-secondary">{t(`${i18nPrefix}.builtIn`, { ns: 'dataset' })}</div> - <Infotip aria-label={t(`${i18nPrefix}.builtInDescription`, { ns: 'dataset' })} popupClassName="max-w-[100px]"> - {t(`${i18nPrefix}.builtInDescription`, { ns: 'dataset' })} - </Infotip> - </div> + <div className="mt-3 flex h-6 items-center"> + <Switch + checked={isBuiltInEnabled} + onCheckedChange={onIsBuiltInEnabledChange} + /> + <div className="mr-0.5 ml-2 system-sm-semibold text-text-secondary">{t(`${i18nPrefix}.builtIn`, { ns: 'dataset' })}</div> + <Infotip aria-label={t(`${i18nPrefix}.builtInDescription`, { ns: 'dataset' })} popupClassName="max-w-[100px]"> + {t(`${i18nPrefix}.builtInDescription`, { ns: 'dataset' })} + </Infotip> + </div> - <div className="mt-1 space-y-1"> - {builtInMetadata.map(payload => ( - <Item - key={payload.name} - readonly - disabled={!isBuiltInEnabled} - payload={payload as MetadataItemWithValueLength} - /> - ))} - </div> + <div className="mt-1 space-y-1"> + {builtInMetadata.map(payload => ( + <Item + key={payload.name} + readonly + disabled={!isBuiltInEnabled} + payload={payload as MetadataItemWithValueLength} + /> + ))} + </div> - {isShowRenameModal && ( - <Modal isShow title={t(`${i18nPrefix}.rename`, { ns: 'dataset' })} onClose={() => setIsShowRenameModal(false)}> - <Field label={t(`${i18nPrefix}.name`, { ns: 'dataset' })} className="mt-4"> - <Input - value={templeName} - onChange={e => setTempleName(e.target.value)} - placeholder={t(`${i18nPrefix}.namePlaceholder`, { ns: 'dataset' })} - /> - </Field> - <div className="mt-4 flex justify-end"> - <Button - className="mr-2" - onClick={() => { - setIsShowRenameModal(false) - setTempleName(currPayload!.name) - }} - > - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button - onClick={handleRenamed} - variant="primary" - disabled={!templeName} - > - {t('operation.save', { ns: 'common' })} - </Button> - </div> - </Modal> - )} - </div> + {isShowRenameModal && ( + <Dialog + open + onOpenChange={(open) => { + if (!open) + setIsShowRenameModal(false) + }} + > + <DialogContent className="overflow-hidden! border-none text-left align-middle"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(`${i18nPrefix}.rename`, { ns: 'dataset' })} + </DialogTitle> + + <Field label={t(`${i18nPrefix}.name`, { ns: 'dataset' })} className="mt-4"> + <Input + value={templeName} + onChange={e => setTempleName(e.target.value)} + placeholder={t(`${i18nPrefix}.namePlaceholder`, { ns: 'dataset' })} + /> + </Field> + <div className="mt-4 flex justify-end"> + <Button + className="mr-2" + onClick={() => { + setIsShowRenameModal(false) + setTempleName(currPayload!.name) + }} + > + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button + onClick={handleRenamed} + variant="primary" + disabled={!templeName} + > + {t('operation.save', { ns: 'common' })} + </Button> + </div> + </DialogContent> + </Dialog> + )} + </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> ) } diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx b/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx index e0ed692677..352b95f248 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx @@ -1,7 +1,7 @@ 'use client' import type { Placement } from '@langgenius/dify-ui/popover' import type { FC } from 'react' -import type { MetadataItem } from '../types' +import type { BuiltInMetadataItem, MetadataItem } from '../types' import type { Props as CreateContentProps } from './create-content' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' @@ -15,15 +15,16 @@ type Props = { popupPlacement?: Placement popupOffset?: { mainAxis: number, crossAxis: number } onSelect: (data: MetadataItem) => void - onSave: (data: MetadataItem) => void trigger: React.ReactNode onManage: () => void } & CreateContentProps -enum Step { - select = 'select', - create = 'create', -} +const Step = { + select: 'select', + create: 'create', +} as const + +type Step = typeof Step[keyof typeof Step] const SelectMetadataModal: FC<Props> = ({ datasetId, @@ -37,7 +38,7 @@ const SelectMetadataModal: FC<Props> = ({ const { data: datasetMetaData } = useDatasetMetaData(datasetId) const [open, setOpen] = useState(false) - const [step, setStep] = useState(Step.select) + const [step, setStep] = useState<Step>(Step.select) const triggerElement = React.isValidElement(trigger) ? trigger : <button type="button">{trigger}</button> @@ -47,7 +48,7 @@ const SelectMetadataModal: FC<Props> = ({ setStep(Step.select) }, []) - const handleSave = useCallback(async (data: MetadataItem) => { + const handleSave = useCallback(async (data: BuiltInMetadataItem) => { await onSave(data) setStep(Step.select) }, [onSave]) diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx index 16f5e573fb..5d174b2534 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx +++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { MetadataItemWithValue } from '../types' +import type { BuiltInMetadataItem, MetadataItemWithValue } from '../types' import { cn } from '@langgenius/dify-ui/cn' import { RiDeleteBinLine } from '@remixicon/react' import * as React from 'react' @@ -29,7 +29,7 @@ type Props = { onChange?: (item: MetadataItemWithValue) => void onDelete?: (item: MetadataItemWithValue) => void onSelect?: (item: MetadataItemWithValue) => void - onAdd?: (item: MetadataItemWithValue) => void + onAdd?: (item: BuiltInMetadataItem) => void } const InfoGroup: FC<Props> = ({ diff --git a/web/app/components/datasets/rename-modal/index.tsx b/web/app/components/datasets/rename-modal/index.tsx index a62c1c93be..ae8f9f5d6a 100644 --- a/web/app/components/datasets/rename-modal/index.tsx +++ b/web/app/components/datasets/rename-modal/index.tsx @@ -4,13 +4,12 @@ import type { AppIconSelection } from '../../base/app-icon-picker' import type { DataSet } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import Textarea from '@/app/components/base/textarea' import { updateDatasetSetting } from '@/service/datasets' import AppIcon from '../../base/app-icon' @@ -89,38 +88,41 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset } }, [appIcon, description, dataset.id, externalKnowledgeApiId, externalKnowledgeId, name, onClose, onSuccess, t]) return ( - <Modal className="w-[520px] max-w-[520px] rounded-xl px-8 py-6" isShow={show} onClose={noop}> - <div className="flex items-center justify-between pb-2"> - <div className="text-xl leading-[30px] font-medium text-text-primary">{t('title', { ns: 'datasetSettings' })}</div> - <div className="cursor-pointer p-2" onClick={onClose}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> - </div> - <div> - <div className={cn('flex flex-col py-4')}> - <div className="shrink-0 py-2 text-sm leading-[20px] font-medium text-text-primary"> - {t('form.name', { ns: 'datasetSettings' })} - </div> - <div className="flex items-center gap-x-2"> - <AppIcon size="medium" onClick={handleOpenAppIconPicker} className="cursor-pointer" iconType={appIcon.type} icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} background={appIcon.type === 'image' ? undefined : appIcon.background} imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} showEditIcon /> - <Input value={name} onChange={e => setName(e.target.value)} className="h-9 grow" placeholder={t('form.namePlaceholder', { ns: 'datasetSettings' }) || ''} /> + <Dialog open={show}> + <DialogContent className="w-full max-w-[520px] overflow-hidden! rounded-xl border-none px-8 py-6 text-left align-middle"> + + <div className="flex items-center justify-between pb-2"> + <div className="text-xl leading-[30px] font-medium text-text-primary">{t('title', { ns: 'datasetSettings' })}</div> + <div className="cursor-pointer p-2" onClick={onClose}> + <RiCloseLine className="h-4 w-4 text-text-tertiary" /> </div> </div> - <div className={cn('flex flex-col py-4')}> - <div className="shrink-0 py-2 text-sm leading-[20px] font-medium text-text-primary"> - {t('form.desc', { ns: 'datasetSettings' })} + <div> + <div className={cn('flex flex-col py-4')}> + <div className="shrink-0 py-2 text-sm leading-[20px] font-medium text-text-primary"> + {t('form.name', { ns: 'datasetSettings' })} + </div> + <div className="flex items-center gap-x-2"> + <AppIcon size="medium" onClick={handleOpenAppIconPicker} className="cursor-pointer" iconType={appIcon.type} icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} background={appIcon.type === 'image' ? undefined : appIcon.background} imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} showEditIcon /> + <Input value={name} onChange={e => setName(e.target.value)} className="h-9 grow" placeholder={t('form.namePlaceholder', { ns: 'datasetSettings' }) || ''} /> + </div> </div> - <div className="w-full"> - <Textarea value={description} onChange={e => setDescription(e.target.value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} /> + <div className={cn('flex flex-col py-4')}> + <div className="shrink-0 py-2 text-sm leading-[20px] font-medium text-text-primary"> + {t('form.desc', { ns: 'datasetSettings' })} + </div> + <div className="w-full"> + <Textarea value={description} onChange={e => setDescription(e.target.value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} /> + </div> </div> </div> - </div> - <div className="flex justify-end pt-6"> - <Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button disabled={loading} variant="primary" onClick={onConfirm}>{t('operation.save', { ns: 'common' })}</Button> - </div> - {showAppIconPicker && (<AppIconPicker onSelect={handleSelectAppIcon} onClose={handleCloseAppIconPicker} />)} - </Modal> + <div className="flex justify-end pt-6"> + <Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button disabled={loading} variant="primary" onClick={onConfirm}>{t('operation.save', { ns: 'common' })}</Button> + </div> + {showAppIconPicker && (<AppIconPicker onSelect={handleSelectAppIcon} onClose={handleCloseAppIconPicker} />)} + </DialogContent> + </Dialog> ) } export default RenameDatasetModal diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx index 333ef45162..0dbf7318b2 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx @@ -88,7 +88,7 @@ describe('SecretKeyModal', () => { beforeEach(() => { vi.clearAllMocks() - // Suppress expected React act() warnings from Headless UI Dialog transitions and async API state updates + // Suppress expected React act() warnings from modal transitions and async API state updates. vi.spyOn(console, 'error').mockImplementation(() => {}) vi.useFakeTimers({ shouldAdvanceTime: true }) mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' }) @@ -290,6 +290,36 @@ describe('SecretKeyModal', () => { }) }) + it('should place the generated key backdrop above the API keys modal', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + mockAppApiKeysData.mockReturnValue({ + data: [ + { id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: null }, + ], + }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) + + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') + await act(async () => { + await user.click(createButton) + }) + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() + }) + + const parentDialog = screen.getByText('appApi.apiKeyModal.apiSecretKeyTips').closest('[role="dialog"]') + const generatedKeyDialog = screen.getByText('appApi.apiKeyModal.generateTips').closest('[role="dialog"]') + const backdrops = document.body.querySelectorAll('.bg-background-overlay') + const generatedKeyBackdrop = backdrops[1] + + expect(parentDialog).toBeInTheDocument() + expect(generatedKeyDialog).toBeInTheDocument() + expect(backdrops).toHaveLength(2) + expect(parentDialog!.compareDocumentPosition(generatedKeyBackdrop!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(generatedKeyBackdrop!.compareDocumentPosition(generatedKeyDialog!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + it('should invalidate app API keys after creating', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) diff --git a/web/app/components/develop/secret-key/secret-key-generate.tsx b/web/app/components/develop/secret-key/secret-key-generate.tsx index 1fb6e65714..cc9b4c778d 100644 --- a/web/app/components/develop/secret-key/secret-key-generate.tsx +++ b/web/app/components/develop/secret-key/secret-key-generate.tsx @@ -2,8 +2,9 @@ import type { CreateApiKeyResponse } from '@/models/app' import { XMarkIcon } from '@heroicons/react/20/solid' import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import InputCopy from './input-copy' import s from './style.module.css' @@ -22,21 +23,33 @@ const SecretKeyGenerateModal = ({ }: ISecretKeyGenerateModalProps) => { const { t } = useTranslation() return ( - <Modal isShow={isShow} onClose={onClose} title={`${t('apiKeyModal.apiSecretKey', { ns: 'appApi' })}`} className={`px-8 ${className}`}> - <div className="-mt-6 -mr-2 mb-4 flex justify-end"> - <XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} /> - </div> - <p className="mt-1 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.generateTips', { ns: 'appApi' })}</p> - <div className="my-4"> - <InputCopy className="w-full" value={newKey?.token} /> - </div> - <div className="my-4 flex justify-end"> - <Button className={`shrink-0 ${s.w64}`} onClick={onClose}> - <span className="text-xs font-medium text-text-secondary">{t('actionMsg.ok', { ns: 'appApi' })}</span> - </Button> - </div> + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onClose() + }} + > + <DialogContent className={cn('w-full max-w-[480px] overflow-hidden! border-none px-8 text-left align-middle', className)}> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {`${t('apiKeyModal.apiSecretKey', { ns: 'appApi' })}`} + </DialogTitle> - </Modal> + <div className="-mt-6 -mr-2 mb-4 flex justify-end"> + <XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} /> + </div> + <p className="mt-1 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.generateTips', { ns: 'appApi' })}</p> + <div className="my-4"> + <InputCopy className="w-full" value={newKey?.token} /> + </div> + <div className="my-4 flex justify-end"> + <Button className={`shrink-0 ${s.w64}`} onClick={onClose}> + <span className="text-xs font-medium text-text-secondary">{t('actionMsg.ok', { ns: 'appApi' })}</span> + </Button> + </div> + + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/develop/secret-key/secret-key-modal.tsx b/web/app/components/develop/secret-key/secret-key-modal.tsx index e8d9204318..71cb87019c 100644 --- a/web/app/components/develop/secret-key/secret-key-modal.tsx +++ b/web/app/components/develop/secret-key/secret-key-modal.tsx @@ -11,6 +11,8 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { RiDeleteBinLine } from '@remixicon/react' import { useState, @@ -19,7 +21,6 @@ import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import CopyFeedback from '@/app/components/base/copy-feedback' import Loading from '@/app/components/base/loading' -import Modal from '@/app/components/base/modal' import { useAppContext } from '@/context/app-context' import useTimestamp from '@/hooks/use-timestamp' import { @@ -103,78 +104,99 @@ const SecretKeyModal = ({ setShowConfirmDelete(false) } + const handleClose = () => { + setVisible(false) + onClose() + } + return ( - <Modal isShow={isShow} onClose={onClose} title={`${t('apiKeyModal.apiSecretKey', { ns: 'appApi' })}`} className={`${s.customModal} flex flex-col px-8`}> - <div className="-mt-6 -mr-2 mb-4 flex justify-end"> - <XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} /> - </div> - <p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}</p> - {isApiKeysLoading && <div className="mt-4"><Loading /></div>} - { - !!apiKeysList?.data?.length && ( - <div className="mt-4 flex grow flex-col overflow-hidden"> - <div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary"> - <div className="w-64 shrink-0 px-3">{t('apiKeyModal.secretKey', { ns: 'appApi' })}</div> - <div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created', { ns: 'appApi' })}</div> - <div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed', { ns: 'appApi' })}</div> - <div className="grow px-3"></div> - </div> - <div className="grow overflow-auto"> - {apiKeysList.data.map(api => ( - <div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" key={api.id}> - <div className="w-64 shrink-0 truncate px-3 font-mono">{generateToken(api.token)}</div> - <div className="w-[200px] shrink-0 truncate px-3">{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}</div> - <div className="w-[200px] shrink-0 truncate px-3">{api.last_used_at ? formatTime(Number(api.last_used_at), t('dateTimeFormat', { ns: 'appLog' }) as string) : t('never', { ns: 'appApi' })}</div> - <div className="flex grow space-x-2 px-3"> - <CopyFeedback content={api.token} /> - {isCurrentWorkspaceManager && ( - <ActionButton - onClick={() => { - setDelKeyId(api.id) - setShowConfirmDelete(true) - }} - > - <RiDeleteBinLine className="h-4 w-4" /> - </ActionButton> - )} - </div> - </div> - ))} - </div> - </div> - ) - } - <div className="flex"> - <Button className={`mt-4 flex shrink-0 ${s.autoWidth}`} onClick={onCreate} disabled={!currentWorkspace || !isCurrentWorkspaceEditor}> - <PlusIcon className="mr-1 flex h-4 w-4 shrink-0" /> - <div className="text-xs font-medium text-text-secondary">{t('apiKeyModal.createNewSecretKey', { ns: 'appApi' })}</div> - </Button> - </div> - <SecretKeyGenerateModal className="shrink-0" isShow={isVisible} onClose={() => setVisible(false)} newKey={newKey} /> - <AlertDialog - open={showConfirmDelete} - onOpenChange={handleDeleteConfirmOpenChange} + <> + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + handleClose() + }} > - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t('actionMsg.deleteConfirmTitle', { ns: 'appApi' })} - </AlertDialogTitle> - <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t('actionMsg.deleteConfirmTips', { ns: 'appApi' })} - </AlertDialogDescription> + <DialogContent className={cn('max-h-[calc(100vh-80px)]! w-full max-w-[800px]! overflow-hidden! border-none text-left align-middle', `${s.customModal} flex flex-col px-8`)}> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {`${t('apiKeyModal.apiSecretKey', { ns: 'appApi' })}`} + </DialogTitle> + + <div className="-mt-6 -mr-2 mb-4 flex justify-end"> + <XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={handleClose} /> </div> - <AlertDialogActions> - <AlertDialogCancelButton> - {t('operation.cancel', { ns: 'common' })} - </AlertDialogCancelButton> - <AlertDialogConfirmButton onClick={onDel}> - {t('operation.confirm', { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> - </Modal> + <p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}</p> + {isApiKeysLoading && <div className="mt-4"><Loading /></div>} + { + !!apiKeysList?.data?.length && ( + <div className="mt-4 flex grow flex-col overflow-hidden"> + <div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary"> + <div className="w-64 shrink-0 px-3">{t('apiKeyModal.secretKey', { ns: 'appApi' })}</div> + <div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created', { ns: 'appApi' })}</div> + <div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed', { ns: 'appApi' })}</div> + <div className="grow px-3"></div> + </div> + <div className="grow overflow-auto"> + {apiKeysList.data.map(api => ( + <div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" key={api.id}> + <div className="w-64 shrink-0 truncate px-3 font-mono">{generateToken(api.token)}</div> + <div className="w-[200px] shrink-0 truncate px-3">{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}</div> + <div className="w-[200px] shrink-0 truncate px-3">{api.last_used_at ? formatTime(Number(api.last_used_at), t('dateTimeFormat', { ns: 'appLog' }) as string) : t('never', { ns: 'appApi' })}</div> + <div className="flex grow space-x-2 px-3"> + <CopyFeedback content={api.token} /> + {isCurrentWorkspaceManager && ( + <ActionButton + onClick={() => { + setDelKeyId(api.id) + setShowConfirmDelete(true) + }} + > + <RiDeleteBinLine className="h-4 w-4" /> + </ActionButton> + )} + </div> + </div> + ))} + </div> + </div> + ) + } + <div className="flex"> + <Button className={`mt-4 flex shrink-0 ${s.autoWidth}`} onClick={onCreate} disabled={!currentWorkspace || !isCurrentWorkspaceEditor}> + <PlusIcon className="mr-1 flex h-4 w-4 shrink-0" /> + <div className="text-xs font-medium text-text-secondary">{t('apiKeyModal.createNewSecretKey', { ns: 'appApi' })}</div> + </Button> + </div> + <AlertDialog + open={showConfirmDelete} + onOpenChange={handleDeleteConfirmOpenChange} + > + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> + {t('actionMsg.deleteConfirmTitle', { ns: 'appApi' })} + </AlertDialogTitle> + <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> + {t('actionMsg.deleteConfirmTips', { ns: 'appApi' })} + </AlertDialogDescription> + </div> + <AlertDialogActions> + <AlertDialogCancelButton> + {t('operation.cancel', { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={onDel}> + {t('operation.confirm', { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> + </DialogContent> + </Dialog> + {isShow && ( + <SecretKeyGenerateModal className="shrink-0" isShow={isVisible} onClose={() => setVisible(false)} newKey={newKey} /> + )} + </> ) } 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 b93598b64a..84d6332e67 100644 --- a/web/app/components/explore/try-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -120,6 +120,42 @@ describe('TryApp (main index.tsx)', () => { expect(document.body.querySelector('[role="status"]')).toBeInTheDocument() }) + + it('renders unavailable state when the app detail request fails', () => { + mockUseGetTryAppInfo.mockReturnValue({ + data: null, + isError: true, + error: new Error('App is unavailable'), + }) + + render( + <TryApp + appId="test-app-id" + onClose={vi.fn()} + onCreate={vi.fn()} + />, + ) + + expect(screen.getByText('App is unavailable')).toBeInTheDocument() + }) + + it('renders unknown unavailable state when app detail is empty', () => { + mockUseGetTryAppInfo.mockReturnValue({ + data: null, + isLoading: false, + isError: false, + }) + + render( + <TryApp + appId="test-app-id" + onClose={vi.fn()} + onCreate={vi.fn()} + />, + ) + + expect(screen.getByText('share.common.appUnknownError')).toBeInTheDocument() + }) }) describe('content rendering', () => { @@ -257,6 +293,26 @@ describe('TryApp (main index.tsx)', () => { expect(mockOnClose).toHaveBeenCalled() }) + + it('calls onClose when the dialog requests close', async () => { + const mockOnClose = vi.fn() + + render( + <TryApp + appId="test-app-id" + onClose={mockOnClose} + onCreate={vi.fn()} + />, + ) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) }) describe('create functionality', () => { diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index eb5ea952da..95e312c4c8 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -3,12 +3,12 @@ import type { FC } from 'react' import type { App as AppType } from '@/models/explore' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useState } from 'react' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' -import Modal from '@/app/components/base/modal/index' import { IS_CLOUD_EDITION } from '@/config' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useGetTryAppInfo } from '@/service/use-try-app' @@ -40,54 +40,59 @@ const TryApp: FC<Props> = ({ const { data: appDetail, isLoading, isError, error } = useGetTryAppInfo(appId) return ( - <Modal - isShow - onClose={onClose} - className="h-[calc(100vh-32px)] max-w-[calc(100vw-32px)] min-w-[1280px] overflow-x-auto p-2" + <Dialog + open + onOpenChange={(open) => { + if (!open) + onClose() + }} > - {isLoading ? ( - <div className="flex h-full items-center justify-center"> - <Loading type="area" /> - </div> - ) : isError ? ( - <div className="flex h-full items-center justify-center"> - <AppUnavailable className="h-auto w-auto" isUnknownReason={!error} unknownReason={error instanceof Error ? error.message : undefined} /> - </div> - ) : !appDetail ? ( - <div className="flex h-full items-center justify-center"> - <AppUnavailable className="h-auto w-auto" isUnknownReason /> - </div> - ) : ( - <div className="flex h-full flex-col"> - <div className="flex shrink-0 justify-between pl-4"> - <Tab - value={activeType} - onChange={setType} - disableTry={app ? !isTrialApp : false} - /> - <Button - size="large" - variant="tertiary" - className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text" - onClick={onClose} - > - <span className="i-ri-close-line size-5" /> - </Button> + <DialogContent className="h-[calc(100vh-32px)] max-h-none w-full max-w-[calc(100vw-32px)] min-w-[1280px] overflow-hidden overflow-x-auto border-none p-2 text-left align-middle"> + + {isLoading ? ( + <div className="flex h-full items-center justify-center"> + <Loading type="area" /> </div> - {/* Main content */} - <div className="mt-2 flex h-0 grow justify-between space-x-2"> - {activeType === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail} /> : <Preview appId={appId} appDetail={appDetail} />} - <AppInfo - className="w-[360px] shrink-0" - appDetail={appDetail} - appId={appId} - categories={categories} - onCreate={onCreate} - /> + ) : isError ? ( + <div className="flex h-full items-center justify-center"> + <AppUnavailable className="h-auto w-auto" isUnknownReason={!error} unknownReason={error instanceof Error ? error.message : undefined} /> </div> - </div> - )} - </Modal> + ) : !appDetail ? ( + <div className="flex h-full items-center justify-center"> + <AppUnavailable className="h-auto w-auto" isUnknownReason /> + </div> + ) : ( + <div className="flex h-full flex-col"> + <div className="flex shrink-0 justify-between pl-4"> + <Tab + value={activeType} + onChange={setType} + disableTry={app ? !isTrialApp : false} + /> + <Button + size="large" + variant="tertiary" + className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text" + onClick={onClose} + > + <span className="i-ri-close-line size-5" /> + </Button> + </div> + {/* Main content */} + <div className="mt-2 flex h-0 grow justify-between space-x-2"> + {activeType === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail} /> : <Preview appId={appId} appDetail={appDetail} />} + <AppInfo + className="w-[360px] shrink-0" + appDetail={appDetail} + appId={appId} + categories={categories} + onCreate={onCreate} + /> + </div> + </div> + )} + </DialogContent> + </Dialog> ) } export default React.memo(TryApp) diff --git a/web/app/components/header/account-about/__tests__/index.spec.tsx b/web/app/components/header/account-about/__tests__/index.spec.tsx index 694a632039..8ec7d8800d 100644 --- a/web/app/components/header/account-about/__tests__/index.spec.tsx +++ b/web/app/components/header/account-about/__tests__/index.spec.tsx @@ -90,7 +90,7 @@ describe('AccountAbout', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />) - // Modal uses Headless UI Dialog which renders into a portal, so we need to use document + // Modal content renders into a portal, so we need to use document. const closeButton = document.querySelector('div.absolute.cursor-pointer') if (!closeButton) diff --git a/web/app/components/header/account-about/index.tsx b/web/app/components/header/account-about/index.tsx index e2535144f9..ba3eba5993 100644 --- a/web/app/components/header/account-about/index.tsx +++ b/web/app/components/header/account-about/index.tsx @@ -1,15 +1,15 @@ 'use client' import type { LangGeniusVersionResponse } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { RiCloseLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import { useTranslation } from 'react-i18next' import DifyLogo from '@/app/components/base/logo/dify-logo' -import Modal from '@/app/components/base/modal' import { IS_CE_EDITION } from '@/config' -import Link from '@/next/link' +import Link from '@/next/link' import { systemFeaturesQueryOptions } from '@/service/system-features' type IAccountSettingProps = { @@ -26,87 +26,92 @@ export default function AccountAbout({ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return ( - <Modal - isShow - onClose={onCancel} - className="w-[480px]! max-w-[480px]! px-6! py-4!" + <Dialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} > - <div className="relative"> - <div className="absolute top-0 right-0 flex h-8 w-8 cursor-pointer items-center justify-center" onClick={onCancel}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> - <div className="flex flex-col items-center gap-4 py-8"> - {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? ( - <img - src={systemFeatures.branding.workspace_logo} - className="block h-7 w-auto object-contain" - alt="logo" - /> - ) - : <DifyLogo size="large" className="mx-auto" />} + <DialogContent className="w-[calc(100vw-2rem)]! max-w-[480px]! overflow-hidden! border-none px-6! py-4! text-left align-middle"> - <div className="text-center text-xs font-normal text-text-tertiary"> - Version - {langGeniusVersionInfo?.current_version} + <div className="relative"> + <div className="absolute top-0 right-0 flex h-8 w-8 cursor-pointer items-center justify-center" onClick={onCancel}> + <RiCloseLine className="h-4 w-4 text-text-tertiary" /> </div> - <div className="flex flex-col items-center gap-2 text-center text-xs font-normal text-text-secondary"> - <div> - © - {dayjs().year()} - {' '} - LangGenius, Inc., Contributors. + <div className="flex flex-col items-center gap-4 py-8"> + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo + ? ( + <img + src={systemFeatures.branding.workspace_logo} + className="block h-7 w-auto object-contain" + alt="logo" + /> + ) + : <DifyLogo size="large" className="mx-auto" />} + + <div className="text-center text-xs font-normal text-text-tertiary"> + Version + {langGeniusVersionInfo?.current_version} </div> - <div className="text-text-accent"> + <div className="flex flex-col items-center gap-2 text-center text-xs font-normal text-text-secondary"> + <div> + © + {dayjs().year()} + {' '} + LangGenius, Inc., Contributors. + </div> + <div className="text-text-accent"> + { + IS_CE_EDITION + ? <Link href="https://github.com/langgenius/dify/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">Open Source License</Link> + : ( + <> + <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</Link> + ,  + <Link href="https://dify.ai/terms" target="_blank" rel="noopener noreferrer">Terms of Service</Link> + </> + ) + } + </div> + </div> + </div> + <div className="-mx-6 mb-4 h-[0.5px] bg-divider-regular" /> + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0 text-xs font-medium text-text-tertiary"> { - IS_CE_EDITION - ? <Link href="https://github.com/langgenius/dify/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">Open Source License</Link> - : ( - <> - <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</Link> - ,  - <Link href="https://dify.ai/terms" target="_blank" rel="noopener noreferrer">Terms of Service</Link> - </> - ) + isLatest + ? t('about.latestAvailable', { ns: 'common', version: langGeniusVersionInfo.latest_version }) + : t('about.nowAvailable', { ns: 'common', version: langGeniusVersionInfo.latest_version }) + } + </div> + <div className="flex shrink-0 items-center"> + <Button className="mr-2" size="small"> + <Link + href="https://github.com/langgenius/dify/releases" + target="_blank" + rel="noopener noreferrer" + > + {t('about.changeLog', { ns: 'common' })} + </Link> + </Button> + { + !isLatest && !IS_CE_EDITION && ( + <Button variant="primary" size="small"> + <Link + href={langGeniusVersionInfo.release_notes} + target="_blank" + rel="noopener noreferrer" + > + {t('about.updateNow', { ns: 'common' })} + </Link> + </Button> + ) } </div> </div> </div> - <div className="-mx-8 mb-4 h-[0.5px] bg-divider-regular" /> - <div className="flex items-center justify-between"> - <div className="text-xs font-medium text-text-tertiary"> - { - isLatest - ? t('about.latestAvailable', { ns: 'common', version: langGeniusVersionInfo.latest_version }) - : t('about.nowAvailable', { ns: 'common', version: langGeniusVersionInfo.latest_version }) - } - </div> - <div className="flex items-center"> - <Button className="mr-2" size="small"> - <Link - href="https://github.com/langgenius/dify/releases" - target="_blank" - rel="noopener noreferrer" - > - {t('about.changeLog', { ns: 'common' })} - </Link> - </Button> - { - !isLatest && !IS_CE_EDITION && ( - <Button variant="primary" size="small"> - <Link - href={langGeniusVersionInfo.release_notes} - target="_blank" - rel="noopener noreferrer" - > - {t('about.updateNow', { ns: 'common' })} - </Link> - </Button> - ) - } - </div> - </div> - </div> - </Modal> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx index b7d2e09734..f75b42bc45 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx @@ -1,12 +1,11 @@ import type { FC } from 'react' import type { ApiBasedExtension } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' -import Modal from '@/app/components/base/modal' import { useDocLink } from '@/context/i18n' import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common' @@ -61,45 +60,48 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({ data, onCance } } return ( - <Modal isShow onClose={noop} wrapperClassName="z-1002" className="w-[640px]! max-w-none! p-8! pb-6!"> - <div className="mb-2 text-xl font-semibold text-text-primary"> - {data.name - ? t('apiBasedExtension.modal.editTitle', { ns: 'common' }) - : t('apiBasedExtension.modal.title', { ns: 'common' })} - </div> - <div className="py-2"> - <div className="text-sm leading-9 font-medium text-text-primary"> - {t('apiBasedExtension.modal.name.title', { ns: 'common' })} + <Dialog open> + <DialogContent className="w-[640px]! max-w-none! overflow-hidden! border-none p-8! pb-6! text-left align-middle"> + + <div className="mb-2 text-xl font-semibold text-text-primary"> + {data.name + ? t('apiBasedExtension.modal.editTitle', { ns: 'common' }) + : t('apiBasedExtension.modal.title', { ns: 'common' })} </div> - <input value={localeData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} /> - </div> - <div className="py-2"> - <div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary"> - {t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })} - <a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="group flex items-center text-xs font-normal text-text-accent"> - <BookOpen01 className="mr-1 h-3 w-3" /> - {t('apiBasedExtension.link', { ns: 'common' })} - </a> + <div className="py-2"> + <div className="text-sm leading-9 font-medium text-text-primary"> + {t('apiBasedExtension.modal.name.title', { ns: 'common' })} + </div> + <input value={localeData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} /> </div> - <input value={localeData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} /> - </div> - <div className="py-2"> - <div className="text-sm leading-9 font-medium text-text-primary"> - {t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })} + <div className="py-2"> + <div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary"> + {t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })} + <a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="group flex items-center text-xs font-normal text-text-accent"> + <BookOpen01 className="mr-1 h-3 w-3" /> + {t('apiBasedExtension.link', { ns: 'common' })} + </a> + </div> + <input value={localeData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} /> </div> - <div className="flex items-center"> - <input value={localeData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} /> + <div className="py-2"> + <div className="text-sm leading-9 font-medium text-text-primary"> + {t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })} + </div> + <div className="flex items-center"> + <input value={localeData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} /> + </div> </div> - </div> - <div className="mt-6 flex items-center justify-end"> - <Button onClick={onCancel} className="mr-2"> - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button variant="primary" disabled={!localeData.name || !localeData.api_endpoint || !localeData.api_key || loading} onClick={handleSave}> - {t('operation.save', { ns: 'common' })} - </Button> - </div> - </Modal> + <div className="mt-6 flex items-center justify-end"> + <Button onClick={onCancel} className="mr-2"> + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button variant="primary" disabled={!localeData.name || !localeData.api_endpoint || !localeData.api_key || loading} onClick={handleSave}> + {t('operation.save', { ns: 'common' })} + </Button> + </div> + </DialogContent> + </Dialog> ) } export default ApiBasedExtensionModal diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx index c4794c9775..002908ce8b 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx @@ -4,14 +4,6 @@ import userEvent from '@testing-library/user-event' import { ConfigurationMethodEnum } from '../../declarations' import ModelLoadBalancingModal from '../model-load-balancing-modal' -vi.mock('@headlessui/react', () => ({ - Transition: ({ show, children }: { show: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null), - TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>, - Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - DialogPanel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>, - DialogTitle: ({ children, className }: { children: React.ReactNode, className?: string }) => <h3 className={className}>{children}</h3>, -})) - type CredentialData = { load_balancing: { enabled: boolean diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx index 34b9d1578f..15632fb898 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx @@ -9,11 +9,11 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import Modal from '@/app/components/base/modal' import { SwitchCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth' import { useGetModelCredential, useUpdateModelLoadBalancingConfig } from '@/service/use-models' import { ConfigurationMethodEnum, FormTypeEnum } from '../declarations' @@ -180,114 +180,118 @@ const ModelLoadBalancingModal = ({ provider, configurateMethod, currentCustomCon }, [refetch, onClose]) return ( <> - <Modal - isShow={Boolean(model) && open} - onClose={onClose} - wrapperClassName="z-1002" - className="w-[640px] max-w-none px-8 pt-8" - title={( - <div className="pb-3 font-semibold"> - <div className="h-[30px]"> - {draftConfig?.enabled - ? t('modelProvider.auth.configLoadBalancing', { ns: 'common' }) - : t('modelProvider.auth.configModel', { ns: 'common' })} - </div> - {Boolean(model) && ( - <div className="flex h-5 items-center"> - <ModelIcon className="mr-2 shrink-0" provider={provider} modelName={model!.model} /> - <ModelName className="grow system-md-regular text-text-secondary" modelItem={model!} showModelType showMode showContextSize /> - </div> - )} - </div> - )} + <Dialog + open={Boolean(model) && open} + onOpenChange={(open) => { + if (!open) + onClose?.() + }} > - {!draftConfig - ? <Loading type="area" /> - : ( - <> - <div className="py-2"> - <div className={cn('min-h-16 rounded-xl border bg-components-panel-bg transition-colors', draftConfig.enabled ? 'cursor-pointer border-components-panel-border' : 'cursor-default border-util-colors-blue-blue-600')} onClick={draftConfig.enabled ? () => toggleModalBalancing(false) : undefined}> - <div className="flex items-center gap-2 px-[15px] py-3 select-none"> - <div className="flex h-8 w-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-components-card-border bg-components-card-bg"> - {Boolean(model) && (<ModelIcon className="shrink-0" provider={provider} modelName={model!.model} />)} - </div> - <div className="grow"> - <div className="text-sm text-text-secondary"> - {providerFormSchemaPredefined - ? t('modelProvider.auth.providerManaged', { ns: 'common' }) - : t('modelProvider.auth.specifyModelCredential', { ns: 'common' })} - </div> - <div className="text-xs text-text-tertiary"> - {providerFormSchemaPredefined - ? t('modelProvider.auth.providerManagedTip', { ns: 'common' }) - : t('modelProvider.auth.specifyModelCredentialTip', { ns: 'common' })} - </div> - </div> - {!providerFormSchemaPredefined && (<SwitchCredentialInLoadBalancing provider={provider} customModelCredential={customModelCredential ?? initialCustomModelCredential} setCustomModelCredential={setCustomModelCredential} model={model} credentials={available_credentials} onUpdate={handleUpdateWhenSwitchCredential} onRemove={handleUpdateWhenSwitchCredential} />)} - </div> - </div> - {modelCredential && ( - <ModelLoadBalancingConfigs {...{ - draftConfig, - setDraftConfig, - provider, - currentCustomConfigurationModelFixedFields: { - __model_name: model.model, - __model_type: model.model_type, - }, - configurationMethod: model.fetch_from, - className: 'mt-2', - modelCredential, - onUpdate: handleUpdate, - onRemove: handleUpdateWhenSwitchCredential, - model: { - model: model.model, - model_type: model.model_type, - }, - }} - /> - )} + <DialogContent className="w-[640px] max-w-none overflow-hidden! border-none px-8 pt-8 text-left align-middle"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + <div className="pb-3 font-semibold"> + <div className="h-[30px]"> + {draftConfig?.enabled + ? t('modelProvider.auth.configLoadBalancing', { ns: 'common' }) + : t('modelProvider.auth.configModel', { ns: 'common' })} + </div> + {Boolean(model) && ( + <div className="flex h-5 items-center"> + <ModelIcon className="mr-2 shrink-0" provider={provider} modelName={model!.model} /> + <ModelName className="grow system-md-regular text-text-secondary" modelItem={model!} showModelType showMode showContextSize /> </div> + )} + </div> + </DialogTitle> - <div className="mt-6 flex items-center justify-between gap-2"> - <div> - {!providerFormSchemaPredefined && ( - <Button onClick={() => openConfirmDelete(undefined, { model: model.model, model_type: model.model_type })} className="text-components-button-destructive-secondary-text"> - {t('modelProvider.auth.removeModel', { ns: 'common' })} - </Button> + {!draftConfig + ? <Loading type="area" /> + : ( + <> + <div className="py-2"> + <div className={cn('min-h-16 rounded-xl border bg-components-panel-bg transition-colors', draftConfig.enabled ? 'cursor-pointer border-components-panel-border' : 'cursor-default border-util-colors-blue-blue-600')} onClick={draftConfig.enabled ? () => toggleModalBalancing(false) : undefined}> + <div className="flex items-center gap-2 px-[15px] py-3 select-none"> + <div className="flex h-8 w-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-components-card-border bg-components-card-bg"> + {Boolean(model) && (<ModelIcon className="shrink-0" provider={provider} modelName={model!.model} />)} + </div> + <div className="grow"> + <div className="text-sm text-text-secondary"> + {providerFormSchemaPredefined + ? t('modelProvider.auth.providerManaged', { ns: 'common' }) + : t('modelProvider.auth.specifyModelCredential', { ns: 'common' })} + </div> + <div className="text-xs text-text-tertiary"> + {providerFormSchemaPredefined + ? t('modelProvider.auth.providerManagedTip', { ns: 'common' }) + : t('modelProvider.auth.specifyModelCredentialTip', { ns: 'common' })} + </div> + </div> + {!providerFormSchemaPredefined && (<SwitchCredentialInLoadBalancing provider={provider} customModelCredential={customModelCredential ?? initialCustomModelCredential} setCustomModelCredential={setCustomModelCredential} model={model} credentials={available_credentials} onUpdate={handleUpdateWhenSwitchCredential} onRemove={handleUpdateWhenSwitchCredential} />)} + </div> + </div> + {modelCredential && ( + <ModelLoadBalancingConfigs {...{ + draftConfig, + setDraftConfig, + provider, + currentCustomConfigurationModelFixedFields: { + __model_name: model.model, + __model_type: model.model_type, + }, + configurationMethod: model.fetch_from, + className: 'mt-2', + modelCredential, + onUpdate: handleUpdate, + onRemove: handleUpdateWhenSwitchCredential, + model: { + model: model.model, + model_type: model.model_type, + }, + }} + /> )} </div> - <div className="space-x-2"> - <Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button - variant="primary" - onClick={handleSave} - disabled={loading - || (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2) - || isLoading} - > - {t('operation.save', { ns: 'common' })} - </Button> + + <div className="mt-6 flex items-center justify-between gap-2"> + <div> + {!providerFormSchemaPredefined && ( + <Button onClick={() => openConfirmDelete(undefined, { model: model.model, model_type: model.model_type })} className="text-components-button-destructive-secondary-text"> + {t('modelProvider.auth.removeModel', { ns: 'common' })} + </Button> + )} + </div> + <div className="space-x-2"> + <Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button + variant="primary" + onClick={handleSave} + disabled={loading + || (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2) + || isLoading} + > + {t('operation.save', { ns: 'common' })} + </Button> + </div> </div> - </div> - </> - )} - </Modal> - <AlertDialog open={!!deleteModel} onOpenChange={open => !open && closeConfirmDelete()}> - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t('modelProvider.confirmDelete', { ns: 'common' })} - </AlertDialogTitle> - </div> - <AlertDialogActions> - <AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton disabled={doingAction} onClick={handleDeleteModel}> - {t('operation.confirm', { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> + </> + )} + <AlertDialog open={!!deleteModel} onOpenChange={open => !open && closeConfirmDelete()}> + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> + {t('modelProvider.confirmDelete', { ns: 'common' })} + </AlertDialogTitle> + </div> + <AlertDialogActions> + <AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogConfirmButton disabled={doingAction} onClick={handleDeleteModel}> + {t('operation.confirm', { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> + </DialogContent> + </Dialog> </> ) } diff --git a/web/app/components/header/nav/__tests__/index.spec.tsx b/web/app/components/header/nav/__tests__/index.spec.tsx index 6f9b448981..1e456296f7 100644 --- a/web/app/components/header/nav/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/__tests__/index.spec.tsx @@ -9,7 +9,6 @@ import { } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import { use } from 'react' import { vi } from 'vitest' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' @@ -17,59 +16,6 @@ import { useRouter, useSelectedLayoutSegment } from '@/next/navigation' import { AppModeEnum } from '@/types/app' import Nav from '../index' -vi.mock('@headlessui/react', () => { - type MenuContextValue = { open: boolean, setOpen: (open: boolean) => void } - const MenuContext = React.createContext<MenuContextValue | null>(null) - - const Menu = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => { - const [open, setOpen] = React.useState(false) - const value = React.useMemo(() => ({ open, setOpen }), [open]) - return ( - <MenuContext value={value}> - {typeof children === 'function' ? children({ open }) : children} - </MenuContext> - ) - } - - const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => { - const context = use(MenuContext) - const handleClick = () => { - context?.setOpen(!context.open) - onClick?.() - } - return ( - <button type="button" aria-expanded={context?.open ?? false} onClick={handleClick} {...props}> - {children} - </button> - ) - } - - const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => { - const context = use(MenuContext) - if (!context?.open) - return null - return ( - <Component role={role ?? 'menu'} {...props}> - {children} - </Component> - ) - } - - const MenuItem = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => ( - <Component role={role ?? 'menuitem'} {...props}> - {children} - </Component> - ) - - return { - Menu, - MenuButton, - MenuItems, - MenuItem, - Transition: ({ show = true, children }: { show?: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null), - } -}) - // Mock next/navigation vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegment: vi.fn(), diff --git a/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx b/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx index 32de0691dd..aaabe45613 100644 --- a/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx @@ -2,7 +2,6 @@ import type { INavSelectorProps, NavItem } from '../index' import type { AppContextValue } from '@/context/app-context' import { act, fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' import { vi } from 'vitest' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' @@ -10,59 +9,6 @@ import { useRouter } from '@/next/navigation' import { AppModeEnum } from '@/types/app' import NavSelector from '../index' -vi.mock('@headlessui/react', () => { - type MenuContextValue = { open: boolean, setOpen: (open: boolean) => void } - const MenuContext = React.createContext<MenuContextValue | null>(null) - - const Menu = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => { - const [open, setOpen] = React.useState(false) - const value = React.useMemo(() => ({ open, setOpen }), [open]) - return ( - <MenuContext.Provider value={value}> - {typeof children === 'function' ? children({ open }) : children} - </MenuContext.Provider> - ) - } - - const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => { - const context = React.useContext(MenuContext) - const handleClick = () => { - context?.setOpen(!context.open) - onClick?.() - } - return ( - <button type="button" aria-expanded={context?.open ?? false} onClick={handleClick} {...props}> - {children} - </button> - ) - } - - const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => { - const context = React.useContext(MenuContext) - if (!context?.open) - return null - return ( - <Component role={role ?? 'menu'} {...props}> - {children} - </Component> - ) - } - - const MenuItem = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => ( - <Component role={role ?? 'menuitem'} {...props}> - {children} - </Component> - ) - - return { - Menu, - MenuButton, - MenuItems, - MenuItem, - Transition: ({ show = true, children }: { show?: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null), - } -}) - // Mock next/navigation vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), diff --git a/web/app/components/plugins/install-plugin/install-bundle/index.tsx b/web/app/components/plugins/install-plugin/install-bundle/index.tsx index 6d2b476c51..1cf2202dae 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/index.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/index.tsx @@ -2,10 +2,10 @@ import type { FC } from 'react' import type { Dependency } from '../../types' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import { InstallStep } from '../../types' import useHideLogic from '../hooks/use-hide-logic' import ReadyToInstall from './ready-to-install' @@ -50,26 +50,31 @@ const InstallBundle: FC<Props> = ({ }, [step, t]) return ( - <Modal - isShow={true} - onClose={foldAnimInto} - className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')} - closable + <Dialog + open + onOpenChange={(open) => { + if (!open) + foldAnimInto() + }} > - <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> - <div className="self-stretch title-2xl-semi-bold text-text-primary"> - {getTitle()} + <DialogContent className={cn('relative w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}> + <DialogCloseButton data-testid="modal-close-button" /> + + <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> + <div className="self-stretch title-2xl-semi-bold text-text-primary"> + {getTitle()} + </div> </div> - </div> - <ReadyToInstall - step={step} - onStepChange={setStep} - onStartToInstall={handleStartToInstall} - setIsInstalling={setIsInstalling} - allPlugins={fromDSLPayload} - onClose={onClose} - /> - </Modal> + <ReadyToInstall + step={step} + onStepChange={setStep} + onStartToInstall={handleStartToInstall} + setIsInstalling={setIsInstalling} + allPlugins={fromDSLPayload} + onClose={onClose} + /> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/plugins/install-plugin/install-from-github/index.tsx b/web/app/components/plugins/install-plugin/install-from-github/index.tsx index 4e2c97ab0b..ca2b28d2ef 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/index.tsx @@ -3,11 +3,11 @@ import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types' import type { InstallState } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import { InstallStepFromGitHub } from '../../types' import Installed from '../base/installed' @@ -160,74 +160,80 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on } return ( - <Modal - isShow={true} - onClose={foldAnimInto} - className={cn(modalClassName, `shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] - border-components-panel-border bg-components-panel-bg p-0`)} - closable + <Dialog + open + onOpenChange={(open) => { + if (!open) + foldAnimInto() + }} > - <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> - <div className="flex grow flex-col items-start gap-1"> - <div className="self-stretch title-2xl-semi-bold text-text-primary"> - {getTitle()} - </div> - <div className="self-stretch system-xs-regular text-text-tertiary"> - {!([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) && t('installFromGitHub.installNote', { ns: 'plugin' })} + <DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, `shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] + border-components-panel-border bg-components-panel-bg p-0`))} + > + <DialogCloseButton data-testid="modal-close-button" /> + + <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> + <div className="flex grow flex-col items-start gap-1"> + <div className="self-stretch title-2xl-semi-bold text-text-primary"> + {getTitle()} + </div> + <div className="self-stretch system-xs-regular text-text-tertiary"> + {!([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) && t('installFromGitHub.installNote', { ns: 'plugin' })} + </div> </div> </div> - </div> - {([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) - ? ( - <Installed - payload={manifest} - isFailed={[InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installFailed].includes(state.step)} - errMsg={errorMsg} - onCancel={onClose} - /> - ) - : ( - <div className={`flex flex-col items-start justify-center self-stretch px-6 py-3 ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`}> - {state.step === InstallStepFromGitHub.setUrl && ( - <SetURL - repoUrl={state.repoUrl} - onChange={value => setState(prevState => ({ ...prevState, repoUrl: value }))} - onNext={handleUrlSubmit} - onCancel={onClose} - /> - )} - {state.step === InstallStepFromGitHub.selectPackage && ( - <SelectPackage - updatePayload={updatePayload!} - repoUrl={state.repoUrl} - selectedVersion={state.selectedVersion} - versions={versions} - onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: String(item.value) }))} - selectedPackage={state.selectedPackage} - packages={packages} - onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: String(item.value) }))} - onUploaded={handleUploaded} - onFailed={handleUploadFail} - onBack={handleBack} - /> - )} - {state.step === InstallStepFromGitHub.readyToInstall && ( - <Loaded - updatePayload={updatePayload!} - uniqueIdentifier={uniqueIdentifier!} - payload={manifest as any} - repoUrl={state.repoUrl} - selectedVersion={state.selectedVersion} - selectedPackage={state.selectedPackage} - onBack={handleBack} - onStartToInstall={handleStartToInstall} - onInstalled={handleInstalled} - onFailed={handleFailed} - /> - )} - </div> - )} - </Modal> + {([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) + ? ( + <Installed + payload={manifest} + isFailed={[InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installFailed].includes(state.step)} + errMsg={errorMsg} + onCancel={onClose} + /> + ) + : ( + <div className={`flex flex-col items-start justify-center self-stretch px-6 py-3 ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`}> + {state.step === InstallStepFromGitHub.setUrl && ( + <SetURL + repoUrl={state.repoUrl} + onChange={value => setState(prevState => ({ ...prevState, repoUrl: value }))} + onNext={handleUrlSubmit} + onCancel={onClose} + /> + )} + {state.step === InstallStepFromGitHub.selectPackage && ( + <SelectPackage + updatePayload={updatePayload!} + repoUrl={state.repoUrl} + selectedVersion={state.selectedVersion} + versions={versions} + onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: String(item.value) }))} + selectedPackage={state.selectedPackage} + packages={packages} + onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: String(item.value) }))} + onUploaded={handleUploaded} + onFailed={handleUploadFail} + onBack={handleBack} + /> + )} + {state.step === InstallStepFromGitHub.readyToInstall && ( + <Loaded + updatePayload={updatePayload!} + uniqueIdentifier={uniqueIdentifier!} + payload={manifest as any} + repoUrl={state.repoUrl} + selectedVersion={state.selectedVersion} + selectedPackage={state.selectedPackage} + onBack={handleBack} + onStartToInstall={handleStartToInstall} + onInstalled={handleInstalled} + onFailed={handleFailed} + /> + )} + </div> + )} + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx index 94504acd98..e7696d455a 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx @@ -2,10 +2,10 @@ import type { Dependency, PluginDeclaration } from '../../types' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import { InstallStep } from '../../types' import useHideLogic from '../hooks/use-hide-logic' @@ -86,52 +86,57 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({ }, []) return ( - <Modal - isShow={true} - onClose={foldAnimInto} - className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')} - closable + <Dialog + open + onOpenChange={(open) => { + if (!open) + foldAnimInto() + }} > - <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> - <div className="self-stretch title-2xl-semi-bold text-text-primary"> - {getTitle()} + <DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}> + <DialogCloseButton data-testid="modal-close-button" /> + + <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> + <div className="self-stretch title-2xl-semi-bold text-text-primary"> + {getTitle()} + </div> </div> - </div> - {step === InstallStep.uploading && ( - <Uploading - isBundle={isBundle} - file={file} - onCancel={onClose} - onPackageUploaded={handlePackageUploaded} - onBundleUploaded={handleBundleUploaded} - onFailed={handleUploadFail} - /> - )} - {isBundle - ? ( - <ReadyToInstallBundle - step={step} - onStepChange={setStep} - onStartToInstall={handleStartToInstall} - setIsInstalling={setIsInstalling} - onClose={onClose} - allPlugins={dependencies} - /> - ) - : ( - <ReadyToInstallPackage - step={step} - onStepChange={setStep} - onStartToInstall={handleStartToInstall} - setIsInstalling={setIsInstalling} - onClose={onClose} - uniqueIdentifier={uniqueIdentifier} - manifest={manifest} - errorMsg={errorMsg} - onError={setErrorMsg} - /> - )} - </Modal> + {step === InstallStep.uploading && ( + <Uploading + isBundle={isBundle} + file={file} + onCancel={onClose} + onPackageUploaded={handlePackageUploaded} + onBundleUploaded={handleBundleUploaded} + onFailed={handleUploadFail} + /> + )} + {isBundle + ? ( + <ReadyToInstallBundle + step={step} + onStepChange={setStep} + onStartToInstall={handleStartToInstall} + setIsInstalling={setIsInstalling} + onClose={onClose} + allPlugins={dependencies} + /> + ) + : ( + <ReadyToInstallPackage + step={step} + onStepChange={setStep} + onStartToInstall={handleStartToInstall} + setIsInstalling={setIsInstalling} + onClose={onClose} + uniqueIdentifier={uniqueIdentifier} + manifest={manifest} + errorMsg={errorMsg} + onError={setErrorMsg} + /> + )} + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx index aace5dcbe9..674d6a451c 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx @@ -161,11 +161,9 @@ describe('Uploading', () => { }) }) - // NOTE: The uploadFile API has an unconventional contract where it always rejects. - // Success vs failure is determined by whether response.message exists: - // - If response.message exists → treated as failure (calls onFailed) - // - If response.message is absent → treated as success (calls onPackageUploaded/onBundleUploaded) - // This explains why we use mockRejectedValue for "success" scenarios below. + // NOTE: Some upload endpoints have historically returned successful plugin upload + // payloads through rejected XHR objects, so the component accepts both resolved + // responses and rejected responses without an error message. it('should call onPackageUploaded when upload rejects without error message (success case)', async () => { const mockResult = { @@ -193,6 +191,30 @@ describe('Uploading', () => { }) }) + it('should call onPackageUploaded when upload resolves with package response', async () => { + const mockResult = { + unique_identifier: 'test-uid', + manifest: createMockManifest(), + } + mockUploadFile.mockResolvedValue(mockResult) + + const onPackageUploaded = vi.fn() + render( + <Uploading + {...defaultProps} + isBundle={false} + onPackageUploaded={onPackageUploaded} + />, + ) + + await waitFor(() => { + expect(onPackageUploaded).toHaveBeenCalledWith({ + uniqueIdentifier: mockResult.unique_identifier, + manifest: mockResult.manifest, + }) + }) + }) + it('should call onBundleUploaded when upload rejects without error message (success case)', async () => { const mockDependencies = createMockDependencies() mockUploadFile.mockRejectedValue({ @@ -212,6 +234,24 @@ describe('Uploading', () => { expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies) }) }) + + it('should call onBundleUploaded when upload resolves with bundle response', async () => { + const mockDependencies = createMockDependencies() + mockUploadFile.mockResolvedValue(mockDependencies) + + const onBundleUploaded = vi.fn() + render( + <Uploading + {...defaultProps} + isBundle + onBundleUploaded={onBundleUploaded} + />, + ) + + await waitFor(() => { + expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies) + }) + }) }) // ================================ @@ -260,35 +300,48 @@ describe('Uploading', () => { // Edge Cases Tests // ================================ describe('Edge Cases', () => { - it('should handle empty response gracefully', async () => { + it('should fail gracefully when upload response is empty', async () => { mockUploadFile.mockRejectedValue({ response: {}, }) const onPackageUploaded = vi.fn() - render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />) + const onFailed = vi.fn() + render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />) await waitFor(() => { - expect(onPackageUploaded).toHaveBeenCalledWith({ - uniqueIdentifier: undefined, - manifest: undefined, - }) + expect(onPackageUploaded).not.toHaveBeenCalled() + expect(onFailed).toHaveBeenCalledWith('plugin.installModal.uploadFailed') }) }) - it('should handle response with only unique_identifier', async () => { + it('should fail gracefully when upload response has no manifest', async () => { mockUploadFile.mockRejectedValue({ response: { unique_identifier: 'only-uid' }, }) const onPackageUploaded = vi.fn() - render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />) + const onFailed = vi.fn() + render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />) await waitFor(() => { - expect(onPackageUploaded).toHaveBeenCalledWith({ - uniqueIdentifier: 'only-uid', - manifest: undefined, - }) + expect(onPackageUploaded).not.toHaveBeenCalled() + expect(onFailed).toHaveBeenCalledWith('plugin.installModal.uploadFailed') + }) + }) + + it('should fail gracefully when upload response is null', async () => { + mockUploadFile.mockRejectedValue({ + response: null, + }) + + const onPackageUploaded = vi.fn() + const onFailed = vi.fn() + render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />) + + await waitFor(() => { + expect(onPackageUploaded).not.toHaveBeenCalled() + expect(onFailed).toHaveBeenCalledWith('plugin.installModal.uploadFailed') }) }) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx index e7c3b4cd0f..ea90975ec5 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx @@ -1,8 +1,7 @@ 'use client' import type { FC } from 'react' -import type { Dependency, PluginDeclaration } from '../../../types' +import type { Dependency, Plugin, PluginDeclaration } from '../../../types' import { Button } from '@langgenius/dify-ui/button' -import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { uploadFile } from '@/service/plugins' @@ -10,6 +9,40 @@ import Card from '../../../card' const i18nPrefix = 'installModal' +type PackageUploadResponse = { + unique_identifier: string + manifest: PluginDeclaration +} + +type UploadFailureResponse = { + message?: string +} + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null +} + +function isPackageUploadResponse(value: unknown): value is PackageUploadResponse { + if (!isObject(value)) + return false + + return typeof value.unique_identifier === 'string' && isObject(value.manifest) +} + +function getRejectedResponse(error: unknown): unknown { + if (!isObject(error) || !('response' in error)) + return undefined + + return error.response +} + +function getUploadFailureMessage(response: unknown): string | undefined { + if (!isObject(response)) + return undefined + + return (response as UploadFailureResponse).message +} + type Props = { isBundle: boolean file: File @@ -32,36 +65,50 @@ const Uploading: FC<Props> = ({ }) => { const { t } = useTranslation() const fileName = file.name - const handleUpload = async () => { + const handleUploadedResponse = React.useCallback((response: unknown) => { + if (isBundle) { + if (Array.isArray(response)) { + onBundleUploaded(response as Dependency[]) + return + } + onFailed(t(`${i18nPrefix}.uploadFailed`, { ns: 'plugin' })) + return + } + + if (!isPackageUploadResponse(response)) { + onFailed(t(`${i18nPrefix}.uploadFailed`, { ns: 'plugin' })) + return + } + + onPackageUploaded({ + uniqueIdentifier: response.unique_identifier, + manifest: response.manifest, + }) + }, [isBundle, onBundleUploaded, onFailed, onPackageUploaded, t]) + + const handleUpload = React.useCallback(async () => { try { - await uploadFile(file, isBundle) + handleUploadedResponse(await uploadFile(file, isBundle)) } - catch (e: any) { - if (e.response?.message) { - onFailed(e.response?.message) - } - else { // Why it would into this branch? - const res = e.response - if (isBundle) { - onBundleUploaded(res) - return - } - onPackageUploaded({ - uniqueIdentifier: res.unique_identifier, - manifest: res.manifest, - }) + catch (error: unknown) { + const response = getRejectedResponse(error) + const message = getUploadFailureMessage(response) + if (message) { + onFailed(message) + return } + handleUploadedResponse(response) } - } + }, [file, handleUploadedResponse, isBundle, onFailed]) React.useEffect(() => { handleUpload() - }, []) + }, [handleUpload]) return ( <> <div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> <div className="flex items-center gap-1 self-stretch"> - <RiLoader2Line className="h-4 w-4 animate-spin-slow text-text-accent" /> + <span className="i-ri-loader-2-line h-4 w-4 animate-spin-slow text-text-accent" /> <div className="system-md-regular text-text-secondary"> {t(`${i18nPrefix}.uploadingPackage`, { ns: 'plugin', @@ -72,7 +119,7 @@ const Uploading: FC<Props> = ({ <div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2"> <Card className="w-full" - payload={{ name: fileName } as any} + payload={{ name: fileName } as Plugin} isLoading loadingFileName={fileName} installed={false} diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx index 128b49fcae..f2bcc14fda 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx @@ -2,10 +2,10 @@ import type { Dependency, Plugin, PluginManifestInMarket } from '../../types' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import { InstallStep } from '../../types' import Installed from '../base/installed' import useHideLogic from '../hooks/use-hide-logic' @@ -70,60 +70,64 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({ }, [setIsInstalling]) return ( - <Modal - isShow={true} - onClose={foldAnimInto} - wrapperClassName="z-9999" - className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')} - closable + <Dialog + open + onOpenChange={(open) => { + if (!open) + foldAnimInto() + }} > - <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> - <div className="self-stretch title-2xl-semi-bold text-text-primary"> - {getTitle()} + <DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}> + <DialogCloseButton data-testid="modal-close-button" /> + + <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> + <div className="self-stretch title-2xl-semi-bold text-text-primary"> + {getTitle()} + </div> </div> - </div> - { - isBundle - ? ( - <ReadyToInstallBundle - step={step} - onStepChange={setStep} - onStartToInstall={handleStartToInstall} - setIsInstalling={setIsInstalling} - onClose={onClose} - allPlugins={dependencies!} - isFromMarketPlace - /> - ) - : ( - <> - { - step === InstallStep.readyToInstall && ( - <Install - uniqueIdentifier={uniqueIdentifier} - payload={manifest!} - onCancel={onClose} - onInstalled={handleInstalled} - onFailed={handleFailed} - onStartToInstall={handleStartToInstall} - /> - ) - } - { - [InstallStep.installed, InstallStep.installFailed].includes(step) && ( - <Installed - payload={manifest!} - isMarketPayload - isFailed={step === InstallStep.installFailed} - errMsg={errorMsg} - onCancel={onSuccess} - /> - ) - } - </> - ) - } - </Modal> + { + isBundle + ? ( + <ReadyToInstallBundle + step={step} + onStepChange={setStep} + onStartToInstall={handleStartToInstall} + setIsInstalling={setIsInstalling} + onClose={onClose} + allPlugins={dependencies!} + isFromMarketPlace + /> + ) + : ( + <> + { + step === InstallStep.readyToInstall && ( + <Install + uniqueIdentifier={uniqueIdentifier} + payload={manifest!} + onCancel={onClose} + onInstalled={handleInstalled} + onFailed={handleFailed} + onStartToInstall={handleStartToInstall} + /> + ) + } + { + [InstallStep.installed, InstallStep.installFailed].includes(step) && ( + <Installed + payload={manifest!} + isMarketPayload + isFailed={step === InstallStep.installFailed} + errMsg={errorMsg} + onCancel={onSuccess} + /> + ) + } + </> + ) + } + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx index e8a2ee8318..b2debdb10f 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx @@ -2,14 +2,11 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import SchemaModal from '../schema-modal' -vi.mock('@/app/components/base/modal', () => ({ - default: ({ - children, - isShow, - }: { - children: React.ReactNode - isShow: boolean - }) => isShow ? <div data-testid="modal">{children}</div> : null, +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => + open === false ? null : <>{children}</>, + DialogContent: ({ children }: { children: React.ReactNode }) => <div data-testid="modal">{children}</div>, + DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, })) vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor', () => ({ diff --git a/web/app/components/plugins/plugin-mutation-model/index.tsx b/web/app/components/plugins/plugin-mutation-model/index.tsx index 96a60fd938..339ec9edb5 100644 --- a/web/app/components/plugins/plugin-mutation-model/index.tsx +++ b/web/app/components/plugins/plugin-mutation-model/index.tsx @@ -2,9 +2,9 @@ import type { UseMutationResult } from '@tanstack/react-query' import type { FC, ReactNode } from 'react' import type { Plugin } from '../types' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { memo } from 'react' -import Modal from '@/app/components/base/modal' import Card from '@/app/components/plugins/card' type Props = { @@ -33,45 +33,52 @@ const PluginMutationModal: FC<Props> = ({ modalBottomLeft, }: Props) => { return ( - <Modal - isShow={true} - onClose={onCancel} - className="min-w-[560px]" - closable - title={modelTitle} + <Dialog + open + onOpenChange={(open) => { + if (!open) + onCancel() + }} > - <div className="mt-3 mb-2 system-md-regular text-text-secondary"> - {description} - </div> - <div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2"> - <Card - installed={mutation.isSuccess} - payload={plugin} - className="w-full" - titleLeft={cardTitleLeft} - /> - </div> - <div className="flex items-center gap-2 self-stretch pt-5"> - <div> - {modalBottomLeft} + <DialogContent className="w-full min-w-[560px] overflow-hidden! border-none text-left align-middle"> + <DialogCloseButton data-testid="modal-close-button" /> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {modelTitle} + </DialogTitle> + + <div className="mt-3 mb-2 system-md-regular text-text-secondary"> + {description} </div> - <div className="ml-auto flex gap-2"> - {!mutation.isPending && ( - <Button onClick={onCancel}> - {cancelButtonText} + <div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2"> + <Card + installed={mutation.isSuccess} + payload={plugin} + className="w-full" + titleLeft={cardTitleLeft} + /> + </div> + <div className="flex items-center gap-2 self-stretch pt-5"> + <div> + {modalBottomLeft} + </div> + <div className="ml-auto flex gap-2"> + {!mutation.isPending && ( + <Button onClick={onCancel}> + {cancelButtonText} + </Button> + )} + <Button + variant="primary" + loading={mutation.isPending} + onClick={mutate} + disabled={mutation.isPending} + > + {confirmButtonText} </Button> - )} - <Button - variant="primary" - loading={mutation.isPending} - onClick={mutate} - disabled={mutation.isPending} - > - {confirmButtonText} - </Button> + </div> </div> - </div> - </Modal> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx index e95f4686f8..54818e4d02 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx @@ -2,17 +2,19 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('../../../base/modal', () => ({ - default: ({ children, title, isShow }: { children: React.ReactNode, title: string, isShow: boolean }) => ( - isShow +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => ( + open !== false ? ( <div data-testid="modal"> - <div data-testid="modal-title">{title}</div> {children} </div> ) : null ), + DialogContent: ({ children }: { children: React.ReactNode }) => <>{children}</>, + DialogTitle: ({ children }: { children: React.ReactNode }) => <div data-testid="modal-title">{children}</div>, + DialogCloseButton: () => <button type="button">Close</button>, })) vi.mock('../../base/key-value-item', () => ({ diff --git a/web/app/components/plugins/plugin-page/plugin-info.tsx b/web/app/components/plugins/plugin-page/plugin-info.tsx index 0638bd66a2..29a137afca 100644 --- a/web/app/components/plugins/plugin-page/plugin-info.tsx +++ b/web/app/components/plugins/plugin-page/plugin-info.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Modal from '../../base/modal' import KeyValueItem from '../base/key-value-item' import { convertRepoToUrl } from '../install-plugin/utils' @@ -23,19 +23,26 @@ const PlugInfo: FC<Props> = ({ const { t } = useTranslation() const labelWidthClassName = 'w-[96px]' return ( - <Modal - title={t(`${i18nPrefix}.title`, { ns: 'plugin' })} - className="w-[480px]" - isShow - onClose={onHide} - closable + <Dialog + open + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <div className="mt-5 space-y-3"> - {repository && <KeyValueItem label={t(`${i18nPrefix}.repository`, { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={`${convertRepoToUrl(repository)}`} valueMaxWidthClassName="max-w-[190px]" />} - {release && <KeyValueItem label={t(`${i18nPrefix}.release`, { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={release} />} - {packageName && <KeyValueItem label={t(`${i18nPrefix}.packageName`, { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={packageName} />} - </div> - </Modal> + <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle"> + <DialogCloseButton data-testid="modal-close-button" /> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(`${i18nPrefix}.title`, { ns: 'plugin' })} + </DialogTitle> + + <div className="mt-5 space-y-3"> + {repository && <KeyValueItem label={t(`${i18nPrefix}.repository`, { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={`${convertRepoToUrl(repository)}`} valueMaxWidthClassName="max-w-[190px]" />} + {release && <KeyValueItem label={t(`${i18nPrefix}.release`, { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={release} />} + {packageName && <KeyValueItem label={t(`${i18nPrefix}.packageName`, { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={packageName} />} + </div> + </DialogContent> + </Dialog> ) } export default React.memo(PlugInfo) diff --git a/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx index 95a9910038..f1959e8119 100644 --- a/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx @@ -14,28 +14,25 @@ const mockSystemFeatures = { enable_marketplace: true } const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { systemFeatures: mockSystemFeatures }) -// Mock Modal component -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, isShow, onClose, closable, className }: { +let mockDialogOnOpenChange: ((open: boolean) => void) | undefined + +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open, onOpenChange }: { children: React.ReactNode - isShow: boolean - onClose: () => void - closable?: boolean - className?: string + open?: boolean + onOpenChange?: (open: boolean) => void }) => { - if (!isShow) - return null - return ( - <div data-testid="modal" className={className}> - {closable && ( - <button data-testid="modal-close" onClick={onClose}> - Close - </button> - )} - {children} - </div> - ) + mockDialogOnOpenChange = onOpenChange + return open === false ? null : <>{children}</> }, + DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => ( + <div data-testid="modal" className={className}>{children}</div> + ), + DialogCloseButton: () => ( + <button data-testid="modal-close" onClick={() => mockDialogOnOpenChange?.(false)}> + Close + </button> + ), })) // Mock OptionCard component diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx index d7d6fcd35f..5710eb7a66 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx @@ -143,7 +143,6 @@ const AutoUpdateSetting: FC<Props> = ({ timezone={timezone} onChange={v => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(v), timezone!))} onClear={() => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(0, timezone!))} - popupClassName="z-99" title={t(`${i18nPrefix}.updateTime`, { ns: 'plugin' })} minuteFilter={minuteFilter} renderTrigger={renderTimePickerTrigger} diff --git a/web/app/components/plugins/reference-setting-modal/index.tsx b/web/app/components/plugins/reference-setting-modal/index.tsx index b93713bdd8..9bc0aaf7d6 100644 --- a/web/app/components/plugins/reference-setting-modal/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/index.tsx @@ -3,11 +3,11 @@ import type { FC } from 'react' import type { AutoUpdateConfig } from './auto-update-setting/types' import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import { PermissionType } from '@/app/components/plugins/types' import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { systemFeaturesQueryOptions } from '@/service/system-features' @@ -53,59 +53,64 @@ const PluginSettingModal: FC<Props> = ({ }, [onHide, onSave, tempAutoUpdateConfig, tempPrivilege]) return ( - <Modal - isShow - onClose={onHide} - closable - className="w-[620px] max-w-[620px] p-0!" + <Dialog + open + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <div className="shadows-shadow-xl flex w-full flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg"> - <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> - <span className="self-stretch title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'plugin' })}</span> - </div> - <div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> - {[ - { title: t(`${i18nPrefix}.whoCanInstall`, { ns: 'plugin' }), key: 'install_permission', value: tempPrivilege?.install_permission || PermissionType.noOne }, - { title: t(`${i18nPrefix}.whoCanDebug`, { ns: 'plugin' }), key: 'debug_permission', value: tempPrivilege?.debug_permission || PermissionType.noOne }, - ].map(({ title, key, value }) => ( - <div key={key} className="flex flex-col items-start gap-1 self-stretch"> - <Label label={title} /> - <div className="flex w-full items-start justify-between gap-2"> - {[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map(option => ( - <OptionCard - key={option} - title={t(`${i18nPrefix}.${option}`, { ns: 'plugin' })} - onSelect={() => handlePrivilegeChange(key)(option)} - selected={value === option} - className="flex-1" - /> - ))} + <DialogContent className="w-[620px] max-w-[620px] overflow-hidden! border-none p-0! text-left align-middle"> + <DialogCloseButton data-testid="modal-close-button" /> + + <div className="shadows-shadow-xl flex w-full flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg"> + <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> + <span className="self-stretch title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'plugin' })}</span> + </div> + <div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> + {[ + { title: t(`${i18nPrefix}.whoCanInstall`, { ns: 'plugin' }), key: 'install_permission', value: tempPrivilege?.install_permission || PermissionType.noOne }, + { title: t(`${i18nPrefix}.whoCanDebug`, { ns: 'plugin' }), key: 'debug_permission', value: tempPrivilege?.debug_permission || PermissionType.noOne }, + ].map(({ title, key, value }) => ( + <div key={key} className="flex flex-col items-start gap-1 self-stretch"> + <Label label={title} /> + <div className="flex w-full items-start justify-between gap-2"> + {[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map(option => ( + <OptionCard + key={option} + title={t(`${i18nPrefix}.${option}`, { ns: 'plugin' })} + onSelect={() => handlePrivilegeChange(key)(option)} + selected={value === option} + className="flex-1" + /> + ))} + </div> </div> - </div> - ))} + ))} + </div> + { + enable_marketplace && ( + <AutoUpdateSetting payload={tempAutoUpdateConfig} onChange={setTempAutoUpdateConfig} /> + ) + } + <div className="flex h-[76px] items-center justify-end gap-2 self-stretch p-6 pt-5"> + <Button + className="min-w-[72px]" + onClick={onHide} + > + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button + className="min-w-[72px]" + variant="primary" + onClick={handleSave} + > + {t('operation.save', { ns: 'common' })} + </Button> + </div> </div> - { - enable_marketplace && ( - <AutoUpdateSetting payload={tempAutoUpdateConfig} onChange={setTempAutoUpdateConfig} /> - ) - } - <div className="flex h-[76px] items-center justify-end gap-2 self-stretch p-6 pt-5"> - <Button - className="min-w-[72px]" - onClick={onHide} - > - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button - className="min-w-[72px]" - variant="primary" - onClick={handleSave} - > - {t('operation.save', { ns: 'common' })} - </Button> - </div> - </div> - </Modal> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 0eec89b8b8..30b503899e 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -420,7 +420,7 @@ function getDescriptionTextarea() { } // Helper to find the AppIcon span in PublishAsKnowledgePipelineModal -// HeadlessUI Dialog renders via portal to document.body, so we search the full document +// The modal renders via portal to document.body, so we search the full document. function getAppIcon() { const emoji = document.querySelector('em-emoji') return emoji?.closest('span') as HTMLElement @@ -687,7 +687,7 @@ describe('PublishAsKnowledgePipelineModal', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) // Real AppIcon renders an em-emoji custom element inside a span - // HeadlessUI Dialog renders via portal, so search the full document + // The modal renders via portal, so search the full document. expect(document.querySelector('em-emoji')).toBeInTheDocument() }) @@ -845,7 +845,7 @@ describe('PublishAsKnowledgePipelineModal', () => { const { rerender } = render(<PublishAsKnowledgePipelineModal {...defaultProps} />) rerender(<PublishAsKnowledgePipelineModal {...defaultProps} />) - // HeadlessUI Dialog renders via portal, so search the full document + // The modal renders via portal, so search the full document. expect(document.querySelector('em-emoji')).toBeInTheDocument() }) }) diff --git a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx index 7a99b7ab90..e1a4af0410 100644 --- a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx @@ -17,9 +17,12 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => - isShow ? <div data-testid="modal">{children}</div> : null, +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => + open === false ? null : <>{children}</>, + DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => ( + <div data-testid="modal" className={className}>{children}</div> + ), })) vi.mock('@langgenius/dify-ui/button', () => ({ diff --git a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx index d31a06f421..815b21ba98 100644 --- a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx @@ -121,18 +121,14 @@ vi.mock('@langgenius/dify-ui/button', () => ({ ), })) -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, isShow, _onClose, className }: PropsWithChildren<{ - isShow: boolean - _onClose: () => void - className?: string - }>) => isShow - ? ( - <div data-testid="modal" className={className}> - {children} - </div> - ) - : null, +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: PropsWithChildren<{ open?: boolean }>) => + open === false ? null : <>{children}</>, + DialogContent: ({ children, className }: PropsWithChildren<{ className?: string }>) => ( + <div data-testid="modal" className={className}> + {children} + </div> + ), })) vi.mock('@/app/components/workflow/constants', () => ({ diff --git a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx index 56ed88e2ae..b4b29fd0a8 100644 --- a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx @@ -31,13 +31,13 @@ describe('VersionMismatchModal', () => { it('should render dialog when isShow is true', () => { render(<VersionMismatchModal {...defaultProps} />) - expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByRole('alertdialog')).toBeInTheDocument() }) it('should not render dialog when isShow is false', () => { render(<VersionMismatchModal {...defaultProps} isShow={false} />) - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) it('should render error title', () => { diff --git a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx index faa71f0f4e..eeb6337847 100644 --- a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx +++ b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx @@ -2,14 +2,13 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import type { IconInfo } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import AppIconPicker from '@/app/components/base/app-icon-picker' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import Textarea from '@/app/components/base/textarea' import { useWorkflowStore } from '@/app/components/workflow/store' @@ -77,77 +76,76 @@ const PublishAsKnowledgePipelineModal = ({ return ( <> - <Modal - isShow - onClose={noop} - className="relative w-[520px]! p-0!" - > - <div className="relative flex items-center p-6 pr-14 pb-3 title-2xl-semi-bold text-text-primary"> - {t('common.publishAs', { ns: 'pipeline' })} - <div - data-testid="publish-modal-close-btn" - className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center" - onClick={onCancel} - > - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> + <Dialog open> + <DialogContent className="relative w-full max-w-[480px]! overflow-hidden! border-none p-0! text-left align-middle"> + + <div className="relative flex items-center p-6 pr-14 pb-3 title-2xl-semi-bold text-text-primary"> + {t('common.publishAs', { ns: 'pipeline' })} + <div + data-testid="publish-modal-close-btn" + className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center" + onClick={onCancel} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" /> + </div> </div> - </div> - <div className="px-6 py-3"> - <div className="mb-5 flex"> - <div className="mr-3 grow"> - <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t('common.publishAsPipeline.name', { ns: 'pipeline' })} + <div className="px-6 py-3"> + <div className="mb-5 flex"> + <div className="mr-3 grow"> + <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> + {t('common.publishAsPipeline.name', { ns: 'pipeline' })} + </div> + <Input + value={pipelineName} + onChange={e => setPipelineName(e.target.value)} + placeholder={t('common.publishAsPipeline.namePlaceholder', { ns: 'pipeline' }) || ''} + /> </div> - <Input - value={pipelineName} - onChange={e => setPipelineName(e.target.value)} - placeholder={t('common.publishAsPipeline.namePlaceholder', { ns: 'pipeline' }) || ''} + <AppIcon + size="xxl" + onClick={() => { setShowAppIconPicker(true) }} + className="mt-2 shrink-0 cursor-pointer" + iconType={pipelineIcon?.icon_type} + icon={pipelineIcon?.icon} + background={pipelineIcon?.icon_background} + imageUrl={pipelineIcon?.icon_url} /> </div> - <AppIcon - size="xxl" - onClick={() => { setShowAppIconPicker(true) }} - className="mt-2 shrink-0 cursor-pointer" - iconType={pipelineIcon?.icon_type} - icon={pipelineIcon?.icon} - background={pipelineIcon?.icon_background} - imageUrl={pipelineIcon?.icon_url} - /> - </div> - <div> - <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t('common.publishAsPipeline.description', { ns: 'pipeline' })} + <div> + <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> + {t('common.publishAsPipeline.description', { ns: 'pipeline' })} + </div> + <Textarea + className="resize-none" + placeholder={t('common.publishAsPipeline.descriptionPlaceholder', { ns: 'pipeline' }) || ''} + value={description} + onChange={e => setDescription(e.target.value)} + /> </div> - <Textarea - className="resize-none" - placeholder={t('common.publishAsPipeline.descriptionPlaceholder', { ns: 'pipeline' }) || ''} - value={description} - onChange={e => setDescription(e.target.value)} - /> </div> - </div> - <div className="flex items-center justify-end px-6 py-5"> - <Button - className="mr-2" - onClick={onCancel} - > - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button - disabled={!pipelineName?.trim() || confirmDisabled} - variant="primary" - onClick={() => handleConfirm()} - > - {t('common.publish', { ns: 'workflow' })} - </Button> - </div> - </Modal> - {showAppIconPicker && ( - <AppIconPicker - onSelect={handleSelectIcon} - onClose={handleCloseIconPicker} - /> - )} + <div className="flex items-center justify-end px-6 py-5"> + <Button + className="mr-2" + onClick={onCancel} + > + {t('operation.cancel', { ns: 'common' })} + </Button> + <Button + disabled={!pipelineName?.trim() || confirmDisabled} + variant="primary" + onClick={() => handleConfirm()} + > + {t('common.publish', { ns: 'workflow' })} + </Button> + </div> + {showAppIconPicker && ( + <AppIconPicker + onSelect={handleSelectIcon} + onClose={handleCloseIconPicker} + /> + )} + </DialogContent> + </Dialog> </> ) } diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx index 742c29a6da..da75e03b5c 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx @@ -1,6 +1,7 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { RiAlertFill, RiCloseLine, @@ -9,7 +10,6 @@ import { import { memo } from 'react' import { useTranslation } from 'react-i18next' import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' -import Modal from '@/app/components/base/modal' import { useUpdateDSLModal } from '../hooks/use-update-dsl-modal' import VersionMismatchModal from './version-mismatch-modal' @@ -39,66 +39,71 @@ const UpdateDSLModal = ({ return ( <> - <Modal - className="w-[520px] rounded-2xl p-6" - isShow={show} - onClose={onCancel} + <Dialog + open={show} + onOpenChange={(open) => { + if (!open) + onCancel() + }} > - <div className="mb-3 flex items-center justify-between"> - <div className="title-2xl-semi-bold text-text-primary">{t('common.importDSL', { ns: 'workflow' })}</div> - <div className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center" onClick={onCancel}> - <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> - </div> - </div> - <div className="relative mb-2 flex grow gap-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs"> - <div className="absolute top-0 left-0 h-full w-full bg-toast-warning-bg opacity-40" /> - <div className="flex items-start justify-center p-1"> - <RiAlertFill className="h-4 w-4 shrink-0 text-text-warning-secondary" /> - </div> - <div className="flex grow flex-col items-start gap-0.5 py-1"> - <div className="system-xs-medium whitespace-pre-line text-text-primary">{t('common.importDSLTip', { ns: 'workflow' })}</div> - <div className="flex items-start gap-1 self-stretch pt-1 pb-0.5"> - <Button - size="small" - variant="secondary" - className="z-1000" - onClick={onBackup} - > - <RiFileDownloadLine className="h-3.5 w-3.5 text-components-button-secondary-text" /> - <div className="flex items-center justify-center gap-1 px-[3px]"> - {t('common.backupCurrentDraft', { ns: 'workflow' })} - </div> - </Button> + <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-none p-6 text-left align-middle"> + + <div className="mb-3 flex items-center justify-between"> + <div className="title-2xl-semi-bold text-text-primary">{t('common.importDSL', { ns: 'workflow' })}</div> + <div className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center" onClick={onCancel}> + <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> </div> </div> - </div> - <div> - <div className="pt-2 system-md-semibold text-text-primary"> - {t('common.chooseDSL', { ns: 'workflow' })} + <div className="relative mb-2 flex grow gap-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs"> + <div className="absolute top-0 left-0 h-full w-full bg-toast-warning-bg opacity-40" /> + <div className="flex items-start justify-center p-1"> + <RiAlertFill className="h-4 w-4 shrink-0 text-text-warning-secondary" /> + </div> + <div className="flex grow flex-col items-start gap-0.5 py-1"> + <div className="system-xs-medium whitespace-pre-line text-text-primary">{t('common.importDSLTip', { ns: 'workflow' })}</div> + <div className="flex items-start gap-1 self-stretch pt-1 pb-0.5"> + <Button + size="small" + variant="secondary" + className="z-1000" + onClick={onBackup} + > + <RiFileDownloadLine className="h-3.5 w-3.5 text-components-button-secondary-text" /> + <div className="flex items-center justify-center gap-1 px-[3px]"> + {t('common.backupCurrentDraft', { ns: 'workflow' })} + </div> + </Button> + </div> + </div> </div> - <div className="flex w-full flex-col items-start justify-center gap-4 self-stretch py-4"> - <Uploader - file={currentFile} - updateFile={handleFile} - className="mt-0! w-full" - accept=".pipeline" - displayName="PIPELINE" - /> + <div> + <div className="pt-2 system-md-semibold text-text-primary"> + {t('common.chooseDSL', { ns: 'workflow' })} + </div> + <div className="flex w-full flex-col items-start justify-center gap-4 self-stretch py-4"> + <Uploader + file={currentFile} + updateFile={handleFile} + className="mt-0! w-full" + accept=".pipeline" + displayName="PIPELINE" + /> + </div> </div> - </div> - <div className="flex items-center justify-end gap-2 self-stretch pt-5"> - <Button onClick={onCancel}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button - disabled={!currentFile || loading} - variant="primary" - tone="destructive" - onClick={handleImport} - loading={loading} - > - {t('common.overwriteAndImport', { ns: 'workflow' })} - </Button> - </div> - </Modal> + <div className="flex items-center justify-end gap-2 self-stretch pt-5"> + <Button onClick={onCancel}>{t('newApp.Cancel', { ns: 'app' })}</Button> + <Button + disabled={!currentFile || loading} + variant="primary" + tone="destructive" + onClick={handleImport} + loading={loading} + > + {t('common.overwriteAndImport', { ns: 'workflow' })} + </Button> + </div> + </DialogContent> + </Dialog> <VersionMismatchModal isShow={showErrorModal} versions={versions} diff --git a/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx b/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx index dc50e972e9..221757de02 100644 --- a/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx +++ b/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx @@ -1,7 +1,14 @@ import type { MouseEventHandler } from 'react' -import { Button } from '@langgenius/dify-ui/button' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' type VersionMismatchModalProps = { isShow: boolean @@ -22,32 +29,36 @@ const VersionMismatchModal = ({ const { t } = useTranslation() return ( - <Modal - isShow={isShow} - onClose={onClose} - className="w-[480px]" + <AlertDialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div> - <div className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> - <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> - <br /> - <div> - {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - <span className="system-md-medium">{versions?.importedVersion}</span> - </div> - <div> - {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - <span className="system-md-medium">{versions?.systemVersion}</span> - </div> + <AlertDialogContent className="w-[480px] max-w-none! overflow-hidden! border-none p-6 text-left align-middle shadow-xl"> + <div className="flex flex-col items-start gap-2 self-stretch pb-4"> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</AlertDialogTitle> + <AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary"> + <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> + <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> + <br /> + <div> + {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} + <span className="system-md-medium">{versions?.importedVersion}</span> + </div> + <div> + {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} + <span className="system-md-medium">{versions?.systemVersion}</span> + </div> + </AlertDialogDescription> </div> - </div> - <div className="flex items-start justify-end gap-2 self-stretch pt-6"> - <Button variant="secondary" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button variant="primary" tone="destructive" onClick={onConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button> - </div> - </Modal> + <AlertDialogActions className="items-start p-0 pt-6"> + <AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={onConfirm}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> ) } diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts index 2449bcc605..6876df9014 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts @@ -340,6 +340,7 @@ describe('useUpdateDSLModal', () => { id: 'import-id', status: DSLImportStatus.FAILED, pipeline_id: 'test-pipeline-id', + error: 'Missing rag_pipeline data in YAML content', }) const { result } = renderUpdateDSLModal() @@ -351,7 +352,10 @@ describe('useUpdateDSLModal', () => { await (result.current.handleImport as unknown as AsyncFn)() }) - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(toastMocks.call).toHaveBeenCalledWith({ + type: 'error', + message: 'Missing rag_pipeline data in YAML content', + }) }) it('should notify error when importDSL throws', async () => { @@ -369,6 +373,29 @@ describe('useUpdateDSLModal', () => { expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) + it('should notify response error when importDSL rejects with a response body', async () => { + mockImportDSL.mockRejectedValue(new Response(JSON.stringify({ + error: 'Missing rag_pipeline data in YAML content', + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + })) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(toastMocks.call).toHaveBeenCalledWith({ + type: 'error', + message: 'Missing rag_pipeline data in YAML content', + }) + }) + it('should notify error when pipeline_id is missing on success', async () => { mockImportDSL.mockResolvedValue({ id: 'import-id', @@ -468,6 +495,7 @@ describe('useUpdateDSLModal', () => { mockImportDSLConfirm.mockResolvedValue({ status: DSLImportStatus.FAILED, pipeline_id: 'test-pipeline-id', + error: 'Import information expired or does not exist', }) const { result } = renderUpdateDSLModal() @@ -477,7 +505,10 @@ describe('useUpdateDSLModal', () => { await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() }) - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(toastMocks.call).toHaveBeenCalledWith({ + type: 'error', + message: 'Import information expired or does not exist', + }) }) it('should notify error when confirm throws exception', async () => { @@ -493,6 +524,27 @@ describe('useUpdateDSLModal', () => { expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) + it('should notify response error when confirm rejects with a response body', async () => { + mockImportDSLConfirm.mockRejectedValue(new Response(JSON.stringify({ + error: 'Import information expired or does not exist', + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + })) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(toastMocks.call).toHaveBeenCalledWith({ + type: 'error', + message: 'Import information expired or does not exist', + }) + }) + it('should notify error when confirm succeeds but pipeline_id is missing', async () => { mockImportDSLConfirm.mockResolvedValue({ status: DSLImportStatus.COMPLETED, diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts index 7271279996..0d12a0a881 100644 --- a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts +++ b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts @@ -15,11 +15,36 @@ type VersionInfo = { importedVersion: string systemVersion: string } +type ImportErrorResponse = { + message?: unknown + error?: unknown +} type UseUpdateDSLModalParams = { onCancel: () => void onImport?: () => void } const isCompletedStatus = (status: DSLImportStatus): boolean => status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS +const getNonEmptyString = (value: unknown): string | undefined => { + if (typeof value !== 'string') + return undefined + + const trimmedValue = value.trim() + return trimmedValue || undefined +} +const getImportErrorMessage = async (error: unknown): Promise<string | undefined> => { + if (error instanceof Response && !error.bodyUsed) { + try { + const errorData = await error.clone().json() as ImportErrorResponse + return getNonEmptyString(errorData.message) ?? getNonEmptyString(errorData.error) + } + catch {} + } + + if (error instanceof Error) + return getNonEmptyString(error.message) + + return undefined +} export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParams) => { const { t } = useTranslation() const { eventEmitter } = useEventEmitterContextContext() @@ -52,9 +77,9 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam if (!file) setFileContent('') } - const notifyError = useCallback(() => { + const notifyError = useCallback((message?: string) => { setLoading(false) - toast.error(t('common.importFailure', { ns: 'workflow' })) + toast.error(message || t('common.importFailure', { ns: 'workflow' })) }, [t]) const updateWorkflow = useCallback(async (pipelineId: string) => { const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`) @@ -117,10 +142,10 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam else if (status === DSLImportStatus.PENDING) showVersionMismatch(id, imported_dsl_version, current_dsl_version) else - notifyError() + notifyError(response.error) } - catch { - notifyError() + catch (error) { + notifyError(await getImportErrorMessage(error)) } isCreatingRef.current = false }, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError]) @@ -128,16 +153,16 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam if (!importId) return try { - const { status, pipeline_id } = await importDSLConfirm(importId) + const { status, pipeline_id, error } = await importDSLConfirm(importId) if (status === DSLImportStatus.COMPLETED) { await completeImport(pipeline_id) return } if (status === DSLImportStatus.FAILED) - notifyError() + notifyError(error) } - catch { - notifyError() + catch (error) { + notifyError(await getImportErrorMessage(error)) } }, [importId, importDSLConfirm, completeImport, notifyError]) return { diff --git a/web/app/components/share/text-generation/info-modal.tsx b/web/app/components/share/text-generation/info-modal.tsx index 29471548ef..f3b2bef5ad 100644 --- a/web/app/components/share/text-generation/info-modal.tsx +++ b/web/app/components/share/text-generation/info-modal.tsx @@ -1,8 +1,8 @@ import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import * as React from 'react' import AppIcon from '@/app/components/base/app-icon' -import Modal from '@/app/components/base/modal' import { appDefaultIconBackground } from '@/config' type Props = { @@ -17,37 +17,42 @@ const InfoModal = ({ data, }: Props) => { return ( - <Modal - isShow={isShow} - onClose={onClose} - className="max-w-[400px] min-w-[400px] p-0!" - closable + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className={cn('flex flex-col items-center gap-4 px-4 pt-10 pb-8')}> - <AppIcon - size="xxl" - iconType={data?.icon_type} - icon={data?.icon} - background={data?.icon_background || appDefaultIconBackground} - imageUrl={data?.icon_url} - /> - <div className="system-xl-semibold text-text-secondary">{data?.title}</div> - <div className="system-xs-regular text-text-tertiary"> - {/* copyright */} - {data?.copyright && ( - <div> - © - {(new Date()).getFullYear()} - {' '} - {data?.copyright} - </div> - )} - {data?.custom_disclaimer && ( - <div className="mt-2">{data.custom_disclaimer}</div> - )} + <DialogContent className="w-full max-w-[400px] min-w-[400px] overflow-hidden! border-none p-0! text-left align-middle"> + <DialogCloseButton data-testid="modal-close-button" /> + + <div className={cn('flex flex-col items-center gap-4 px-4 pt-10 pb-8')}> + <AppIcon + size="xxl" + iconType={data?.icon_type} + icon={data?.icon} + background={data?.icon_background || appDefaultIconBackground} + imageUrl={data?.icon_url} + /> + <div className="system-xl-semibold text-text-secondary">{data?.title}</div> + <div className="system-xs-regular text-text-tertiary"> + {/* copyright */} + {data?.copyright && ( + <div> + © + {(new Date()).getFullYear()} + {' '} + {data?.copyright} + </div> + )} + {data?.custom_disclaimer && ( + <div className="mt-2">{data.custom_disclaimer}</div> + )} + </div> </div> - </div> - </Modal> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx index 9029ee56a2..c496dd9adf 100644 --- a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx @@ -3,9 +3,12 @@ import type { MCPServerDetail } from '@/app/components/tools/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import MCPServerModal from '../mcp-server-modal' +const mockGetSocket = vi.hoisted(() => vi.fn()) +const mockSocketEmit = vi.hoisted(() => vi.fn()) + // Mock the services vi.mock('@/service/use-tools', () => ({ useCreateMCPServer: () => ({ @@ -19,6 +22,12 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateMCPServerDetail: () => vi.fn(), })) +vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ + webSocketClient: { + getSocket: mockGetSocket, + }, +})) + describe('MCPServerModal', () => { const createWrapper = () => { const queryClient = new QueryClient({ @@ -38,6 +47,11 @@ describe('MCPServerModal', () => { onHide: vi.fn(), } + beforeEach(() => { + vi.clearAllMocks() + mockGetSocket.mockReturnValue(null) + }) + describe('Rendering', () => { it('should render without crashing', () => { render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() }) @@ -168,6 +182,15 @@ describe('MCPServerModal', () => { } }) + it('should call onHide when the dialog requests close', () => { + const onHide = vi.fn() + render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() }) + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + expect(onHide).toHaveBeenCalledTimes(1) + }) + it('should disable confirm button when description is empty', () => { render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() }) @@ -346,6 +369,48 @@ describe('MCPServerModal', () => { }) }) + it('should ignore parameters without variables when rendering and submitting', async () => { + const onHide = vi.fn() + const latestParams = [ + { label: 'Missing variable', type: 'string' }, + ] + + render( + <MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />, + { wrapper: createWrapper() }, + ) + + expect(screen.queryByText('Missing variable')).not.toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder'), { + target: { value: 'Test description' }, + }) + fireEvent.click(screen.getByText('tools.mcp.server.modal.confirm')) + + await waitFor(() => { + expect(onHide).toHaveBeenCalled() + }) + }) + + it('should emit a created update when socket exists', async () => { + const onHide = vi.fn() + mockGetSocket.mockReturnValue({ emit: mockSocketEmit }) + + render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() }) + + fireEvent.change(screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder'), { + target: { value: 'Test description' }, + }) + fireEvent.click(screen.getByText('tools.mcp.server.modal.confirm')) + + await waitFor(() => { + expect(mockSocketEmit).toHaveBeenCalledWith('collaboration_event', expect.objectContaining({ + type: 'mcp_server_update', + data: expect.objectContaining({ action: 'created' }), + })) + }) + }) + it('should handle empty description submission', async () => { const onHide = vi.fn() render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() }) diff --git a/web/app/components/tools/mcp/mcp-server-modal.tsx b/web/app/components/tools/mcp/mcp-server-modal.tsx index ab4a5c36f4..cfb33f3839 100644 --- a/web/app/components/tools/mcp/mcp-server-modal.tsx +++ b/web/app/components/tools/mcp/mcp-server-modal.tsx @@ -3,12 +3,11 @@ import type { MCPServerDetail, } from '@/app/components/tools/types' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import Modal from '@/app/components/base/modal' import Textarea from '@/app/components/base/textarea' import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' @@ -20,11 +19,19 @@ import { type ModalProps = { appID: string - latestParams?: any[] + latestParams?: MCPServerParam[] data?: MCPServerDetail show: boolean onHide: () => void - appInfo?: any + appInfo?: { + description?: string + } +} + +type MCPServerParam = { + variable?: string + label?: string + type?: string } const MCPServerModal = ({ @@ -42,7 +49,7 @@ const MCPServerModal = ({ const defaultDescription = data?.description || appInfo?.description || '' const [description, setDescription] = React.useState(defaultDescription) - const [params, setParams] = React.useState(data?.parameters || {}) + const [params, setParams] = React.useState<Record<string, string>>(data?.parameters || {}) const handleParamChange = (variable: string, value: string) => { setParams(prev => ({ @@ -52,10 +59,14 @@ const MCPServerModal = ({ } const getParamValue = () => { - const res = {} as any - latestParams.map((param) => { - res[param.variable] = params[param.variable] - return param + const res: Record<string, string> = {} + latestParams.forEach((param) => { + if (!param.variable) + return + + const value = params[param.variable] + if (value !== undefined) + res[param.variable] = value }) return res } @@ -78,7 +89,11 @@ const MCPServerModal = ({ const submit = async () => { if (!data) { - const payload: any = { + const payload: { + appID: string + description?: string + parameters: Record<string, string> + } = { appID, parameters: getParamValue(), } @@ -92,13 +107,18 @@ const MCPServerModal = ({ onHide() } else { - const payload: any = { + const payload: { + appID: string + id: string + description: string + parameters: Record<string, string> + } = { appID, id: data.id, parameters: getParamValue(), + description, } - payload.description = description await updateMCPServer(payload) invalidateMCPServerDetail(appID) emitMcpServerUpdate('updated') @@ -107,56 +127,67 @@ const MCPServerModal = ({ } return ( - <Modal - isShow={show} - onClose={onHide} - className={cn('relative max-w-[520px]! p-0!')} + <Dialog + open={show} + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <div className="absolute top-5 right-5 z-10 cursor-pointer p-1.5" onClick={onHide}> - <RiCloseLine className="h-5 w-5 text-text-tertiary" /> - </div> - <div className="relative p-6 pb-3 title-2xl-semi-bold text-xl text-text-primary"> - {!data ? t('mcp.server.modal.addTitle', { ns: 'tools' }) : t('mcp.server.modal.editTitle', { ns: 'tools' })} - </div> - <div className="space-y-5 px-6 py-3"> - <div className="space-y-0.5"> - <div className="flex h-6 items-center gap-1"> - <div className="system-sm-medium text-text-secondary">{t('mcp.server.modal.description', { ns: 'tools' })}</div> - <div className="system-xs-regular text-text-destructive-secondary">*</div> - </div> - <Textarea - className="h-[96px] resize-none" - value={description} - placeholder={t('mcp.server.modal.descriptionPlaceholder', { ns: 'tools' })} - onChange={e => setDescription(e.target.value)} - > - </Textarea> + <DialogContent className="w-[calc(100vw-2rem)] max-w-[520px]! overflow-hidden! border-none p-0! text-left align-middle transition-all duration-100 ease-in"> + <div className="absolute top-5 right-5 z-10 cursor-pointer p-1.5" onClick={onHide}> + <RiCloseLine className="h-5 w-5 text-text-tertiary" /> </div> - {latestParams.length > 0 && ( - <div> - <div className="mb-1 flex items-center gap-2"> - <div className="shrink-0 system-xs-medium-uppercase text-text-primary">{t('mcp.server.modal.parameters', { ns: 'tools' })}</div> - <Divider type="horizontal" className="m-0! h-px! grow bg-divider-subtle" /> - </div> - <div className="mb-2 body-xs-regular text-text-tertiary">{t('mcp.server.modal.parametersTip', { ns: 'tools' })}</div> - <div className="space-y-3"> - {latestParams.map(paramItem => ( - <MCPServerParamItem - key={paramItem.variable} - data={paramItem} - value={params[paramItem.variable] || ''} - onChange={value => handleParamChange(paramItem.variable, value)} - /> - ))} + <div className="relative p-6 pb-3 title-2xl-semi-bold text-xl text-text-primary"> + {!data ? t('mcp.server.modal.addTitle', { ns: 'tools' }) : t('mcp.server.modal.editTitle', { ns: 'tools' })} + </div> + <div className="space-y-5 px-6 py-3"> + <div className="space-y-0.5"> + <div className="flex h-6 items-center gap-1"> + <div className="system-sm-medium text-text-secondary">{t('mcp.server.modal.description', { ns: 'tools' })}</div> + <div className="system-xs-regular text-text-destructive-secondary">*</div> </div> + <Textarea + className="h-[96px] resize-none" + value={description} + placeholder={t('mcp.server.modal.descriptionPlaceholder', { ns: 'tools' })} + onChange={e => setDescription(e.target.value)} + > + </Textarea> </div> - )} - </div> - <div className="flex flex-row-reverse p-6 pt-5"> - <Button disabled={!description || creating || updating} className="ml-2" variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.server.modal.confirm', { ns: 'tools' })}</Button> - <Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button> - </div> - </Modal> + {latestParams.length > 0 && ( + <div> + <div className="mb-1 flex items-center gap-2"> + <div className="shrink-0 system-xs-medium-uppercase text-text-primary">{t('mcp.server.modal.parameters', { ns: 'tools' })}</div> + <Divider type="horizontal" className="m-0! h-px! grow bg-divider-subtle" /> + </div> + <div className="mb-2 body-xs-regular text-text-tertiary">{t('mcp.server.modal.parametersTip', { ns: 'tools' })}</div> + <div className="space-y-3"> + {latestParams.map((paramItem) => { + if (!paramItem.variable) + return null + + const { variable } = paramItem + + return ( + <MCPServerParamItem + key={variable} + data={paramItem} + value={params[variable] || ''} + onChange={value => handleParamChange(variable, value)} + /> + ) + })} + </div> + </div> + )} + </div> + <div className="flex flex-row-reverse p-6 pt-5"> + <Button disabled={!description || creating || updating} className="ml-2" variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.server.modal.confirm', { ns: 'tools' })}</Button> + <Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index 165535127d..79179ae3dc 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -4,17 +4,15 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AppIconType } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine, RiEditLine } from '@remixicon/react' import { useHover } from 'ahooks' -import { noop } from 'es-toolkit/function' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import AppIconPicker from '@/app/components/base/app-icon-picker' import { Mcp } from '@/app/components/base/icons/src/vender/other' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import TabSlider from '@/app/components/base/tab-slider' import { MCPAuthMethod } from '@/app/components/tools/types' import { shouldUseMcpIconForAppIcon } from '@/utils/mcp' @@ -281,18 +279,16 @@ const MCPModal: FC<DuplicateAppModalProps> = ({ const formKey = data?.id ?? 'create' return ( - <Modal - isShow={show} - onClose={noop} - className={cn('relative max-w-[520px]!', 'p-6')} - > - <MCPModalContent - key={formKey} - data={data} - onConfirm={onConfirm} - onHide={onHide} - /> - </Modal> + <Dialog open={show}> + <DialogContent className="w-full max-w-[520px]! overflow-hidden! border-none p-6 text-left align-middle"> + <MCPModalContent + key={formKey} + data={data} + onConfirm={onConfirm} + onHide={onHide} + /> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx index 241cd7d762..684c700648 100644 --- a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx +++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx @@ -126,6 +126,15 @@ describe('UpdateDSLModal', () => { expect(defaultProps.onBackup).toHaveBeenCalledTimes(1) }) + it('should call cancel handler when the import dialog requests close', () => { + const onCancel = vi.fn() + renderModal({ ...defaultProps, onCancel }) + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + it('should import a valid file and emit workflow update payload', async () => { renderModal() @@ -228,6 +237,32 @@ describe('UpdateDSLModal', () => { }) }) + it('should close the pending modal when dialog requests close', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-8', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'app.newApp.Confirm' })).not.toBeInTheDocument() + }) + }) + it('should show an error when the selected file content is invalid for the current app mode', async () => { class InvalidDSLFileReader extends MockFileReader { override readAsText(_file: Blob) { diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index 17e1de3feb..93e9d1fa85 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -189,7 +189,6 @@ const OnlineUsers = () => { placement="bottom-start" sideOffset={8} alignOffset={-48} - className="z-[9999]" popupClassName={cn( 'mt-1.5 flex max-h-[200px] w-[240px] flex-col overflow-y-auto', 'rounded-xl border-[0.5px] border-components-panel-border', diff --git a/web/app/components/workflow/nodes/http/components/authorization/index.tsx b/web/app/components/workflow/nodes/http/components/authorization/index.tsx index b72e52911d..684f943d5f 100644 --- a/web/app/components/workflow/nodes/http/components/authorization/index.tsx +++ b/web/app/components/workflow/nodes/http/components/authorization/index.tsx @@ -4,12 +4,12 @@ import type { Authorization as AuthorizationPayloadType } from '../../types' import type { Var } from '@/app/components/workflow/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 { produce } from 'immer' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import BaseInput from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import { VarType } from '@/app/components/workflow/types' @@ -115,70 +115,78 @@ const Authorization: FC<Props> = ({ onHide() }, [tempPayload, onChange, onHide]) return ( - <Modal - title={t(`${i18nPrefix}.authorization`, { ns: 'workflow' })} - isShow={isShow} - onClose={onHide} + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <div> - <div className="space-y-2"> - <Field title={t(`${i18nPrefix}.authorizationType`, { ns: 'workflow' })}> - <RadioGroup - options={[ - { value: AuthorizationType.none, label: t(`${i18nPrefix}.no-auth`, { ns: 'workflow' }) }, - { value: AuthorizationType.apiKey, label: t(`${i18nPrefix}.api-key`, { ns: 'workflow' }) }, - ]} - value={tempPayload.type} - onChange={handleAuthTypeChange} - /> - </Field> + <DialogContent className="overflow-hidden! border-none text-left align-middle"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(`${i18nPrefix}.authorization`, { ns: 'workflow' })} + </DialogTitle> - {tempPayload.type === AuthorizationType.apiKey && ( - <> - <Field title={t(`${i18nPrefix}.auth-type`, { ns: 'workflow' })}> - <RadioGroup - options={[ - { value: APIType.basic, label: t(`${i18nPrefix}.basic`, { ns: 'workflow' }) }, - { value: APIType.bearer, label: t(`${i18nPrefix}.bearer`, { ns: 'workflow' }) }, - { value: APIType.custom, label: t(`${i18nPrefix}.custom`, { ns: 'workflow' }) }, - ]} - value={tempPayload.config?.type || APIType.basic} - onChange={handleAuthAPITypeChange} - /> - </Field> - {tempPayload.config?.type === APIType.custom && ( - <Field title={t(`${i18nPrefix}.header`, { ns: 'workflow' })} isRequired> - <BaseInput - value={tempPayload.config?.header || ''} - onChange={handleAPIKeyOrHeaderChange('header')} + <div> + <div className="space-y-2"> + <Field title={t(`${i18nPrefix}.authorizationType`, { ns: 'workflow' })}> + <RadioGroup + options={[ + { value: AuthorizationType.none, label: t(`${i18nPrefix}.no-auth`, { ns: 'workflow' }) }, + { value: AuthorizationType.apiKey, label: t(`${i18nPrefix}.api-key`, { ns: 'workflow' }) }, + ]} + value={tempPayload.type} + onChange={handleAuthTypeChange} + /> + </Field> + + {tempPayload.type === AuthorizationType.apiKey && ( + <> + <Field title={t(`${i18nPrefix}.auth-type`, { ns: 'workflow' })}> + <RadioGroup + options={[ + { value: APIType.basic, label: t(`${i18nPrefix}.basic`, { ns: 'workflow' }) }, + { value: APIType.bearer, label: t(`${i18nPrefix}.bearer`, { ns: 'workflow' }) }, + { value: APIType.custom, label: t(`${i18nPrefix}.custom`, { ns: 'workflow' }) }, + ]} + value={tempPayload.config?.type || APIType.basic} + onChange={handleAuthAPITypeChange} /> </Field> - )} + {tempPayload.config?.type === APIType.custom && ( + <Field title={t(`${i18nPrefix}.header`, { ns: 'workflow' })} isRequired> + <BaseInput + value={tempPayload.config?.header || ''} + onChange={handleAPIKeyOrHeaderChange('header')} + /> + </Field> + )} - <Field title={t(`${i18nPrefix}.api-key-title`, { ns: 'workflow' })} isRequired> - <div className="flex"> - <Input - instanceId="http-api-key" - className={cn(isFocus ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-components-input-border-hover bg-components-input-bg-normal', 'w-0 grow rounded-lg border px-3 py-[6px]')} - value={tempPayload.config?.api_key || ''} - onChange={handleAPIKeyChange} - nodesOutputVars={availableVars} - availableNodes={availableNodesWithParent} - onFocusChange={setIsFocus} - placeholder={' '} - placeholderClassName="leading-[21px]!" - /> - </div> - </Field> - </> - )} + <Field title={t(`${i18nPrefix}.api-key-title`, { ns: 'workflow' })} isRequired> + <div className="flex"> + <Input + instanceId="http-api-key" + className={cn(isFocus ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-components-input-border-hover bg-components-input-bg-normal', 'w-0 grow rounded-lg border px-3 py-[6px]')} + value={tempPayload.config?.api_key || ''} + onChange={handleAPIKeyChange} + nodesOutputVars={availableVars} + availableNodes={availableNodesWithParent} + onFocusChange={setIsFocus} + placeholder={' '} + placeholderClassName="leading-[21px]!" + /> + </div> + </Field> + </> + )} + </div> + <div className="mt-6 flex justify-end space-x-2"> + <Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button variant="primary" onClick={handleConfirm}>{t('operation.save', { ns: 'common' })}</Button> + </div> </div> - <div className="mt-6 flex justify-end space-x-2"> - <Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleConfirm}>{t('operation.save', { ns: 'common' })}</Button> - </div> - </div> - </Modal> + </DialogContent> + </Dialog> ) } export default React.memo(Authorization) diff --git a/web/app/components/workflow/nodes/http/components/curl-panel.tsx b/web/app/components/workflow/nodes/http/components/curl-panel.tsx index 8ba5fb36a9..87dc2a3427 100644 --- a/web/app/components/workflow/nodes/http/components/curl-panel.tsx +++ b/web/app/components/workflow/nodes/http/components/curl-panel.tsx @@ -2,11 +2,11 @@ import type { FC } from 'react' import type { HttpNodeType } from '../types' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import Textarea from '@/app/components/base/textarea' import { useNodesInteractions } from '@/app/components/workflow/hooks' import { parseCurl } from './curl-parser' @@ -42,28 +42,35 @@ const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => { }, [onHide, nodeId, inputString, handleNodeSelect, handleCurlImport]) return ( - <Modal - title={t('nodes.http.curl.title', { ns: 'workflow' })} - isShow={isShow} - onClose={onHide} - className="w-[400px]! max-w-[400px]! p-4!" + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <div> - <Textarea - value={inputString} - className="my-3 h-40 w-full grow" - onChange={e => setInputString(e.target.value)} - placeholder={t('nodes.http.curl.placeholder', { ns: 'workflow' })!} - /> - </div> - <div className="mt-4 flex justify-end space-x-2"> - <Button className="w-[95px]!" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button className="w-[95px]!" variant="primary" onClick={handleSave}> - {' '} - {t('operation.save', { ns: 'common' })} - </Button> - </div> - </Modal> + <DialogContent className="w-[400px]! max-w-[400px]! overflow-hidden! border-none p-4! text-left align-middle"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t('nodes.http.curl.title', { ns: 'workflow' })} + </DialogTitle> + + <div> + <Textarea + value={inputString} + className="my-3 h-40 w-full grow" + onChange={e => setInputString(e.target.value)} + placeholder={t('nodes.http.curl.placeholder', { ns: 'workflow' })!} + /> + </div> + <div className="mt-4 flex justify-end space-x-2"> + <Button className="w-[95px]!" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button className="w-[95px]!" variant="primary" onClick={handleSave}> + {' '} + {t('operation.save', { ns: 'common' })} + </Button> + </div> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx index fa5166a06f..f4445fd54d 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx @@ -1,5 +1,5 @@ import type { SchemaRoot } from '../../types' -import Modal from '../../../../../base/modal' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { JsonSchemaConfig } from './json-schema-config' type JsonSchemaConfigModalProps = { @@ -16,16 +16,21 @@ export function JsonSchemaConfigModal({ onClose, }: JsonSchemaConfigModalProps) { return ( - <Modal - isShow={isShow} - onClose={onClose} - className="h-[800px] max-w-[960px] p-0" + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <JsonSchemaConfig - defaultSchema={defaultSchema} - onSave={onSave} - onClose={onClose} - /> - </Modal> + <DialogContent className="h-[800px] max-h-none w-full max-w-[960px] overflow-hidden! border-none p-0 text-left align-middle"> + + <JsonSchemaConfig + defaultSchema={defaultSchema} + onSave={onSave} + onClose={onClose} + /> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx index f63d9a0d50..2c5de93964 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx @@ -110,24 +110,23 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param ), })) -vi.mock('@/app/components/base/modal', () => ({ +vi.mock('@langgenius/dify-ui/dialog', () => ({ __esModule: true, - default: ({ + Dialog: ({ children, - isShow, - title, + open, }: { children: ReactNode - isShow?: boolean - title?: ReactNode - }) => isShow + open?: boolean + }) => open !== false ? ( <div data-testid="base-modal"> - <div>{title}</div> {children} </div> ) : null, + DialogContent: ({ children }: { children: ReactNode }) => <>{children}</>, + DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>, })) vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({ diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx index ddded68989..122ee16941 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import type { Param } from '../../types' import type { MoreInfo } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' @@ -13,7 +14,6 @@ import { useTranslation } from 'react-i18next' import Field from '@/app/components/app/configuration/config-var/config-modal/field' import ConfigSelect from '@/app/components/app/configuration/config-var/config-select' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import Textarea from '@/app/components/base/textarea' import { ChangeType } from '@/app/components/workflow/types' import { checkKeys } from '@/utils/var' @@ -124,64 +124,71 @@ const AddExtractParameter: FC<Props> = ({ </div> )} {isShowModal && ( - <Modal - title={t(`${i18nPrefix}.addExtractParameter`, { ns: 'workflow' })} - isShow - onClose={hideModal} - className="w-[400px]! max-w-[400px]! p-4!" + <Dialog + open + onOpenChange={(open) => { + if (!open) + hideModal() + }} > - <div> - <div className="space-y-2"> - <Field title={t(`${i18nPrefix}.addExtractParameterContent.name`, { ns: 'workflow' })}> - <Input - value={param.name} - onChange={e => handleParamChange('name')(e.target.value)} - placeholder={t(`${i18nPrefix}.addExtractParameterContent.namePlaceholder`, { ns: 'workflow' })!} - /> - </Field> - <Field title={t(`${i18nPrefix}.addExtractParameterContent.type`, { ns: 'workflow' })}> - <Select - value={param.type} - onValueChange={value => value && handleParamChange('type')(value)} - > - <SelectTrigger className="w-full capitalize"> - {param.type} - </SelectTrigger> - <SelectContent> - {TYPES.map(type => ( - <SelectItem key={type} value={type} className="capitalize"> - <SelectItemText className="capitalize">{type}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> - </Field> - {param.type === ParamType.select && ( - <Field title={t('variableConfig.options', { ns: 'appDebug' })}> - <ConfigSelect options={param.options || []} onChange={handleParamChange('options')} /> + <DialogContent className="w-[400px]! max-w-[400px]! overflow-hidden! border-none p-4! text-left align-middle"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(`${i18nPrefix}.addExtractParameter`, { ns: 'workflow' })} + </DialogTitle> + + <div> + <div className="space-y-2"> + <Field title={t(`${i18nPrefix}.addExtractParameterContent.name`, { ns: 'workflow' })}> + <Input + value={param.name} + onChange={e => handleParamChange('name')(e.target.value)} + placeholder={t(`${i18nPrefix}.addExtractParameterContent.namePlaceholder`, { ns: 'workflow' })!} + /> </Field> - )} - <Field title={t(`${i18nPrefix}.addExtractParameterContent.description`, { ns: 'workflow' })}> - <Textarea - value={param.description} - onChange={e => handleParamChange('description')(e.target.value)} - placeholder={t(`${i18nPrefix}.addExtractParameterContent.descriptionPlaceholder`, { ns: 'workflow' })!} - /> - </Field> - <Field title={t(`${i18nPrefix}.addExtractParameterContent.required`, { ns: 'workflow' })}> - <> - <div className="mb-1.5 text-xs leading-[18px] font-normal text-text-tertiary">{t(`${i18nPrefix}.addExtractParameterContent.requiredContent`, { ns: 'workflow' })}</div> - <Switch size="lg" checked={param.required ?? false} onCheckedChange={handleParamChange('required')} /> - </> - </Field> + <Field title={t(`${i18nPrefix}.addExtractParameterContent.type`, { ns: 'workflow' })}> + <Select + value={param.type} + onValueChange={value => value && handleParamChange('type')(value)} + > + <SelectTrigger className="w-full capitalize"> + {param.type} + </SelectTrigger> + <SelectContent> + {TYPES.map(type => ( + <SelectItem key={type} value={type} className="capitalize"> + <SelectItemText className="capitalize">{type}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + </Field> + {param.type === ParamType.select && ( + <Field title={t('variableConfig.options', { ns: 'appDebug' })}> + <ConfigSelect options={param.options || []} onChange={handleParamChange('options')} /> + </Field> + )} + <Field title={t(`${i18nPrefix}.addExtractParameterContent.description`, { ns: 'workflow' })}> + <Textarea + value={param.description} + onChange={e => handleParamChange('description')(e.target.value)} + placeholder={t(`${i18nPrefix}.addExtractParameterContent.descriptionPlaceholder`, { ns: 'workflow' })!} + /> + </Field> + <Field title={t(`${i18nPrefix}.addExtractParameterContent.required`, { ns: 'workflow' })}> + <> + <div className="mb-1.5 text-xs leading-[18px] font-normal text-text-tertiary">{t(`${i18nPrefix}.addExtractParameterContent.requiredContent`, { ns: 'workflow' })}</div> + <Switch size="lg" checked={param.required ?? false} onCheckedChange={handleParamChange('required')} /> + </> + </Field> + </div> + <div className="mt-4 flex justify-end space-x-2"> + <Button className="w-[95px]!" onClick={hideModal}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button className="w-[95px]!" variant="primary" onClick={handleSave}>{isAdd ? t('operation.add', { ns: 'common' }) : t('operation.save', { ns: 'common' })}</Button> + </div> </div> - <div className="mt-4 flex justify-end space-x-2"> - <Button className="w-[95px]!" onClick={hideModal}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button className="w-[95px]!" variant="primary" onClick={handleSave}>{isAdd ? t('operation.add', { ns: 'common' }) : t('operation.save', { ns: 'common' })}</Button> - </div> - </div> - </Modal> + </DialogContent> + </Dialog> )} </div> ) diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx index 11d7d33295..87ee0447ff 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx @@ -1,7 +1,7 @@ import type { ToolVarInputs } from '../../types' import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { App } from '@/types/app' -import { screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' @@ -479,20 +479,23 @@ describe('InputVarList', () => { }, }) - await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' })) - await user.type(screen.getByPlaceholderText('Topic'), 'weather') + const topicInput = await screen.findByPlaceholderText('Topic') + await user.type(topicInput, 'weather') - expect(onChange).toHaveBeenLastCalledWith({ - assistant: { - app_id: 'app-1', - inputs: { topic: 'weather' }, - files: [], - }, - model: { - credential_id: 'credential-1', - }, + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + assistant: { + app_id: 'app-1', + inputs: { topic: 'weather' }, + files: [], + }, + model: { + credential_id: 'credential-1', + }, + }) }) + await user.click(screen.getByRole('button', { name: 'app.appSelector.label' })) await user.click(screen.getByText('workflow:errorMsg.configureModel')) await user.click(await screen.findByRole('combobox', { name: 'plugin.detailPanel.configureModel' })) await user.click(await screen.findByRole('option', { name: /GPT-4o/i })) diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 5797983e44..97fc4378b9 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -92,7 +92,7 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { return ( <div ref={bottomPanelRef} - className="absolute right-0 bottom-0 left-0 z-[60] px-1" + className="absolute right-0 bottom-0 left-0 z-10 px-1" style={ { width: bottomPanelWidth, diff --git a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx index 573d3e5642..689b2fe0b3 100644 --- a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx @@ -3,10 +3,10 @@ import type { ConversationVariable, } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { RiCloseLine } from '@remixicon/react' import { useMount } from 'ahooks' import copy from 'copy-to-clipboard' -import { noop } from 'es-toolkit/function' import { capitalize } from 'es-toolkit/string' import * as React from 'react' import { useCallback } from 'react' @@ -16,7 +16,6 @@ import { CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' import { BubbleX } from '@/app/components/base/icons/src/vender/line/others' -import Modal from '@/app/components/base/modal' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' @@ -76,87 +75,86 @@ const ConversationVariableModal = ({ }) return ( - <Modal - isShow - onClose={noop} - className={cn('h-[640px] w-[920px] max-w-[920px] p-0')} - > - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> - <div className="flex h-full w-full"> - {/* LEFT */} - <div className="flex h-full w-[224px] shrink-0 flex-col border-r border-divider-burn bg-background-sidenav-bg"> - <div className="shrink-0 pt-5 pr-4 pb-3 pl-5 system-xl-semibold text-text-primary">{t('chatVariable.panelTitle', { ns: 'workflow' })}</div> - <div className="grow overflow-y-auto px-3 py-2"> - {varList.map(chatVar => ( - <div key={chatVar.id} className={cn('group mb-0.5 flex cursor-pointer items-center rounded-lg p-2 hover:bg-state-base-hover', currentVar.id === chatVar.id && 'bg-state-base-hover')} onClick={() => setCurrentVar(chatVar)}> - <BubbleX className={cn('mr-1 h-4 w-4 shrink-0 text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')} /> - <div title={chatVar.name} className={cn('truncate system-sm-medium text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')}>{chatVar.name}</div> - </div> - ))} - </div> + <Dialog open> + <DialogContent className={cn('max-h-none w-full overflow-hidden! border-none text-left align-middle', cn('h-[640px] w-[920px] max-w-[920px] p-0'))}> + + <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}> + <RiCloseLine className="h-4 w-4 text-text-tertiary" /> </div> - {/* RIGHT */} - <div className="flex h-full w-0 grow flex-col bg-components-panel-bg"> - <div className="shrink-0 p-4 pb-2"> - <div className="flex items-center gap-1 py-1"> - <div className="system-xl-semibold text-text-primary">{currentVar.name}</div> - <div className="system-xs-medium text-text-tertiary">{capitalize(currentVar.value_type)}</div> + <div className="flex h-full w-full"> + {/* LEFT */} + <div className="flex h-full w-[224px] shrink-0 flex-col border-r border-divider-burn bg-background-sidenav-bg"> + <div className="shrink-0 pt-5 pr-4 pb-3 pl-5 system-xl-semibold text-text-primary">{t('chatVariable.panelTitle', { ns: 'workflow' })}</div> + <div className="grow overflow-y-auto px-3 py-2"> + {varList.map(chatVar => ( + <div key={chatVar.id} className={cn('group mb-0.5 flex cursor-pointer items-center rounded-lg p-2 hover:bg-state-base-hover', currentVar.id === chatVar.id && 'bg-state-base-hover')} onClick={() => setCurrentVar(chatVar)}> + <BubbleX className={cn('mr-1 h-4 w-4 shrink-0 text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')} /> + <div title={chatVar.name} className={cn('truncate system-sm-medium text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')}>{chatVar.name}</div> + </div> + ))} </div> </div> - <div className="flex h-0 grow flex-col p-4 pt-2"> - <div className="mb-2 flex shrink-0 items-center gap-2"> - <div className="shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('chatVariable.storedContent', { ns: 'workflow' }).toLocaleUpperCase()}</div> - <div - className="h-px grow" - style={{ - background: 'linear-gradient(to right, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255) 100%)', - }} - > + {/* RIGHT */} + <div className="flex h-full w-0 grow flex-col bg-components-panel-bg"> + <div className="shrink-0 p-4 pb-2"> + <div className="flex items-center gap-1 py-1"> + <div className="system-xl-semibold text-text-primary">{currentVar.name}</div> + <div className="system-xs-medium text-text-tertiary">{capitalize(currentVar.value_type)}</div> </div> - {!!latestValueTimestampMap[currentVar.id] && ( - <div className="shrink-0 system-xs-regular text-text-tertiary"> - {t('chatVariable.updatedAt', { ns: 'workflow' })} - {formatTime(latestValueTimestampMap[currentVar.id]!, t('dateTimeFormat', { ns: 'appLog' }) as string)} - </div> - )} </div> - <div className="grow overflow-y-auto"> - {currentVar.value_type !== ChatVarType.Number && currentVar.value_type !== ChatVarType.String && ( - <div className="flex h-full flex-col rounded-lg bg-components-input-bg-normal px-2 pb-2"> - <div className="flex h-7 shrink-0 items-center justify-between pt-1 pr-2 pl-3"> - <div className="system-xs-semibold text-text-secondary">JSON</div> - <div className="flex items-center p-1"> - {!isCopied - ? ( - <Copy className="h-4 w-4 cursor-pointer text-text-tertiary" onClick={handleCopy} /> - ) - : ( - <CopyCheck className="h-4 w-4 text-text-tertiary" /> - )} + <div className="flex h-0 grow flex-col p-4 pt-2"> + <div className="mb-2 flex shrink-0 items-center gap-2"> + <div className="shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('chatVariable.storedContent', { ns: 'workflow' }).toLocaleUpperCase()}</div> + <div + className="h-px grow" + style={{ + background: 'linear-gradient(to right, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255) 100%)', + }} + > + </div> + {!!latestValueTimestampMap[currentVar.id] && ( + <div className="shrink-0 system-xs-regular text-text-tertiary"> + {t('chatVariable.updatedAt', { ns: 'workflow' })} + {formatTime(latestValueTimestampMap[currentVar.id]!, t('dateTimeFormat', { ns: 'appLog' }) as string)} + </div> + )} + </div> + <div className="grow overflow-y-auto"> + {currentVar.value_type !== ChatVarType.Number && currentVar.value_type !== ChatVarType.String && ( + <div className="flex h-full flex-col rounded-lg bg-components-input-bg-normal px-2 pb-2"> + <div className="flex h-7 shrink-0 items-center justify-between pt-1 pr-2 pl-3"> + <div className="system-xs-semibold text-text-secondary">JSON</div> + <div className="flex items-center p-1"> + {!isCopied + ? ( + <Copy className="h-4 w-4 cursor-pointer text-text-tertiary" onClick={handleCopy} /> + ) + : ( + <CopyCheck className="h-4 w-4 text-text-tertiary" /> + )} + </div> + </div> + <div className="grow pl-4"> + <CodeEditor + readOnly + noWrapper + isExpand + language={CodeLanguage.json} + value={latestValueMap[currentVar.id] || ''} + isJSONStringifyBeauty + /> </div> </div> - <div className="grow pl-4"> - <CodeEditor - readOnly - noWrapper - isExpand - language={CodeLanguage.json} - value={latestValueMap[currentVar.id] || ''} - isJSONStringifyBeauty - /> - </div> - </div> - )} - {(currentVar.value_type === ChatVarType.Number || currentVar.value_type === ChatVarType.String) && ( - <div className="h-full overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-4 py-3 system-md-regular text-components-input-text-filled">{latestValueMap[currentVar.id] || ''}</div> - )} + )} + {(currentVar.value_type === ChatVarType.Number || currentVar.value_type === ChatVarType.String) && ( + <div className="h-full overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-4 py-3 system-md-regular text-components-input-text-filled">{latestValueMap[currentVar.id] || ''}</div> + )} + </div> </div> </div> </div> - </div> - </Modal> + </DialogContent> + </Dialog> ) } diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx index 8299ff2b30..bd0c9934c3 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx @@ -33,7 +33,8 @@ const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => { onOpenChange={setOpen} > <DropdownMenuTrigger - render={<Button size="small" className="px-1" onClick={e => e.stopPropagation()} />} + nativeButton={false} + render={<Button nativeButton={false} size="small" className="px-1" onClick={e => e.stopPropagation()} />} > <RiMoreFill className="h-4 w-4" /> </DropdownMenuTrigger> diff --git a/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx index 1f7948c41b..b2057ae54c 100644 --- a/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx +++ b/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx @@ -1,9 +1,16 @@ import type { FC } from 'react' import type { VersionHistory } from '@/types/workflow' -import { Button } from '@langgenius/dify-ui/button' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' type DeleteConfirmModalProps = { isOpen: boolean @@ -21,24 +28,36 @@ const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({ const { t } = useTranslation() return ( - <Modal className="p-0" isShow={isOpen} onClose={onClose}> - <div className="flex flex-col gap-y-2 p-6 pb-4"> - <div className="title-2xl-semi-bold text-text-primary"> - {`${t('operation.delete', { ns: 'common' })} ${versionInfo.marked_name || t('versionHistory.defaultName', { ns: 'workflow' })}`} + <AlertDialog + open={isOpen} + onOpenChange={(open) => { + if (!open) + onClose() + }} + > + <AlertDialogContent className="overflow-hidden! border-none text-left align-middle shadow-xl"> + <div className="flex flex-col gap-y-2 p-6 pb-4"> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> + {`${t('operation.delete', { ns: 'common' })} ${versionInfo.marked_name || t('versionHistory.defaultName', { ns: 'workflow' })}`} + </AlertDialogTitle> + <AlertDialogDescription className="system-md-regular text-text-secondary"> + {t('versionHistory.deletionTip', { ns: 'workflow' })} + </AlertDialogDescription> </div> - <p className="system-md-regular text-text-secondary"> - {t('versionHistory.deletionTip', { ns: 'workflow' })} - </p> - </div> - <div className="flex items-center justify-end gap-x-2 p-6"> - <Button onClick={onClose}> - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button variant="primary" tone="destructive" onClick={onDelete.bind(null, versionInfo.id)}> - {t('operation.delete', { ns: 'common' })} - </Button> - </div> - </Modal> + <AlertDialogActions> + <AlertDialogCancelButton + nativeButton={false} + variant="secondary" + closeProps={{ nativeButton: false }} + > + {t('operation.cancel', { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton nativeButton={false} onClick={onDelete.bind(null, versionInfo.id)}> + {t('operation.delete', { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> ) } diff --git a/web/app/components/workflow/panel/version-history-panel/empty.tsx b/web/app/components/workflow/panel/version-history-panel/empty.tsx index 21751234d7..844b53412e 100644 --- a/web/app/components/workflow/panel/version-history-panel/empty.tsx +++ b/web/app/components/workflow/panel/version-history-panel/empty.tsx @@ -22,7 +22,7 @@ const Empty: FC<EmptyProps> = ({ {t('versionHistory.filter.empty', { ns: 'workflow' })} </div> <div className="flex justify-center"> - <Button size="small" onClick={onResetFilter}> + <Button nativeButton={false} size="small" onClick={onResetFilter}> {t('versionHistory.filter.reset', { ns: 'workflow' })} </Button> </div> diff --git a/web/app/components/workflow/panel/version-history-panel/filter/index.tsx b/web/app/components/workflow/panel/version-history-panel/filter/index.tsx index bb94e3727a..e2277b3fb1 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/filter/index.tsx @@ -42,6 +42,7 @@ const Filter: FC<FilterProps> = ({ onOpenChange={setOpen} > <PopoverTrigger + nativeButton={false} render={( <div className={cn( diff --git a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx index 9fc2c25742..b001224c08 100644 --- a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx +++ b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx @@ -1,9 +1,16 @@ import type { FC } from 'react' import type { VersionHistory } from '@/types/workflow' -import { Button } from '@langgenius/dify-ui/button' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' type RestoreConfirmModalProps = { isOpen: boolean @@ -21,24 +28,36 @@ const RestoreConfirmModal: FC<RestoreConfirmModalProps> = ({ const { t } = useTranslation() return ( - <Modal className="p-0" isShow={isOpen} onClose={onClose}> - <div className="flex flex-col gap-y-2 p-6 pb-4"> - <div className="title-2xl-semi-bold text-text-primary"> - {`${t('common.restore', { ns: 'workflow' })} ${versionInfo.marked_name || t('versionHistory.defaultName', { ns: 'workflow' })}`} + <AlertDialog + open={isOpen} + onOpenChange={(open) => { + if (!open) + onClose() + }} + > + <AlertDialogContent className="overflow-hidden! border-none text-left align-middle shadow-xl"> + <div className="flex flex-col gap-y-2 p-6 pb-4"> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> + {`${t('common.restore', { ns: 'workflow' })} ${versionInfo.marked_name || t('versionHistory.defaultName', { ns: 'workflow' })}`} + </AlertDialogTitle> + <AlertDialogDescription className="system-md-regular text-text-secondary"> + {t('versionHistory.restorationTip', { ns: 'workflow' })} + </AlertDialogDescription> </div> - <p className="system-md-regular text-text-secondary"> - {t('versionHistory.restorationTip', { ns: 'workflow' })} - </p> - </div> - <div className="flex items-center justify-end gap-x-2 p-6"> - <Button onClick={onClose}> - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button variant="primary" onClick={onRestore.bind(null, versionInfo)}> - {t('common.restore', { ns: 'workflow' })} - </Button> - </div> - </Modal> + <AlertDialogActions> + <AlertDialogCancelButton + nativeButton={false} + variant="secondary" + closeProps={{ nativeButton: false }} + > + {t('operation.cancel', { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton nativeButton={false} tone="default" onClick={onRestore.bind(null, versionInfo)}> + {t('common.restore', { ns: 'workflow' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> ) } diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx index 549dee487f..c5cb9cc62d 100644 --- a/web/app/components/workflow/update-dsl-modal.tsx +++ b/web/app/components/workflow/update-dsl-modal.tsx @@ -2,6 +2,7 @@ import type { MouseEventHandler } from 'react' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiAlertFill, @@ -17,7 +18,6 @@ import { import { useTranslation } from 'react-i18next' import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' import { useStore as useAppStore } from '@/app/components/app/store' -import Modal from '@/app/components/base/modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { useEventEmitterContextContext } from '@/context/event-emitter' import { @@ -199,90 +199,100 @@ const UpdateDSLModal = ({ return ( <> - <Modal - className="w-[520px] rounded-2xl p-6" - isShow={show} - onClose={onCancel} + <Dialog + open={show} + onOpenChange={(open) => { + if (!open) + onCancel() + }} > - <div className="mb-3 flex items-center justify-between"> - <div className="title-2xl-semi-bold text-text-primary">{t('importApp', { ns: 'app' })}</div> - <div className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center" onClick={onCancel}> - <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> - </div> - </div> - <div className="relative mb-2 flex grow gap-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs"> - <div className="absolute top-0 left-0 h-full w-full bg-toast-warning-bg opacity-40" /> - <div className="flex items-start justify-center p-1"> - <RiAlertFill className="h-4 w-4 shrink-0 text-text-warning-secondary" /> - </div> - <div className="flex grow flex-col items-start gap-0.5 py-1"> - <div className="system-xs-medium whitespace-pre-line text-text-primary">{t('common.importDSLTip', { ns: 'workflow' })}</div> - <div className="flex items-start gap-1 self-stretch pt-1 pb-0.5"> - <Button - size="small" - variant="secondary" - className="z-1000" - onClick={onBackup} - > - <RiFileDownloadLine className="h-3.5 w-3.5 text-components-button-secondary-text" /> - <div className="flex items-center justify-center gap-1 px-[3px]"> - {t('common.backupCurrentDraft', { ns: 'workflow' })} - </div> - </Button> + <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-none p-6 text-left align-middle"> + + <div className="mb-3 flex items-center justify-between"> + <div className="title-2xl-semi-bold text-text-primary">{t('importApp', { ns: 'app' })}</div> + <div className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center" onClick={onCancel}> + <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> </div> </div> - </div> - <div> - <div className="pt-2 system-md-semibold text-text-primary"> - {t('common.chooseDSL', { ns: 'workflow' })} + <div className="relative mb-2 flex grow gap-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs"> + <div className="absolute top-0 left-0 h-full w-full bg-toast-warning-bg opacity-40" /> + <div className="flex items-start justify-center p-1"> + <RiAlertFill className="h-4 w-4 shrink-0 text-text-warning-secondary" /> + </div> + <div className="flex grow flex-col items-start gap-0.5 py-1"> + <div className="system-xs-medium whitespace-pre-line text-text-primary">{t('common.importDSLTip', { ns: 'workflow' })}</div> + <div className="flex items-start gap-1 self-stretch pt-1 pb-0.5"> + <Button + size="small" + variant="secondary" + className="z-1000" + onClick={onBackup} + > + <RiFileDownloadLine className="h-3.5 w-3.5 text-components-button-secondary-text" /> + <div className="flex items-center justify-center gap-1 px-[3px]"> + {t('common.backupCurrentDraft', { ns: 'workflow' })} + </div> + </Button> + </div> + </div> </div> - <div className="flex w-full flex-col items-start justify-center gap-4 self-stretch py-4"> - <Uploader - file={currentFile} - updateFile={handleFile} - className="mt-0! w-full" - /> + <div> + <div className="pt-2 system-md-semibold text-text-primary"> + {t('common.chooseDSL', { ns: 'workflow' })} + </div> + <div className="flex w-full flex-col items-start justify-center gap-4 self-stretch py-4"> + <Uploader + file={currentFile} + updateFile={handleFile} + className="mt-0! w-full" + /> + </div> </div> - </div> - <div className="flex items-center justify-end gap-2 self-stretch pt-5"> - <Button onClick={onCancel}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button - disabled={!currentFile || loading} - variant="primary" - tone="destructive" - onClick={handleImport} - loading={loading} - > - {t('common.overwriteAndImport', { ns: 'workflow' })} - </Button> - </div> - </Modal> - <Modal - isShow={showErrorModal} - onClose={() => setShowErrorModal(false)} - className="w-[480px]" + <div className="flex items-center justify-end gap-2 self-stretch pt-5"> + <Button onClick={onCancel}>{t('newApp.Cancel', { ns: 'app' })}</Button> + <Button + disabled={!currentFile || loading} + variant="primary" + tone="destructive" + onClick={handleImport} + loading={loading} + > + {t('common.overwriteAndImport', { ns: 'workflow' })} + </Button> + </div> + </DialogContent> + </Dialog> + <Dialog + open={showErrorModal} + onOpenChange={(open) => { + if (!open) + setShowErrorModal(false) + }} > - <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div> - <div className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> - <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> - <br /> - <div> - {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - <span className="system-md-medium">{versions?.importedVersion}</span> - </div> - <div> - {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - <span className="system-md-medium">{versions?.systemVersion}</span> + <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle"> + + <div className="flex flex-col items-start gap-2 self-stretch pb-4"> + <div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div> + <div className="flex grow flex-col system-md-regular text-text-secondary"> + <div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div> + <div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div> + <br /> + <div> + {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} + <span className="system-md-medium">{versions?.importedVersion}</span> + </div> + <div> + {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} + <span className="system-md-medium">{versions?.systemVersion}</span> + </div> </div> </div> - </div> - <div className="flex items-start justify-end gap-2 self-stretch pt-6"> - <Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button> - <Button variant="primary" tone="destructive" onClick={onUpdateDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button> - </div> - </Modal> + <div className="flex items-start justify-end gap-2 self-stretch pt-6"> + <Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button> + <Button variant="primary" tone="destructive" onClick={onUpdateDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button> + </div> + </DialogContent> + </Dialog> </> ) } diff --git a/web/app/education-apply/expire-notice-modal.tsx b/web/app/education-apply/expire-notice-modal.tsx index e54afaecde..5e07bf0fb1 100644 --- a/web/app/education-apply/expire-notice-modal.tsx +++ b/web/app/education-apply/expire-notice-modal.tsx @@ -1,9 +1,9 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { RiExternalLinkLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import { useDocLink } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' import useTimestamp from '@/hooks/use-timestamp' @@ -41,63 +41,70 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => { } return ( - <Modal - isShow - onClose={onClose} - title={expired ? t(`${i18nPrefix}.expired.title`, { ns: 'education' }) : t(`${i18nPrefix}.isAboutToExpire.title`, { ns: 'education', date: formatTime(expireAt, t(`${i18nPrefix}.dateFormat`, { ns: 'education' }) as string), interpolation: { escapeValue: false } })} - closable - className="max-w-[600px]" + <Dialog + open + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="mt-5 space-y-5 body-md-regular text-text-secondary"> - <div> - {expired - ? ( - <> - <div>{t(`${i18nPrefix}.expired.summary.line1`, { ns: 'education' })}</div> - <div>{t(`${i18nPrefix}.expired.summary.line2`, { ns: 'education' })}</div> - </> - ) - : t(`${i18nPrefix}.isAboutToExpire.summary`, { ns: 'education' })} + <DialogContent className="w-full max-w-[600px] overflow-hidden! border-none text-left align-middle"> + <DialogCloseButton data-testid="modal-close-button" /> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {expired ? t(`${i18nPrefix}.expired.title`, { ns: 'education' }) : t(`${i18nPrefix}.isAboutToExpire.title`, { ns: 'education', date: formatTime(expireAt, t(`${i18nPrefix}.dateFormat`, { ns: 'education' }) as string), interpolation: { escapeValue: false } })} + </DialogTitle> + + <div className="mt-5 space-y-5 body-md-regular text-text-secondary"> + <div> + {expired + ? ( + <> + <div>{t(`${i18nPrefix}.expired.summary.line1`, { ns: 'education' })}</div> + <div>{t(`${i18nPrefix}.expired.summary.line2`, { ns: 'education' })}</div> + </> + ) + : t(`${i18nPrefix}.isAboutToExpire.summary`, { ns: 'education' })} + </div> + <div> + <strong className="block title-md-semi-bold">{t(`${i18nPrefix}.stillInEducation.title`, { ns: 'education' })}</strong> + {t(`${i18nPrefix}.stillInEducation.${expired ? 'expired' : 'isAboutToExpire'}`, { ns: 'education' })} + </div> + <div> + <strong className="block title-md-semi-bold">{t(`${i18nPrefix}.alreadyGraduated.title`, { ns: 'education' })}</strong> + {t(`${i18nPrefix}.alreadyGraduated.${expired ? 'expired' : 'isAboutToExpire'}`, { ns: 'education' })} + </div> </div> - <div> - <strong className="block title-md-semi-bold">{t(`${i18nPrefix}.stillInEducation.title`, { ns: 'education' })}</strong> - {t(`${i18nPrefix}.stillInEducation.${expired ? 'expired' : 'isAboutToExpire'}`, { ns: 'education' })} + <div className="mt-7 flex items-center justify-between space-x-2"> + <Link className="flex items-center space-x-1 system-xs-regular text-text-accent" href={eduDocLink} target="_blank" rel="noopener noreferrer"> + <div>{t('learn', { ns: 'education' })}</div> + <RiExternalLinkLine className="size-3" /> + </Link> + <div className="flex space-x-2"> + {expired + ? ( + <Button + onClick={() => { + onClose() + setShowPricingModal() + }} + className="flex items-center space-x-1" + > + <SparklesSoftAccent className="size-4" /> + <div className="text-components-button-secondary-accent-text">{t(`${i18nPrefix}.action.upgrade`, { ns: 'education' })}</div> + </Button> + ) + : ( + <Button onClick={onClose}> + {t(`${i18nPrefix}.action.dismiss`, { ns: 'education' })} + </Button> + )} + <Button variant="primary" onClick={handleConfirm}> + {t(`${i18nPrefix}.action.reVerify`, { ns: 'education' })} + </Button> + </div> </div> - <div> - <strong className="block title-md-semi-bold">{t(`${i18nPrefix}.alreadyGraduated.title`, { ns: 'education' })}</strong> - {t(`${i18nPrefix}.alreadyGraduated.${expired ? 'expired' : 'isAboutToExpire'}`, { ns: 'education' })} - </div> - </div> - <div className="mt-7 flex items-center justify-between space-x-2"> - <Link className="flex items-center space-x-1 system-xs-regular text-text-accent" href={eduDocLink} target="_blank" rel="noopener noreferrer"> - <div>{t('learn', { ns: 'education' })}</div> - <RiExternalLinkLine className="size-3" /> - </Link> - <div className="flex space-x-2"> - {expired - ? ( - <Button - onClick={() => { - onClose() - setShowPricingModal() - }} - className="flex items-center space-x-1" - > - <SparklesSoftAccent className="size-4" /> - <div className="text-components-button-secondary-accent-text">{t(`${i18nPrefix}.action.upgrade`, { ns: 'education' })}</div> - </Button> - ) - : ( - <Button onClick={onClose}> - {t(`${i18nPrefix}.action.dismiss`, { ns: 'education' })} - </Button> - )} - <Button variant="primary" onClick={handleConfirm}> - {t(`${i18nPrefix}.action.reVerify`, { ns: 'education' })} - </Button> - </div> - </div> - </Modal> + </DialogContent> + </Dialog> ) } diff --git a/web/app/education-apply/verify-state-modal.tsx b/web/app/education-apply/verify-state-modal.tsx index 9103e94f36..ac10f2319e 100644 --- a/web/app/education-apply/verify-state-modal.tsx +++ b/web/app/education-apply/verify-state-modal.tsx @@ -1,10 +1,9 @@ import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { RiExternalLinkLine, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' @@ -34,61 +33,25 @@ function Confirm({ }: IConfirm) { const { t } = useTranslation() const docLink = useDocLink() - const dialogRef = useRef<HTMLDivElement>(null) - const [isVisible, setIsVisible] = useState(isShow) const eduDocLink = docLink('/use-dify/workspace/subscription-management#dify-for-education') const handleClick = () => { window.open(eduDocLink, '_blank', 'noopener,noreferrer') } - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') - onCancel() - } - - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('keydown', handleKeyDown) - } - }, [onCancel]) - - const handleClickOutside = useCallback((event: MouseEvent) => { - if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node)) - onCancel() - }, [maskClosable, onCancel]) - - useEffect(() => { - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [handleClickOutside]) - - useEffect(() => { - const timer = setTimeout(() => { - setIsVisible(isShow) - }, isShow ? 0 : 200) - - return () => clearTimeout(timer) - }, [isShow]) - - if (!isVisible) - return null - - return createPortal( - <div - className="fixed inset-0 z-10000000 flex items-center justify-center bg-background-overlay" - onClick={(e) => { - e.preventDefault() - e.stopPropagation() + return ( + <Dialog + open={isShow} + onOpenChange={(open) => { + if (!open) + onCancel() }} + disablePointerDismissal={!maskClosable} > - <div ref={dialogRef} className="relative w-full max-w-[481px] overflow-hidden"> + <DialogContent className="w-full max-w-[481px]! overflow-hidden! border-none bg-transparent p-0! shadow-none"> <div className="shadows-shadow-lg flex max-w-full flex-col items-start rounded-2xl border-[0.5px] border-solid border-components-panel-border bg-components-panel-bg"> <div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6"> - <div className="title-2xl-semi-bold text-text-primary">{title}</div> + <DialogTitle className="title-2xl-semi-bold text-text-primary">{title}</DialogTitle> <div className="w-full system-md-regular text-text-tertiary">{content}</div> </div> {email && ( @@ -109,9 +72,8 @@ function Confirm({ <Button variant="primary" className={confirmText ? 'min-w-20!' : 'w-20!'} onClick={onConfirm}>{confirmText || t('operation.ok', { ns: 'common' })}</Button> </div> </div> - </div> - </div>, - document.body, + </DialogContent> + </Dialog> ) } diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index eb85d5d902..bd8254b6fb 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -39,9 +39,19 @@ const BASE_UI_RESTRICTED_IMPORT_PATTERNS = [ }, ] +const FLOATING_UI_RESTRICTED_IMPORT_PATTERNS = [ + { + group: [ + '@floating-ui/*', + ], + message: 'Do not import Floating UI directly in web. Use @langgenius/dify-ui/* primitives instead.', + }, +] + export const WEB_RESTRICTED_IMPORT_PATTERNS = [ ...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS, ...BASE_UI_RESTRICTED_IMPORT_PATTERNS, + ...FLOATING_UI_RESTRICTED_IMPORT_PATTERNS, ] export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ diff --git a/web/models/pipeline.ts b/web/models/pipeline.ts index e75ee7569a..30389ab5e0 100644 --- a/web/models/pipeline.ts +++ b/web/models/pipeline.ts @@ -89,6 +89,7 @@ export type ImportPipelineDSLResponse = { dataset_id: string current_dsl_version: string imported_dsl_version: string + error?: string } export type ImportPipelineDSLConfirmResponse = { diff --git a/web/package.json b/web/package.json index 0dd9dfbde5..df1ceed01f 100644 --- a/web/package.json +++ b/web/package.json @@ -54,7 +54,6 @@ "@emoji-mart/data": "catalog:", "@floating-ui/react": "catalog:", "@formatjs/intl-localematcher": "catalog:", - "@headlessui/react": "catalog:", "@heroicons/react": "catalog:", "@lexical/code": "catalog:", "@lexical/link": "catalog:", diff --git a/web/service/__tests__/use-pipeline.spec.tsx b/web/service/__tests__/use-pipeline.spec.tsx new file mode 100644 index 0000000000..411d29ac6a --- /dev/null +++ b/web/service/__tests__/use-pipeline.spec.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DSLImportMode, DSLImportStatus } from '@/models/app' +import { post } from '../base' +import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '../use-pipeline' + +vi.mock('../base', () => ({ + post: vi.fn(), + get: vi.fn(), + patch: vi.fn(), + del: vi.fn(), +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) +} + +describe('use-pipeline imports', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(post).mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.COMPLETED, + pipeline_id: 'pipeline-id', + dataset_id: 'dataset-id', + current_dsl_version: '0.1.0', + imported_dsl_version: '0.1.0', + }) + }) + + it('should import pipeline DSL silently so callers can own error toasts', async () => { + const { result } = renderHook( + () => useImportPipelineDSL(), + { wrapper: createWrapper() }, + ) + const request = { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: 'rag_pipeline: {}', + } + + await act(async () => { + await result.current.mutateAsync(request) + }) + + expect(post).toHaveBeenCalledWith( + '/rag/pipelines/imports', + { body: request }, + { silent: true }, + ) + }) + + it('should confirm pipeline DSL import silently so callers can own error toasts', async () => { + const { result } = renderHook( + () => useImportPipelineDSLConfirm(), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.mutateAsync('import-id') + }) + + expect(post).toHaveBeenCalledWith( + '/rag/pipelines/imports/import-id/confirm', + {}, + { silent: true }, + ) + }) +}) diff --git a/web/service/use-pipeline.ts b/web/service/use-pipeline.ts index 6efb5bb8c6..7e1bd9711f 100644 --- a/web/service/use-pipeline.ts +++ b/web/service/use-pipeline.ts @@ -113,7 +113,7 @@ export const useImportPipelineDSL = ( return useMutation({ mutationKey: [NAME_SPACE, 'dsl-import'], mutationFn: (request: ImportPipelineDSLRequest) => { - return post<ImportPipelineDSLResponse>('/rag/pipelines/imports', { body: request }) + return post<ImportPipelineDSLResponse>('/rag/pipelines/imports', { body: request }, { silent: true }) }, ...mutationOptions, }) @@ -125,7 +125,7 @@ export const useImportPipelineDSLConfirm = ( return useMutation({ mutationKey: [NAME_SPACE, 'dsl-import-confirm'], mutationFn: (importId: string) => { - return post<ImportPipelineDSLConfirmResponse>(`/rag/pipelines/imports/${importId}/confirm`) + return post<ImportPipelineDSLConfirmResponse>(`/rag/pipelines/imports/${importId}/confirm`, {}, { silent: true }) }, ...mutationOptions, }) diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index 6211fcd2f4..99d264113e 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -1,46 +1,26 @@ import * as jestDomMatchers from '@testing-library/jest-dom/matchers' import { act, cleanup } from '@testing-library/react' import * as React from 'react' -import '@testing-library/jest-dom/vitest' +import { afterEach, beforeEach, expect, vi } from 'vitest' import 'vitest-canvas-mock' -expect.extend(jestDomMatchers) - -// Suppress act() warnings from @headlessui/react internal Transition component -// These warnings are caused by Headless UI's internal async state updates, not our code -const originalConsoleError = console.error -console.error = (...args: unknown[]) => { - // Check all arguments for the Headless UI TransitionRootFn act warning - const fullMessage = args.map(arg => (typeof arg === 'string' ? arg : '')).join(' ') - if (fullMessage.includes('TransitionRootFn') && fullMessage.includes('not wrapped in act')) - return - originalConsoleError.apply(console, args) +if (typeof expect.extend === 'function') { + expect.extend(jestDomMatchers) } -// Fix for @headlessui/react compatibility with happy-dom -// headlessui tries to override focus properties which may be read-only in happy-dom +( + globalThis as typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean + } +).BASE_UI_ANIMATIONS_DISABLED = true + +// Base UI waits for element animations while closing overlays. if (typeof window !== 'undefined') { - // Provide a minimal animations API polyfill before @headlessui/react boots if (typeof Element !== 'undefined' && !Element.prototype.getAnimations) Element.prototype.getAnimations = () => [] if (!document.getAnimations) document.getAnimations = () => [] - - const ensureWritable = (target: object, prop: string) => { - const descriptor = Object.getOwnPropertyDescriptor(target, prop) - if (descriptor && !descriptor.writable) { - const original = descriptor.value ?? descriptor.get?.call(target) - Object.defineProperty(target, prop, { - value: typeof original === 'function' ? original : vi.fn(), - writable: true, - configurable: true, - }) - } - } - - ensureWritable(window, 'focus') - ensureWritable(HTMLElement.prototype, 'focus') } if (typeof globalThis.ResizeObserver === 'undefined') { From 24ea21db25b8e254de8fef286bd33b245e487766 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 20:18:39 +0800 Subject: [PATCH 14/53] refactor(web): converge overlay layering on dify-ui z-50 (#35976) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 42 -- packages/dify-ui/AGENTS.md | 2 +- packages/dify-ui/README.md | 14 +- packages/dify-ui/src/alert-dialog/index.tsx | 4 +- .../src/autocomplete/__tests__/index.spec.tsx | 2 +- packages/dify-ui/src/autocomplete/index.tsx | 2 +- .../src/combobox/__tests__/index.spec.tsx | 2 +- packages/dify-ui/src/combobox/index.tsx | 2 +- packages/dify-ui/src/context-menu/index.tsx | 2 +- packages/dify-ui/src/dialog/index.tsx | 8 +- .../src/drawer/__tests__/index.spec.tsx | 2 +- packages/dify-ui/src/drawer/index.tsx | 6 +- packages/dify-ui/src/dropdown-menu/index.tsx | 2 +- packages/dify-ui/src/overlay-shared.ts | 2 +- packages/dify-ui/src/popover/index.tsx | 2 +- packages/dify-ui/src/preview-card/index.tsx | 2 +- packages/dify-ui/src/select/index.tsx | 2 +- .../src/toast/__tests__/index.spec.tsx | 2 +- packages/dify-ui/src/toast/index.tsx | 2 +- packages/dify-ui/src/tooltip/index.tsx | 2 +- web/AGENTS.md | 6 +- .../annotation/add-annotation-modal/index.tsx | 115 +++-- .../edit-annotation-modal/index.tsx | 192 ++++---- .../view-annotation-modal/index.tsx | 159 ++++--- .../inputs-form/__tests__/content.spec.tsx | 2 +- .../inputs-form/__tests__/content.spec.tsx | 2 +- .../base/drawer-plus/__tests__/index.spec.tsx | 446 ------------------ .../base/drawer-plus/index.stories.tsx | 124 ----- web/app/components/base/drawer-plus/index.tsx | 106 ----- .../pricing/plans/cloud-plan-item/index.tsx | 1 - .../__tests__/index.spec.tsx | 2 +- .../__tests__/config-credentials.spec.tsx | 21 +- .../config-credentials.tsx | 341 +++++++------ .../get-schema.tsx | 147 +++--- .../edit-custom-collection-modal/index.tsx | 412 ++++++++-------- .../edit-custom-collection-modal/test-api.tsx | 160 ++++--- .../__tests__/config-credentials.spec.tsx | 12 +- .../setting/build-in/config-credentials.tsx | 143 +++--- web/docs/lint.md | 4 +- web/docs/overlay-migration.md | 91 ---- web/docs/overlay.md | 44 ++ web/eslint.config.mjs | 2 +- web/eslint.constants.mjs | 16 - web/service/tools.ts | 2 +- 44 files changed, 1052 insertions(+), 1602 deletions(-) delete mode 100644 web/app/components/base/drawer-plus/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/drawer-plus/index.stories.tsx delete mode 100644 web/app/components/base/drawer-plus/index.tsx delete mode 100644 web/docs/overlay-migration.md create mode 100644 web/docs/overlay.md diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f7ff4f8d6d..e49483f63c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -192,11 +192,6 @@ "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 @@ -222,11 +217,6 @@ "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 @@ -249,9 +239,6 @@ "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 5 }, @@ -844,16 +831,6 @@ "count": 1 } }, - "web/app/components/base/drawer-plus/index.stories.tsx": { - "react/component-hook-factories": { - "count": 1 - } - }, - "web/app/components/base/drawer-plus/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/error-boundary/index.tsx": { "react-refresh/only-export-components": { "count": 3 @@ -2879,20 +2856,7 @@ "count": 2 } }, - "web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/tools/edit-custom-collection-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 4 }, @@ -2901,9 +2865,6 @@ } }, "web/app/components/tools/edit-custom-collection-modal/test-api.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -2944,9 +2905,6 @@ } }, "web/app/components/tools/setting/build-in/config-credentials.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 3 } diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index bdc2160702..9524394214 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -75,7 +75,7 @@ Composition rules: - Keep Base UI primitive semantics visible in the public API. Export compound parts such as `ComboboxInputGroup`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, and `ComboboxItemIndicator` instead of wrapping them into one business component. - For `Combobox` multiple selection, follow the official chips pattern: `ComboboxInputGroup` contains `ComboboxChips`, `ComboboxValue` renders `ComboboxChip` items, and `ComboboxInput` remains inside the chips row. Chips should wrap and let the input group grow vertically instead of forcing horizontal overflow. -- Content primitives must own their Base UI `Portal` and use `z-1002` on `Positioner`, matching the overlay contract in `README.md`. +- Content primitives must own their Base UI `Portal` and use `z-50` on `Positioner`, matching the overlay contract in `README.md`. Toast owns `z-60`. - Use `w-(--anchor-width)` with viewport-aware max-width for `Autocomplete` and `Combobox` popups. Do not add `min-w-(--anchor-width)` when it would defeat available-width clamping. [Autocomplete docs]: https://base-ui.com/react/components/autocomplete.md#usage-guidelines diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index c78faede89..010fb3e56d 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -84,18 +84,18 @@ 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, 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. | +| Layer | z-index | Where | +| ------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------- | +| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-50` | Positioner / Backdrop | +| Toast viewport | `z-60` | One layer above overlays so notifications are never hidden under a dialog. | -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. +Rationale: Dify UI owns the normal application overlay layer. Overlay primitives share `z-50` and **rely on DOM order** for stacking — the portal mounted later wins. Toast owns `z-60` so notifications remain visible above dialogs, popovers, and other portalled surfaces without falling back to `z-9999`. -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`. +See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay best practices. ### 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 add ad hoc `z-*` overrides on primitives from this package. If something is getting clipped, fix the parent overlay structure instead of raising the child primitive. - 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. diff --git a/packages/dify-ui/src/alert-dialog/index.tsx b/packages/dify-ui/src/alert-dialog/index.tsx index 7b432c87dc..81299ef932 100644 --- a/packages/dify-ui/src/alert-dialog/index.tsx +++ b/packages/dify-ui/src/alert-dialog/index.tsx @@ -29,14 +29,14 @@ export function AlertDialogContent({ <BaseAlertDialog.Backdrop {...backdropProps} className={cn( - 'fixed inset-0 z-1002 bg-background-overlay', + 'fixed inset-0 z-50 bg-background-overlay', 'transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none', backdropClassName, )} /> <BaseAlertDialog.Popup className={cn( - 'fixed top-1/2 left-1/2 z-1002 max-h-[calc(100vh-2rem)] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', + 'fixed top-1/2 left-1/2 z-50 max-h-[calc(100vh-2rem)] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', 'transition-[transform,scale,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none', className, )} diff --git a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx index a7031c5b12..5c56dc4c07 100644 --- a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx @@ -173,7 +173,7 @@ describe('Autocomplete wrappers', () => { await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-side', 'bottom') await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-align', 'start') - await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveClass('z-1002') + await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveClass('z-50') await expect.element(screen.getByRole('dialog', { name: 'autocomplete popup' })).toHaveClass('rounded-xl') await expect.element(screen.getByRole('dialog', { name: 'autocomplete popup' })).toHaveClass('w-(--anchor-width)') await expect.element(screen.getByRole('listbox', { name: 'autocomplete list' })).toHaveClass('scroll-py-1') diff --git a/packages/dify-ui/src/autocomplete/index.tsx b/packages/dify-ui/src/autocomplete/index.tsx index 16c4b19673..4c8893b376 100644 --- a/packages/dify-ui/src/autocomplete/index.tsx +++ b/packages/dify-ui/src/autocomplete/index.tsx @@ -261,7 +261,7 @@ export function AutocompleteContent({ align={align} sideOffset={sideOffset} alignOffset={alignOffset} - className={cn('z-1002 outline-hidden', className)} + className={cn('z-50 outline-hidden', className)} {...positionerProps} > <BaseAutocomplete.Popup diff --git a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx index 705ebe9601..75968c162f 100644 --- a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx @@ -231,7 +231,7 @@ describe('Combobox wrappers', () => { await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-side', 'bottom') await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-align', 'start') - await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveClass('z-1002') + await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveClass('z-50') await expect.element(screen.getByRole('dialog', { name: 'combobox popup' })).toHaveClass('rounded-xl') await expect.element(screen.getByRole('dialog', { name: 'combobox popup' })).toHaveClass('w-(--anchor-width)') await expect.element(screen.getByRole('listbox', { name: 'combobox list' })).toHaveClass('scroll-py-1') diff --git a/packages/dify-ui/src/combobox/index.tsx b/packages/dify-ui/src/combobox/index.tsx index c4f03241f6..eb43b911c7 100644 --- a/packages/dify-ui/src/combobox/index.tsx +++ b/packages/dify-ui/src/combobox/index.tsx @@ -323,7 +323,7 @@ export function ComboboxContent({ align={align} sideOffset={sideOffset} alignOffset={alignOffset} - className={cn('z-1002 outline-hidden', className)} + className={cn('z-50 outline-hidden', className)} {...positionerProps} > <BaseCombobox.Popup diff --git a/packages/dify-ui/src/context-menu/index.tsx b/packages/dify-ui/src/context-menu/index.tsx index 10b9f2c206..e33a94f03f 100644 --- a/packages/dify-ui/src/context-menu/index.tsx +++ b/packages/dify-ui/src/context-menu/index.tsx @@ -76,7 +76,7 @@ function renderContextMenuPopup({ align={align} sideOffset={sideOffset} alignOffset={alignOffset} - className={cn('z-1002 outline-hidden', className)} + className={cn('z-50 outline-hidden', className)} {...positionerProps} > <BaseContextMenu.Popup diff --git a/packages/dify-ui/src/dialog/index.tsx b/packages/dify-ui/src/dialog/index.tsx index dbb2448ff6..f29517717a 100644 --- a/packages/dify-ui/src/dialog/index.tsx +++ b/packages/dify-ui/src/dialog/index.tsx @@ -1,8 +1,8 @@ 'use client' // z-index strategy (relies on root `isolation: isolate` in layout.tsx): -// All @langgenius/dify-ui/* overlay primitives — z-1002 -// Toast stays one layer above overlays at z-1003. +// All @langgenius/dify-ui/* overlay primitives — z-50 +// Toast stays one layer above overlays at z-60. // Overlays share the same z-index; DOM order handles stacking when multiple are open. // This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render // above the dialog backdrop instead of being clipped by it. @@ -56,14 +56,14 @@ export function DialogContent({ <BaseDialog.Backdrop {...backdropProps} className={cn( - 'fixed inset-0 z-1002 bg-background-overlay', + 'fixed inset-0 z-50 bg-background-overlay', 'transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none', backdropClassName, )} /> <BaseDialog.Popup className={cn( - 'fixed top-1/2 left-1/2 z-1002 max-h-[80dvh] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl', + 'fixed top-1/2 left-1/2 z-50 max-h-[80dvh] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl', 'transition-[transform,scale,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none', className, )} diff --git a/packages/dify-ui/src/drawer/__tests__/index.spec.tsx b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx index 8c3a93f02c..a0ca1cdf99 100644 --- a/packages/dify-ui/src/drawer/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx @@ -49,7 +49,7 @@ describe('Drawer wrapper', () => { 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') + await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-50') asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click() diff --git a/packages/dify-ui/src/drawer/index.tsx b/packages/dify-ui/src/drawer/index.tsx index c63bc8174e..a2ad6dcdaf 100644 --- a/packages/dify-ui/src/drawer/index.tsx +++ b/packages/dify-ui/src/drawer/index.tsx @@ -32,7 +32,7 @@ export function DrawerBackdrop({ return ( <BaseDrawer.Backdrop className={cn( - 'fixed inset-0 z-1002 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]', + 'fixed inset-0 z-50 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]', 'transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 motion-reduce:transition-none', className, )} @@ -47,7 +47,7 @@ export function DrawerViewport({ }: BaseDrawer.Viewport.Props) { return ( <BaseDrawer.Viewport - className={cn('fixed inset-0 z-1002 touch-none overflow-hidden overscroll-contain outline-hidden', className)} + className={cn('fixed inset-0 z-50 touch-none overflow-hidden overscroll-contain outline-hidden', className)} {...props} /> ) @@ -60,7 +60,7 @@ export function DrawerPopup({ return ( <BaseDrawer.Popup className={cn( - 'fixed z-1002 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none', + 'fixed z-50 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none', 'transition-[transform,opacity,box-shadow] duration-200 data-swiping:select-none data-swiping:duration-0 motion-reduce:transition-none', 'data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:h-dvh data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-2rem)] data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-r-0 data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]', 'data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))]', diff --git a/packages/dify-ui/src/dropdown-menu/index.tsx b/packages/dify-ui/src/dropdown-menu/index.tsx index f742625964..509d4c9d35 100644 --- a/packages/dify-ui/src/dropdown-menu/index.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.tsx @@ -134,7 +134,7 @@ function renderDropdownMenuPopup({ align={align} sideOffset={sideOffset} alignOffset={alignOffset} - className={cn('z-1002 outline-hidden', className)} + className={cn('z-50 outline-hidden', className)} {...positionerProps} > <Menu.Popup diff --git a/packages/dify-ui/src/overlay-shared.ts b/packages/dify-ui/src/overlay-shared.ts index cdff8253a1..d6d3a05b9d 100644 --- a/packages/dify-ui/src/overlay-shared.ts +++ b/packages/dify-ui/src/overlay-shared.ts @@ -7,4 +7,4 @@ export const overlayLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system export const overlaySeparatorClassName = 'my-1 h-px bg-divider-subtle' export const overlayPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]' export const overlayPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' -export const overlayBackdropClassName = 'fixed inset-0 z-1002 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' +export const overlayBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' diff --git a/packages/dify-ui/src/popover/index.tsx b/packages/dify-ui/src/popover/index.tsx index 3fc9f98f9a..c29c50d1fe 100644 --- a/packages/dify-ui/src/popover/index.tsx +++ b/packages/dify-ui/src/popover/index.tsx @@ -51,7 +51,7 @@ export function PopoverContent({ align={align} sideOffset={sideOffset} alignOffset={alignOffset} - className={cn('z-1002 outline-hidden', className)} + className={cn('z-50 outline-hidden', className)} {...positionerProps} > <BasePopover.Popup diff --git a/packages/dify-ui/src/preview-card/index.tsx b/packages/dify-ui/src/preview-card/index.tsx index 771b15cf13..f4448e477a 100644 --- a/packages/dify-ui/src/preview-card/index.tsx +++ b/packages/dify-ui/src/preview-card/index.tsx @@ -62,7 +62,7 @@ export function PreviewCardContent({ align={align} sideOffset={sideOffset} alignOffset={alignOffset} - className={cn('z-1002 outline-hidden', className)} + className={cn('z-50 outline-hidden', className)} {...positionerProps} > <BasePreviewCard.Popup diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index 2f2f91d9c6..676fcae6b4 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -135,7 +135,7 @@ export function SelectContent({ sideOffset={sideOffset} alignOffset={alignOffset} alignItemWithTrigger={false} - className={cn('z-1002 outline-hidden', className)} + className={cn('z-50 outline-hidden', className)} {...positionerProps} > <BaseSelect.Popup diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx index e02f6828ac..68ba746f4f 100644 --- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx @@ -39,7 +39,7 @@ describe('@langgenius/dify-ui/toast', () => { await expect.element(screen.getByText('Saved')).toBeInTheDocument() await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument() await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite') - await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-1003') + await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60') expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4') expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden') expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument() diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx index a479621563..7d4e867faf 100644 --- a/packages/dify-ui/src/toast/index.tsx +++ b/packages/dify-ui/src/toast/index.tsx @@ -222,7 +222,7 @@ function ToastViewport() { <BaseToast.Viewport aria-label={toastViewportLabel} className={cn( - 'group/toast-viewport pointer-events-none fixed inset-0 z-1003 overflow-visible', + 'group/toast-viewport pointer-events-none fixed inset-0 z-60 overflow-visible', )} > <div diff --git a/packages/dify-ui/src/tooltip/index.tsx b/packages/dify-ui/src/tooltip/index.tsx index 1f9772ce2d..88bd6459f7 100644 --- a/packages/dify-ui/src/tooltip/index.tsx +++ b/packages/dify-ui/src/tooltip/index.tsx @@ -58,7 +58,7 @@ export function TooltipContent({ align={align} sideOffset={sideOffset} alignOffset={alignOffset} - className={cn('z-1002 outline-hidden', positionerClassName)} + className={cn('z-50 outline-hidden', positionerClassName)} > <BaseTooltip.Popup className={cn( diff --git a/web/AGENTS.md b/web/AGENTS.md index dc72a293d1..2f7e0f6cda 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -4,10 +4,10 @@ ## Overlay Components (Mandatory) -- `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-1002` / `z-1003` layering. -- `./docs/overlay-migration.md` is the source of truth for the ongoing migration (deprecated import paths and coexistence rules). +- `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-50` / `z-60` layering. +- `./docs/overlay.md` records the current web overlay best practices. - In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`. -- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them. +- Do not introduce overlay imports from `@/app/components/base/*`; when touching existing callers, migrate them. ## Query & Mutation (Mandatory) diff --git a/web/app/components/app/annotation/add-annotation-modal/index.tsx b/web/app/components/app/annotation/add-annotation-modal/index.tsx index ba987a8a8a..53f3cd5bdd 100644 --- a/web/app/components/app/annotation/add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/index.tsx @@ -2,12 +2,21 @@ import type { FC } from 'react' import type { AnnotationItemBasic } from '../type' import { Button } from '@langgenius/dify-ui/button' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' -import Drawer from '@/app/components/base/drawer-plus' import AnnotationFull from '@/app/components/billing/annotation-full' import { useProviderContext } from '@/context/provider-context' import EditItem, { EditItemType } from './edit-item' @@ -67,52 +76,72 @@ const AddAnnotationModal: FC<Props> = ({ onHide() } } + if (!isShow) + return null + return ( <div> <Drawer - isShow={isShow} - onHide={onHide} - maxWidthClassName="max-w-[480px]!" - title={t('addModal.title', { ns: 'appAnnotation' }) as string} - body={( - <div className="space-y-6 p-6 pb-4"> - <EditItem - type={EditItemType.Query} - content={question} - onChange={setQuestion} - /> - <EditItem - type={EditItemType.Answer} - content={answer} - onChange={setAnswer} - /> - </div> - )} - foot={ - ( - <div> - {isAnnotationFull && ( - <div className="mt-6 mb-4 px-6"> - <AnnotationFull /> - </div> - )} - <div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> - <div - className="flex items-center space-x-2" - > - <Checkbox id="create-next-checkbox" checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} /> - <div>{t('addModal.createNext', { ns: 'appAnnotation' })}</div> - </div> - <div className="mt-2 flex space-x-2"> - <Button className="h-7 text-xs" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button className="h-7 text-xs" variant="primary" onClick={handleSave} loading={isSaving} disabled={isAnnotationFull}>{t('operation.add', { ns: 'common' })}</Button> - </div> - </div> - </div> - - ) - } + open + modal + disablePointerDismissal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} > + <DrawerPortal> + <DrawerBackdrop /> + <DrawerViewport> + <DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="shrink-0 border-b border-divider-subtle py-4"> + <div className="flex h-6 items-center justify-between pr-5 pl-6"> + <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> + {t('addModal.title', { ns: 'appAnnotation' })} + </DrawerTitle> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + /> + </div> + </div> + <div className="min-h-0 flex-1 overflow-y-auto"> + <div className="space-y-6 p-6 pb-4"> + <EditItem + type={EditItemType.Query} + content={question} + onChange={setQuestion} + /> + <EditItem + type={EditItemType.Answer} + content={answer} + onChange={setAnswer} + /> + </div> + </div> + <div className="shrink-0"> + {isAnnotationFull && ( + <div className="mt-6 mb-4 px-6"> + <AnnotationFull /> + </div> + )} + <div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> + <div className="flex items-center space-x-2"> + <Checkbox id="create-next-checkbox" checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} /> + <div>{t('addModal.createNext', { ns: 'appAnnotation' })}</div> + </div> + <div className="mt-2 flex space-x-2"> + <Button className="h-7 text-xs" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button className="h-7 text-xs" variant="primary" onClick={handleSave} loading={isSaving} disabled={isAnnotationFull}>{t('operation.add', { ns: 'common' })}</Button> + </div> + </div> + </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> </div> ) diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.tsx index 8e690eca9b..0f6ee0dd1b 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/index.tsx @@ -8,11 +8,20 @@ import { AlertDialogContent, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Drawer from '@/app/components/base/drawer-plus' import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication' import AnnotationFull from '@/app/components/billing/annotation-full' import { useProviderContext } from '@/context/provider-context' @@ -90,92 +99,115 @@ const EditAnnotationModal: FC<Props> = ({ } } const [showModal, setShowModal] = useState(false) + if (!isShow) + return null return ( <div> <Drawer - isShow={isShow} - onHide={onHide} - maxWidthClassName="max-w-[480px]!" - title={t('editModal.title', { ns: 'appAnnotation' }) as string} - body={( - <div> - <div className="space-y-6 p-6 pb-4"> - <EditItem - type={EditItemType.Query} - content={query} - readonly={(isAdd && isAnnotationFull) || onlyEditResponse} - onSave={editedContent => handleSave(EditItemType.Query, editedContent)} - /> - <EditItem - type={EditItemType.Answer} - content={answer} - readonly={isAdd && isAnnotationFull} - onSave={editedContent => handleSave(EditItemType.Answer, editedContent)} - /> - <AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}> - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle - title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })} - className="w-full truncate title-2xl-semi-bold text-text-primary" - > - {t('feature.annotation.removeConfirm', { ns: 'appDebug' })} - </AlertDialogTitle> + open + modal + disablePointerDismissal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} + > + <DrawerPortal> + <DrawerBackdrop /> + <DrawerViewport> + <DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="shrink-0 border-b border-divider-subtle py-4"> + <div className="flex h-6 items-center justify-between pr-5 pl-6"> + <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> + {t('editModal.title', { ns: 'appAnnotation' })} + </DrawerTitle> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + /> </div> - <AlertDialogActions> - <AlertDialogCancelButton> - {t('operation.cancel', { ns: 'common' })} - </AlertDialogCancelButton> - <AlertDialogConfirmButton - tone="destructive" - onClick={() => { - onRemove() - setShowModal(false) - onHide() - }} - > - {t('operation.confirm', { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> - </div> - </div> - )} - foot={( - <div> - {isAnnotationFull && ( - <div className="mt-6 mb-4 px-6"> - <AnnotationFull /> - </div> - )} - - { - annotationId - ? ( - <div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> - <div - className="flex cursor-pointer items-center space-x-2 pl-3" - onClick={() => setShowModal(true)} - > - <MessageCheckRemove /> - <div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div> - </div> - {!!createdAt && ( - <div> - {t('editModal.createdAt', { ns: 'appAnnotation' })} -  - {formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)} + </div> + <div className="min-h-0 flex-1 overflow-y-auto"> + <div className="space-y-6 p-6 pb-4"> + <EditItem + type={EditItemType.Query} + content={query} + readonly={(isAdd && isAnnotationFull) || onlyEditResponse} + onSave={editedContent => handleSave(EditItemType.Query, editedContent)} + /> + <EditItem + type={EditItemType.Answer} + content={answer} + readonly={isAdd && isAnnotationFull} + onSave={editedContent => handleSave(EditItemType.Answer, editedContent)} + /> + <AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}> + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle + title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })} + className="w-full truncate title-2xl-semi-bold text-text-primary" + > + {t('feature.annotation.removeConfirm', { ns: 'appDebug' })} + </AlertDialogTitle> </div> - )} + <AlertDialogActions> + <AlertDialogCancelButton> + {t('operation.cancel', { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton + tone="destructive" + onClick={() => { + onRemove() + setShowModal(false) + onHide() + }} + > + {t('operation.confirm', { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> + </div> + </div> + <div className="shrink-0"> + {isAnnotationFull && ( + <div className="mt-6 mb-4 px-6"> + <AnnotationFull /> </div> - ) - : undefined - } - </div> - )} - /> + )} + + { + annotationId + ? ( + <div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> + <div + className="flex cursor-pointer items-center space-x-2 pl-3" + onClick={() => setShowModal(true)} + > + <MessageCheckRemove /> + <div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div> + </div> + {!!createdAt && ( + <div> + {t('editModal.createdAt', { ns: 'appAnnotation' })} +  + {formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)} + </div> + )} + </div> + ) + : undefined + } + </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> + </Drawer> </div> ) diff --git a/web/app/components/app/annotation/view-annotation-modal/index.tsx b/web/app/components/app/annotation/view-annotation-modal/index.tsx index c9f7e8a78f..712fb29c2e 100644 --- a/web/app/components/app/annotation/view-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/view-annotation-modal/index.tsx @@ -10,11 +10,20 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import Drawer from '@/app/components/base/drawer-plus' import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication' import Pagination from '@/app/components/base/pagination' import TabSlider from '@/app/components/base/tab-slider-plain' @@ -198,75 +207,97 @@ const ViewAnnotationModal: FC<Props> = ({ </div> ) + if (!isShow) + return null + return ( <div> <Drawer - isShow={isShow} - onHide={onHide} - maxWidthClassName="max-w-[800px]!" - title={( - <TabSlider - className="relative top-[9px] shrink-0" - value={activeTab} - onChange={v => setActiveTab(v as TabType)} - options={tabs} - noBorderBottom - itemClassName="pb-3.5!" - /> - )} - body={( - <div> - <div className="space-y-6 p-6 pb-4"> - {activeTab === TabType.annotation ? annotationTab : hitHistoryTab} - </div> - <AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}> - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle - title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })} - className="w-full truncate title-2xl-semi-bold text-text-primary" - > - {t('feature.annotation.removeConfirm', { ns: 'appDebug' })} - </AlertDialogTitle> + open + modal + disablePointerDismissal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} + > + <DrawerPortal> + <DrawerBackdrop /> + <DrawerViewport> + <DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-200 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="shrink-0 border-b border-divider-subtle py-4"> + <div className="flex h-6 items-center justify-between pr-5 pl-6"> + <DrawerTitle render={<div />} className="min-w-0"> + <TabSlider + className="relative top-[9px] shrink-0" + value={activeTab} + onChange={v => setActiveTab(v as TabType)} + options={tabs} + noBorderBottom + itemClassName="pb-3.5!" + /> + </DrawerTitle> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + /> + </div> </div> - <AlertDialogActions> - <AlertDialogCancelButton> - {t('operation.cancel', { ns: 'common' })} - </AlertDialogCancelButton> - <AlertDialogConfirmButton - tone="destructive" - onClick={async () => { - await onRemove() - setShowModal(false) - onHide() - }} - > - {t('operation.confirm', { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> - </div> - )} - foot={id - ? ( - <div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> - <div - className="flex cursor-pointer items-center space-x-2 pl-3" - onClick={() => setShowModal(true)} - > - <MessageCheckRemove /> - <div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div> + <div className="min-h-0 flex-1 overflow-y-auto"> + <div className="space-y-6 p-6 pb-4"> + {activeTab === TabType.annotation ? annotationTab : hitHistoryTab} + </div> + <AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}> + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle + title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })} + className="w-full truncate title-2xl-semi-bold text-text-primary" + > + {t('feature.annotation.removeConfirm', { ns: 'appDebug' })} + </AlertDialogTitle> + </div> + <AlertDialogActions> + <AlertDialogCancelButton> + {t('operation.cancel', { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton + tone="destructive" + onClick={async () => { + await onRemove() + setShowModal(false) + onHide() + }} + > + {t('operation.confirm', { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> </div> - <div> - {t('editModal.createdAt', { ns: 'appAnnotation' })} + {id && ( + <div className="flex h-16 shrink-0 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> + <div + className="flex cursor-pointer items-center space-x-2 pl-3" + onClick={() => setShowModal(true)} + > + <MessageCheckRemove /> + <div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div> + </div> + <div> + {t('editModal.createdAt', { ns: 'appAnnotation' })}   - {formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)} - </div> - </div> - ) - : undefined} - /> + {formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)} + </div> + </div> + )} + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> + </Drawer> </div> ) diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx index 8d3c7002d7..a53bdc3b93 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx @@ -258,7 +258,7 @@ describe('InputsFormContent', () => { renderWithContext(<InputsFormContent />, context) await user.click(screen.getByText('B')) - expect(screen.getByText('A').closest('.z-1002')).not.toBeNull() + expect(screen.getByText('A').closest('.z-50')).not.toBeNull() }) it('handles select input with existing value (value not in options -> shows placeholder)', () => { diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx index 47c273d163..d552af8cec 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx @@ -208,7 +208,7 @@ describe('InputsFormContent', () => { await user.click(selectTrigger) - expect(screen.getByText('Option 1').closest('.z-1002')).not.toBeNull() + expect(screen.getByText('Option 1').closest('.z-50')).not.toBeNull() }) it('should handle single file upload change', async () => { diff --git a/web/app/components/base/drawer-plus/__tests__/index.spec.tsx b/web/app/components/base/drawer-plus/__tests__/index.spec.tsx deleted file mode 100644 index b3a7d2cd2b..0000000000 --- a/web/app/components/base/drawer-plus/__tests__/index.spec.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import DrawerPlus from '..' - -vi.mock('@/hooks/use-breakpoints', () => ({ - default: () => 'desktop', - MediaType: { mobile: 'mobile', desktop: 'desktop', tablet: 'tablet' }, -})) - -describe('DrawerPlus', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should not render when isShow is false', () => { - render( - <DrawerPlus - isShow={false} - onHide={() => {}} - title="Test Drawer" - body={<div>Content</div>} - />, - ) - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }) - - it('should render when isShow is true', () => { - const bodyContent = <div>Body Content</div> - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test Drawer" - body={bodyContent} - />, - ) - - expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByText('Test Drawer')).toBeInTheDocument() - expect(screen.getByText('Body Content')).toBeInTheDocument() - }) - - it('should render footer when provided', () => { - const footerContent = <div>Footer Content</div> - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test Drawer" - body={<div>Body</div>} - foot={footerContent} - />, - ) - - expect(screen.getByText('Footer Content')).toBeInTheDocument() - }) - - it('should render JSX element as title', () => { - const titleElement = <h1 data-testid="custom-title">Custom Title</h1> - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title={titleElement} - body={<div>Body</div>} - />, - ) - - expect(screen.getByTestId('custom-title')).toBeInTheDocument() - }) - - it('should render titleDescription when provided', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test Drawer" - titleDescription="Description text" - body={<div>Body</div>} - />, - ) - - expect(screen.getByText('Description text')).toBeInTheDocument() - }) - - it('should not render titleDescription when not provided', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test Drawer" - body={<div>Body</div>} - />, - ) - - expect(screen.queryByText(/Description/)).not.toBeInTheDocument() - }) - - it('should render JSX element as titleDescription', () => { - const descElement = <span data-testid="custom-desc">Custom Description</span> - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - titleDescription={descElement} - body={<div>Body</div>} - />, - ) - - expect(screen.getByTestId('custom-desc')).toBeInTheDocument() - }) - }) - - describe('Props - Display Options', () => { - it('should apply default maxWidthClassName', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - />, - ) - const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg') - const outerPanel = innerPanel?.parentElement - expect(outerPanel?.className).toContain('max-w-[640px]!') - }) - - it('should apply custom maxWidthClassName', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - maxWidthClassName="max-w-[800px]!" - />, - ) - - const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg') - const outerPanel = innerPanel?.parentElement - expect(outerPanel?.className).toContain('max-w-[800px]!') - }) - - it('should apply custom panelClassName', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - panelClassName="custom-panel" - />, - ) - - const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg') - const outerPanel = innerPanel?.parentElement - expect(outerPanel?.className).toContain('custom-panel') - }) - - it('should apply custom dialogClassName', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - dialogClassName="custom-dialog" - />, - ) - - expect(document.querySelector('.custom-dialog')).toBeInTheDocument() - }) - - it('should apply custom contentClassName', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - contentClassName="custom-content" - />, - ) - const title = screen.getByText('Test') - const header = title.closest('.shrink-0.border-b.border-divider-subtle') - const content = header?.parentElement - expect(content?.className).toContain('custom-content') - }) - - it('should apply custom headerClassName', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - headerClassName="custom-header" - />, - ) - - const title = screen.getByText('Test') - const header = title.closest('.shrink-0.border-b.border-divider-subtle') - expect(header?.className).toContain('custom-header') - }) - - it('should apply custom height', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - height="500px" - />, - ) - - const title = screen.getByText('Test') - const header = title.closest('.shrink-0.border-b.border-divider-subtle') - const content = header?.parentElement - expect(content?.getAttribute('style')).toContain('height: 500px') - }) - - it('should use default height', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - />, - ) - - const title = screen.getByText('Test') - const header = title.closest('.shrink-0.border-b.border-divider-subtle') - const content = header?.parentElement - expect(content?.getAttribute('style')).toContain('calc(100vh - 72px)') - }) - }) - - describe('Event Handlers', () => { - it('should call onHide when close button is clicked', () => { - const handleHide = vi.fn() - render( - <DrawerPlus - isShow={true} - onHide={handleHide} - title="Test" - body={<div>Body</div>} - />, - ) - - const title = screen.getByText('Test') - const headerRight = title.nextElementSibling // .flex items-center - const closeDiv = headerRight?.querySelector('.cursor-pointer') as HTMLElement - - fireEvent.click(closeDiv) - expect(handleHide).toHaveBeenCalledTimes(1) - }) - }) - - describe('Complex Content', () => { - it('should render complex JSX elements in body', () => { - const complexBody = ( - <div> - <h2>Header</h2> - <p>Paragraph</p> - <button>Action Button</button> - </div> - ) - - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={complexBody} - />, - ) - - expect(screen.getByText('Header')).toBeInTheDocument() - expect(screen.getByText('Paragraph')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Action Button' })).toBeInTheDocument() - }) - - it('should render complex footer', () => { - const complexFooter = ( - <div className="footer-actions"> - <button>Cancel</button> - <button>Save</button> - </div> - ) - - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - foot={complexFooter} - />, - ) - - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty title', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="" - body={<div>Body</div>} - />, - ) - - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should handle undefined titleDescription', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - titleDescription={undefined} - body={<div>Body</div>} - />, - ) - - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should handle rapid isShow toggle', () => { - const { rerender } = render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - />, - ) - - expect(screen.getByRole('dialog')).toBeInTheDocument() - - rerender( - <DrawerPlus - isShow={false} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - />, - ) - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - - rerender( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - />, - ) - - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should handle special characters in title', () => { - const specialTitle = 'Test <> & " \' | Drawer' - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title={specialTitle} - body={<div>Body</div>} - />, - ) - - expect(screen.getByText(specialTitle)).toBeInTheDocument() - }) - - it('should handle empty body content', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div></div>} - />, - ) - - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should apply both custom maxWidth and panel classNames', () => { - render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - maxWidthClassName="max-w-[500px]!" - panelClassName="custom-style" - />, - ) - - const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg') - const outerPanel = innerPanel?.parentElement - expect(outerPanel?.className).toContain('max-w-[500px]!') - expect(outerPanel?.className).toContain('custom-style') - }) - }) - - describe('Memoization', () => { - it('should be memoized and not re-render on parent changes', () => { - const { rerender } = render( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - />, - ) - - const dialog = screen.getByRole('dialog') - - rerender( - <DrawerPlus - isShow={true} - onHide={() => {}} - title="Test" - body={<div>Body</div>} - />, - ) - - expect(dialog).toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/base/drawer-plus/index.stories.tsx b/web/app/components/base/drawer-plus/index.stories.tsx deleted file mode 100644 index 4bdfef2ab3..0000000000 --- a/web/app/components/base/drawer-plus/index.stories.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useState } from 'react' -import { fn } from 'storybook/test' -import DrawerPlus from '.' - -const meta = { - title: 'Base/Feedback/DrawerPlus', - component: DrawerPlus, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: 'Enhanced drawer built atop the base drawer component. Provides header/foot slots, mask control, and mobile breakpoints.', - }, - }, - }, - tags: ['autodocs'], -} satisfies Meta<typeof DrawerPlus> - -export default meta -type Story = StoryObj<typeof meta> - -type DrawerPlusProps = React.ComponentProps<typeof DrawerPlus> - -const storyBodyElement: React.JSX.Element = ( - <div className="space-y-3 p-6 text-sm text-text-secondary"> - <p> - DrawerPlus allows rich content with sticky header/footer and responsive masking on mobile. Great for editing flows or showing execution logs. - </p> - <div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs"> - Body content scrolls if it exceeds the allotted height. - </div> - </div> -) - -const DrawerPlusDemo = (props: Partial<DrawerPlusProps>) => { - const [open, setOpen] = useState(false) - - const { - body, - title, - foot, - isShow: _isShow, - onHide: _onHide, - ...rest - } = props - - const resolvedBody: React.JSX.Element = body ?? storyBodyElement - - return ( - <div className="flex h-[400px] items-center justify-center bg-background-default-subtle"> - <button - type="button" - className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700" - onClick={() => setOpen(true)} - > - Open drawer plus - </button> - - <DrawerPlus - {...rest as Omit<DrawerPlusProps, 'isShow' | 'onHide' | 'title' | 'body' | 'foot'>} - isShow={open} - onHide={() => setOpen(false)} - title={title ?? 'Workflow execution details'} - body={resolvedBody} - foot={foot} - /> - </div> - ) -} - -export const Playground: Story = { - render: args => <DrawerPlusDemo {...args} />, - args: { - isShow: false, - onHide: fn(), - title: 'Edit configuration', - body: storyBodyElement, - }, -} - -export const WithFooter: Story = { - render: (args) => { - const FooterDemo = () => { - const [open, setOpen] = useState(false) - return ( - <div className="flex h-[400px] items-center justify-center bg-background-default-subtle"> - <button - type="button" - className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700" - onClick={() => setOpen(true)} - > - Open drawer plus - </button> - - <DrawerPlus - {...args} - isShow={open} - onHide={() => setOpen(false)} - title={args.title ?? 'Workflow execution details'} - body={args.body ?? ( - <div className="space-y-3 p-6 text-sm text-text-secondary"> - <p>Populate the body with scrollable content. Footer stays pinned.</p> - </div> - )} - foot={( - <div className="flex justify-end gap-2 border-t border-divider-subtle bg-components-panel-bg p-4"> - <button className="rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary" onClick={() => setOpen(false)}>Cancel</button> - <button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white">Save</button> - </div> - )} - /> - </div> - ) - } - return <FooterDemo /> - }, - args: { - isShow: false, - onHide: fn(), - title: 'Edit configuration!', - body: storyBodyElement, - }, -} diff --git a/web/app/components/base/drawer-plus/index.tsx b/web/app/components/base/drawer-plus/index.tsx deleted file mode 100644 index 261022669b..0000000000 --- a/web/app/components/base/drawer-plus/index.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client' -import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' -import { RiCloseLine } from '@remixicon/react' -import * as React from 'react' -import { useRef } from 'react' -import Drawer from '@/app/components/base/drawer' -import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' - -type Props = { - isShow: boolean - onHide: () => void - dialogClassName?: string - dialogBackdropClassName?: string - panelClassName?: string - maxWidthClassName?: string - contentClassName?: string - headerClassName?: string - height?: number | string - title: string | React.JSX.Element - titleDescription?: string | React.JSX.Element - body: React.JSX.Element - foot?: React.JSX.Element - isShowMask?: boolean - clickOutsideNotOpen?: boolean - positionCenter?: boolean -} - -const DrawerPlus: FC<Props> = ({ - isShow, - onHide, - dialogClassName = '', - dialogBackdropClassName = '', - panelClassName = '', - maxWidthClassName = 'max-w-[640px]!', - height = 'calc(100vh - 72px)', - contentClassName, - headerClassName, - title, - titleDescription, - body, - foot, - isShowMask, - clickOutsideNotOpen = true, - positionCenter, -}) => { - const ref = useRef(null) - const media = useBreakpoints() - const isMobile = media === MediaType.mobile - - if (!isShow) - return null - - return ( - // clickOutsideNotOpen to fix confirm modal click cause drawer close - <Drawer - isOpen={isShow} - clickOutsideNotOpen={clickOutsideNotOpen} - onClose={onHide} - footer={null} - mask={isMobile || isShowMask} - positionCenter={positionCenter} - dialogClassName={dialogClassName} - dialogBackdropClassName={dialogBackdropClassName} - panelClassName={cn('mx-2 mt-16 mb-3 rounded-xl p-0! sm:mr-2', panelClassName, maxWidthClassName)} - > - <div - className={cn(contentClassName, 'flex w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl')} - style={{ - height, - }} - ref={ref} - > - <div className={cn(headerClassName, 'shrink-0 border-b border-divider-subtle py-4')}> - <div className="flex h-6 items-center justify-between pr-5 pl-6"> - <div className="system-xl-semibold text-text-primary"> - {title} - </div> - <div className="flex items-center"> - <div - onClick={onHide} - className="flex h-6 w-6 cursor-pointer items-center justify-center" - > - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> - </div> - </div> - {titleDescription && ( - <div className="pr-10 pl-6 system-xs-regular text-text-tertiary"> - {titleDescription} - </div> - )} - </div> - <div className="grow overflow-y-auto"> - {body} - </div> - {foot && ( - <div className="shrink-0"> - {foot} - </div> - )} - </div> - </Drawer> - ) -} -export default React.memo(DrawerPlus) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index d3dc47b29f..e6546a469c 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -201,7 +201,6 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ open={showEducationPricingConfirm} onOpenChange={setShowEducationPricingConfirm} > - {showEducationPricingConfirm && <div className="fixed inset-0 z-1002 bg-background-overlay"></div>} <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx index 206a7c0148..db7b69f7cb 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx @@ -57,7 +57,7 @@ describe('EditWorkspaceModal', () => { it('should render on the dify-ui overlay layer', async () => { renderModal() - expect(await screen.findByRole('dialog')).toHaveClass('z-1002') + expect(await screen.findByRole('dialog')).toHaveClass('z-50') }) it('should let user edit workspace name', async () => { diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx index ec4866b212..98a7b10c76 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx @@ -1,5 +1,5 @@ import type { Credential } from '@/app/components/tools/types' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' import ConfigCredential from '../config-credentials' @@ -82,6 +82,25 @@ describe('ConfigCredential', () => { expect(mockOnChange).not.toHaveBeenCalled() }) + it('should call onHide when Escape is pressed', async () => { + await act(async () => { + render( + <ConfigCredential + credential={baseCredential} + onChange={mockOnChange} + onHide={mockOnHide} + />, + ) + }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + await waitFor(() => { + expect(mockOnHide).toHaveBeenCalledTimes(1) + }) + expect(mockOnChange).not.toHaveBeenCalled() + }) + it('should call both onChange and onHide when save is pressed', async () => { await act(async () => { render( diff --git a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx index 9ed7c45165..dde11b56f3 100644 --- a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx @@ -1,11 +1,19 @@ 'use client' -import type { FC } from 'react' import type { Credential } from '@/app/components/tools/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Drawer from '@/app/components/base/drawer-plus' import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import Radio from '@/app/components/base/radio/ui' @@ -25,175 +33,200 @@ type ItemProps = { onClick: (value: AuthType | AuthHeaderPrefix) => void } -const SelectItem: FC<ItemProps> = ({ text, value, isChecked, onClick }) => { +function SelectItem({ text, value, isChecked, onClick }: ItemProps) { return ( - <div - className={cn(isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border', 'mb-2 flex h-9 w-[150px] cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 hover:bg-components-panel-on-panel-item-bg-hover')} + <button + type="button" + className={cn( + isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border', + 'mb-2 flex h-9 w-37.5 cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover', + )} onClick={() => onClick(value)} > <Radio isChecked={isChecked} /> <div className="system-sm-regular text-text-primary">{text}</div> - </div> + </button> ) } -const ConfigCredential: FC<Props> = ({ +export default function ConfigCredential({ positionCenter, credential, onChange, onHide, -}) => { +}: Props) { const { t } = useTranslation() - const [tempCredential, setTempCredential] = React.useState<Credential>(credential) + const [tempCredential, setTempCredential] = useState<Credential>(credential) return ( <Drawer - isShow - positionCenter={positionCenter} - onHide={onHide} - title={t('createTool.authMethod.title', { ns: 'tools' })!} - dialogClassName="z-60" - dialogBackdropClassName="z-70" - panelClassName="mt-2 w-[520px]! h-fit z-80" - maxWidthClassName="max-w-[520px]!" - height="fit-content" - headerClassName="border-b-divider-regular!" - body={( - <div className="px-6 pt-2"> - <div className="space-y-4"> - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.type', { ns: 'tools' })}</div> - <div className="flex space-x-3"> - <SelectItem - text={t('createTool.authMethod.types.none', { ns: 'tools' })} - value={AuthType.none} - isChecked={tempCredential.auth_type === AuthType.none} - onClick={value => setTempCredential({ - auth_type: value as AuthType, - })} - /> - <SelectItem - text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })} - value={AuthType.apiKeyHeader} - isChecked={tempCredential.auth_type === AuthType.apiKeyHeader} - onClick={value => setTempCredential({ - auth_type: value as AuthType, - api_key_header: tempCredential.api_key_header || 'Authorization', - api_key_value: tempCredential.api_key_value || '', - api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom, - })} - /> - <SelectItem - text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })} - value={AuthType.apiKeyQuery} - isChecked={tempCredential.auth_type === AuthType.apiKeyQuery} - onClick={value => setTempCredential({ - auth_type: value as AuthType, - api_key_query_param: tempCredential.api_key_query_param || 'key', - api_key_value: tempCredential.api_key_value || '', - })} - /> + open + modal + disablePointerDismissal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} + > + <DrawerPortal> + <DrawerBackdrop forceRender /> + <DrawerViewport> + <DrawerPopup + className={cn( + 'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-auto data-[swipe-direction=right]:h-fit data-[swipe-direction=right]:max-h-[calc(100dvh-1rem)] data-[swipe-direction=right]:w-130 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle', + positionCenter + ? 'data-[swipe-direction=right]:right-[max(0.5rem,calc(50%_-_260px))]' + : 'data-[swipe-direction=right]:right-2', + )} + > + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="shrink-0 border-b border-divider-regular py-4"> + <div className="flex h-6 items-center justify-between pr-5 pl-6"> + <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> + {t('createTool.authMethod.title', { ns: 'tools' })} + </DrawerTitle> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + /> + </div> </div> - </div> - {tempCredential.auth_type === AuthType.apiKeyHeader && ( - <> - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}</div> - <div className="flex space-x-3"> - <SelectItem - text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })} - value={AuthHeaderPrefix.basic} - isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic} - onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })} - /> - <SelectItem - text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })} - value={AuthHeaderPrefix.bearer} - isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer} - onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })} - /> - <SelectItem - text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })} - value={AuthHeaderPrefix.custom} - isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom} - onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })} - /> + <div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2"> + <div className="space-y-4"> + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.type', { ns: 'tools' })}</div> + <div className="flex space-x-3"> + <SelectItem + text={t('createTool.authMethod.types.none', { ns: 'tools' })} + value={AuthType.none} + isChecked={tempCredential.auth_type === AuthType.none} + onClick={value => setTempCredential({ + auth_type: value as AuthType, + })} + /> + <SelectItem + text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })} + value={AuthType.apiKeyHeader} + isChecked={tempCredential.auth_type === AuthType.apiKeyHeader} + onClick={value => setTempCredential({ + auth_type: value as AuthType, + api_key_header: tempCredential.api_key_header || 'Authorization', + api_key_value: tempCredential.api_key_value || '', + api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom, + })} + /> + <SelectItem + text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })} + value={AuthType.apiKeyQuery} + isChecked={tempCredential.auth_type === AuthType.apiKeyQuery} + onClick={value => setTempCredential({ + auth_type: value as AuthType, + api_key_query_param: tempCredential.api_key_query_param || 'key', + api_key_value: tempCredential.api_key_value || '', + })} + /> + </div> </div> + {tempCredential.auth_type === AuthType.apiKeyHeader && ( + <> + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}</div> + <div className="flex space-x-3"> + <SelectItem + text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })} + value={AuthHeaderPrefix.basic} + isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic} + onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })} + /> + <SelectItem + text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })} + value={AuthHeaderPrefix.bearer} + isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer} + onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })} + /> + <SelectItem + text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })} + value={AuthHeaderPrefix.custom} + isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom} + onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })} + /> + </div> + </div> + <div> + <div className="flex items-center py-2 system-sm-medium text-text-primary"> + {t('createTool.authMethod.key', { ns: 'tools' })} + <Infotip + aria-label={t('createTool.authMethod.keyTooltip', { ns: 'tools' })} + className="ml-0.5 h-4 w-4" + popupClassName="w-[261px] text-text-tertiary" + > + {t('createTool.authMethod.keyTooltip', { ns: 'tools' })} + </Infotip> + </div> + <Input + value={tempCredential.api_key_header} + onChange={e => setTempCredential({ ...tempCredential, api_key_header: e.target.value })} + placeholder={t('createTool.authMethod.types.apiKeyPlaceholder', { ns: 'tools' })!} + /> + </div> + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.value', { ns: 'tools' })}</div> + <Input + value={tempCredential.api_key_value} + onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })} + placeholder={t('createTool.authMethod.types.apiValuePlaceholder', { ns: 'tools' })!} + /> + </div> + </> + )} + {tempCredential.auth_type === AuthType.apiKeyQuery && ( + <> + <div> + <div className="flex items-center py-2 system-sm-medium text-text-primary"> + {t('createTool.authMethod.queryParam', { ns: 'tools' })} + <Infotip + aria-label={t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })} + className="ml-0.5 h-4 w-4" + popupClassName="w-[261px] text-text-tertiary" + > + {t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })} + </Infotip> + </div> + <Input + value={tempCredential.api_key_query_param} + onChange={e => setTempCredential({ ...tempCredential, api_key_query_param: e.target.value })} + placeholder={t('createTool.authMethod.types.queryParamPlaceholder', { ns: 'tools' })!} + /> + </div> + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.value', { ns: 'tools' })}</div> + <Input + value={tempCredential.api_key_value} + onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })} + placeholder={t('createTool.authMethod.types.apiValuePlaceholder', { ns: 'tools' })!} + /> + </div> + </> + )} </div> - <div> - <div className="flex items-center py-2 system-sm-medium text-text-primary"> - {t('createTool.authMethod.key', { ns: 'tools' })} - <Infotip - aria-label={t('createTool.authMethod.keyTooltip', { ns: 'tools' })} - className="ml-0.5 h-4 w-4" - popupClassName="w-[261px] text-text-tertiary" - > - {t('createTool.authMethod.keyTooltip', { ns: 'tools' })} - </Infotip> - </div> - <Input - value={tempCredential.api_key_header} - onChange={e => setTempCredential({ ...tempCredential, api_key_header: e.target.value })} - placeholder={t('createTool.authMethod.types.apiKeyPlaceholder', { ns: 'tools' })!} - /> - </div> - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.value', { ns: 'tools' })}</div> - <Input - value={tempCredential.api_key_value} - onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })} - placeholder={t('createTool.authMethod.types.apiValuePlaceholder', { ns: 'tools' })!} - /> - </div> - </> - )} - {tempCredential.auth_type === AuthType.apiKeyQuery && ( - <> - <div> - <div className="flex items-center py-2 system-sm-medium text-text-primary"> - {t('createTool.authMethod.queryParam', { ns: 'tools' })} - <Infotip - aria-label={t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })} - className="ml-0.5 h-4 w-4" - popupClassName="w-[261px] text-text-tertiary" - > - {t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })} - </Infotip> - </div> - <Input - value={tempCredential.api_key_query_param} - onChange={e => setTempCredential({ ...tempCredential, api_key_query_param: e.target.value })} - placeholder={t('createTool.authMethod.types.queryParamPlaceholder', { ns: 'tools' })!} - /> - </div> - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.value', { ns: 'tools' })}</div> - <Input - value={tempCredential.api_key_value} - onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })} - placeholder={t('createTool.authMethod.types.apiValuePlaceholder', { ns: 'tools' })!} - /> - </div> - </> - )} - - </div> - - <div className="mt-4 flex shrink-0 justify-end space-x-2 py-4"> - <Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button - variant="primary" - onClick={() => { - onChange(tempCredential) - onHide() - }} - > - {t('operation.save', { ns: 'common' })} - </Button> - </div> - </div> - )} - /> + </div> + <div className="mt-4 flex shrink-0 justify-end space-x-2 py-4 pr-6 pl-6"> + <Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button + variant="primary" + onClick={() => { + onChange(tempCredential) + onHide() + }} + > + {t('operation.save', { ns: 'common' })} + </Button> + </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> + </Drawer> ) } -export default React.memo(ConfigCredential) diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx index 2bb5059870..b07d21ce14 100644 --- a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx @@ -1,12 +1,13 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { toast } from '@langgenius/dify-ui/toast' import { - RiAddLine, - RiArrowDownSLine, -} from '@remixicon/react' -import { useClickAway } from 'ahooks' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -32,7 +33,7 @@ const GetSchema: FC<Props> = ({ } setIsParsing(true) try { - const { schema } = await importSchemaFromURL(importUrl) as any + const { schema } = await importSchemaFromURL(importUrl) setImportUrl('') onChange(schema) } @@ -42,79 +43,79 @@ const GetSchema: FC<Props> = ({ } } - const importURLRef = React.useRef(null) - useClickAway(() => { - setShowImportFromUrl(false) - }, importURLRef) - const [showExamples, setShowExamples] = useState(false) - const showExamplesRef = React.useRef(null) - useClickAway(() => { - setShowExamples(false) - }, showExamplesRef) return ( - <div className="relative flex w-[224px] justify-end space-x-1"> - <div ref={importURLRef}> - <Button - size="small" - className="space-x-1" - onClick={() => { setShowImportFromUrl(!showImportFromUrl) }} + <div className="flex w-[224px] justify-end gap-1"> + <DropdownMenu open={showImportFromUrl} onOpenChange={setShowImportFromUrl}> + <DropdownMenuTrigger + render={( + <Button + size="small" + className="gap-1" + /> + )} > - <RiAddLine className="h-3 w-3" /> - <div className="system-xs-medium text-text-secondary">{t('createTool.importFromUrl', { ns: 'tools' })}</div> - </Button> - {showImportFromUrl && ( - <div className="absolute top-[26px] left-[-35px] rounded-lg border border-components-panel-border bg-components-panel-bg p-2 shadow-lg"> - <div className="relative"> - <Input - type="text" - className="w-[244px]" - placeholder={t('createTool.importFromUrlPlaceHolder', { ns: 'tools' })!} - value={importUrl} - onChange={e => setImportUrl(e.target.value)} - /> - <Button - className="absolute top-1 right-1" - size="small" - variant="primary" - disabled={!importUrl} - onClick={handleImportFromUrl} - loading={isParsing} - > - {isParsing ? '' : t('operation.ok', { ns: 'common' })} - </Button> - </div> - </div> - )} - </div> - <div className="relative -mt-0.5" ref={showExamplesRef}> - <Button - size="small" - className="space-x-1" - onClick={() => { setShowExamples(!showExamples) }} + <span className="i-ri-add-line size-3" aria-hidden /> + <span className="system-xs-medium text-text-secondary">{t('createTool.importFromUrl', { ns: 'tools' })}</span> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-start" + sideOffset={2} + popupClassName="w-[300px] p-2" > - <div className="system-xs-medium text-text-secondary">{t('createTool.examples', { ns: 'tools' })}</div> - <RiArrowDownSLine className="h-3 w-3" /> - </Button> - {showExamples && ( - <div className="absolute top-7 right-0 rounded-lg bg-components-panel-bg p-1 shadow-sm"> - {examples.map(item => ( - <div - key={item.key} - onClick={() => { - onChange(item.content) - setShowExamples(false) - }} - className="cursor-pointer rounded-lg px-3 py-1.5 system-sm-regular leading-5 whitespace-nowrap text-text-secondary hover:bg-components-panel-on-panel-item-bg-hover" - > - {t(`createTool.exampleOptions.${item.key}`, { ns: 'tools' })} - </div> - ))} + <div className="relative"> + <Input + type="text" + className="w-full" + placeholder={t('createTool.importFromUrlPlaceHolder', { ns: 'tools' })!} + value={importUrl} + onChange={e => setImportUrl(e.target.value)} + /> + <Button + className="absolute top-1 right-1" + size="small" + variant="primary" + disabled={!importUrl} + onClick={handleImportFromUrl} + loading={isParsing} + > + {isParsing ? '' : t('operation.ok', { ns: 'common' })} + </Button> </div> - )} - - </div> + </DropdownMenuContent> + </DropdownMenu> + <DropdownMenu open={showExamples} onOpenChange={setShowExamples}> + <DropdownMenuTrigger + render={( + <Button + size="small" + className="gap-1" + /> + )} + > + <span className="system-xs-medium text-text-secondary">{t('createTool.examples', { ns: 'tools' })}</span> + <span className="i-ri-arrow-down-s-line size-3" aria-hidden /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={2} + popupClassName="min-w-max" + > + {examples.map(item => ( + <DropdownMenuItem + key={item.key} + onClick={() => { + onChange(item.content) + setShowExamples(false) + }} + className="system-sm-regular whitespace-nowrap text-text-secondary" + > + {t(`createTool.exampleOptions.${item.key}`, { ns: 'tools' })} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> </div> ) } diff --git a/web/app/components/tools/edit-custom-collection-modal/index.tsx b/web/app/components/tools/edit-custom-collection-modal/index.tsx index 03899941ba..0a0f017a36 100644 --- a/web/app/components/tools/edit-custom-collection-modal/index.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/index.tsx @@ -3,6 +3,16 @@ import type { FC } from 'react' import type { Credential, CustomCollectionBackend, CustomParamSchema, Emoji } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import { RiSettings2Line } from '@remixicon/react' import { useDebounce, useGetState } from 'ahooks' @@ -11,7 +21,6 @@ import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' -import Drawer from '@/app/components/base/drawer-plus' import EmojiPicker from '@/app/components/base/emoji-picker' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' @@ -191,199 +200,224 @@ const EditCustomCollectionModal: FC<Props> = ({ return ( <> <Drawer - isShow - positionCenter={isAdd && !positionLeft} - onHide={onHide} - title={t(`createTool.${isAdd ? 'title' : 'editTitle'}`, { ns: 'tools' })!} - dialogClassName={dialogClassName} - panelClassName="mt-2 w-[640px]!" - maxWidthClassName="max-w-[640px]!" - height="calc(100vh - 16px)" - headerClassName="border-b-divider-regular!" - body={( - <div className="flex h-full flex-col"> - <div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3"> - <div> - <div className="py-2 system-sm-medium text-text-primary"> - {t('createTool.name', { ns: 'tools' })} - {' '} - <span className="ml-1 text-red-500">*</span> - </div> - <div className="flex items-center justify-between gap-3"> - <AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" icon={emoji.content} background={emoji.background} /> - <Input - className="h-10 grow" - placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!} - value={customCollection.provider} - onChange={(e) => { - const newCollection = produce(customCollection, (draft) => { - draft.provider = e.target.value - }) - setCustomCollection(newCollection) - }} - /> - </div> - </div> - - {/* Schema */} - <div className="select-none"> - <div className="flex items-center justify-between"> - <div className="flex items-center"> - <div className="py-2 system-sm-medium text-text-primary"> - {t('createTool.schema', { ns: 'tools' })} - <span className="ml-1 text-red-500">*</span> - </div> - <div className="mx-2 h-3 w-px bg-divider-regular"></div> - <a - href="https://swagger.io/specification/" - target="_blank" - rel="noopener noreferrer" - className="flex h-[18px] items-center space-x-1 text-text-accent" - > - <div className="text-xs font-normal">{t('createTool.viewSchemaSpec', { ns: 'tools' })}</div> - <LinkExternal02 className="h-3 w-3" /> - </a> + open + modal + disablePointerDismissal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} + > + <DrawerPortal> + <DrawerBackdrop forceRender /> + <DrawerViewport className={dialogClassName}> + <DrawerPopup + className={cn( + 'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-160 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle', + isAdd && !positionLeft + ? 'data-[swipe-direction=right]:right-[max(0.5rem,calc(50%_-_320px))]' + : 'data-[swipe-direction=right]:right-2', + )} + > + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="shrink-0 border-b border-divider-regular py-4"> + <div className="flex h-6 items-center justify-between pr-5 pl-6"> + <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> + {t(`createTool.${isAdd ? 'title' : 'editTitle'}`, { ns: 'tools' })} + </DrawerTitle> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + /> </div> - <GetSchema onChange={setSchema} /> - </div> - <Textarea - className="h-[240px] resize-none" - value={schema} - onChange={e => setSchema(e.target.value)} - placeholder={t('createTool.schemaPlaceHolder', { ns: 'tools' })!} - /> - </div> + <div className="min-h-0 flex-1"> + <div className="flex h-full flex-col"> + <div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3"> + <div> + <div className="py-2 system-sm-medium text-text-primary"> + {t('createTool.name', { ns: 'tools' })} + {' '} + <span className="ml-1 text-red-500">*</span> + </div> + <div className="flex items-center justify-between gap-3"> + <AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" icon={emoji.content} background={emoji.background} /> + <Input + className="h-10 grow" + placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!} + value={customCollection.provider} + onChange={(e) => { + const newCollection = produce(customCollection, (draft) => { + draft.provider = e.target.value + }) + setCustomCollection(newCollection) + }} + /> + </div> + </div> - {/* Available Tools */} - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.availableTools.title', { ns: 'tools' })}</div> - <div className="w-full overflow-x-auto rounded-lg border border-divider-regular"> - <table className="w-full system-xs-regular text-text-secondary"> - <thead className="text-text-tertiary uppercase"> - <tr className={cn(paramsSchemas.length > 0 && 'border-b', 'border-divider-regular')}> - <th className="p-2 pl-3 font-medium">{t('createTool.availableTools.name', { ns: 'tools' })}</th> - <th className="w-[236px] p-2 pl-3 font-medium">{t('createTool.availableTools.description', { ns: 'tools' })}</th> - <th className="p-2 pl-3 font-medium">{t('createTool.availableTools.method', { ns: 'tools' })}</th> - <th className="p-2 pl-3 font-medium">{t('createTool.availableTools.path', { ns: 'tools' })}</th> - <th className="w-[54px] p-2 pl-3 font-medium">{t('createTool.availableTools.action', { ns: 'tools' })}</th> - </tr> - </thead> - <tbody> - {paramsSchemas.map((item, index) => ( - <tr key={index} className="border-b border-divider-regular last:border-0"> - <td className="p-2 pl-3">{item.operation_id}</td> - <td className="w-[236px] p-2 pl-3">{item.summary}</td> - <td className="p-2 pl-3">{item.method}</td> - <td className="p-2 pl-3">{getPath(item.server_url)}</td> - <td className="w-[62px] p-2 pl-3"> - <Button - size="small" - onClick={() => { - setCurrTool(item) - setIsShowTestApi(true) - }} + {/* Schema */} + <div className="select-none"> + <div className="flex items-center justify-between"> + <div className="flex items-center"> + <div className="py-2 system-sm-medium text-text-primary"> + {t('createTool.schema', { ns: 'tools' })} + <span className="ml-1 text-red-500">*</span> + </div> + <div className="mx-2 h-3 w-px bg-divider-regular"></div> + <a + href="https://swagger.io/specification/" + target="_blank" + rel="noopener noreferrer" + className="flex h-[18px] items-center space-x-1 text-text-accent" > - {t('createTool.availableTools.test', { ns: 'tools' })} - </Button> - </td> - </tr> - ))} - </tbody> - </table> + <div className="text-xs font-normal">{t('createTool.viewSchemaSpec', { ns: 'tools' })}</div> + <LinkExternal02 className="h-3 w-3" /> + </a> + </div> + <GetSchema onChange={setSchema} /> + + </div> + <Textarea + className="h-[240px] resize-none" + value={schema} + onChange={e => setSchema(e.target.value)} + placeholder={t('createTool.schemaPlaceHolder', { ns: 'tools' })!} + /> + </div> + + {/* Available Tools */} + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.availableTools.title', { ns: 'tools' })}</div> + <div className="w-full overflow-x-auto rounded-lg border border-divider-regular"> + <table className="w-full system-xs-regular text-text-secondary"> + <thead className="text-text-tertiary uppercase"> + <tr className={cn(paramsSchemas.length > 0 && 'border-b', 'border-divider-regular')}> + <th className="p-2 pl-3 font-medium">{t('createTool.availableTools.name', { ns: 'tools' })}</th> + <th className="w-[236px] p-2 pl-3 font-medium">{t('createTool.availableTools.description', { ns: 'tools' })}</th> + <th className="p-2 pl-3 font-medium">{t('createTool.availableTools.method', { ns: 'tools' })}</th> + <th className="p-2 pl-3 font-medium">{t('createTool.availableTools.path', { ns: 'tools' })}</th> + <th className="w-[54px] p-2 pl-3 font-medium">{t('createTool.availableTools.action', { ns: 'tools' })}</th> + </tr> + </thead> + <tbody> + {paramsSchemas.map((item, index) => ( + <tr key={index} className="border-b border-divider-regular last:border-0"> + <td className="p-2 pl-3">{item.operation_id}</td> + <td className="w-[236px] p-2 pl-3">{item.summary}</td> + <td className="p-2 pl-3">{item.method}</td> + <td className="p-2 pl-3">{getPath(item.server_url)}</td> + <td className="w-[62px] p-2 pl-3"> + <Button + size="small" + onClick={() => { + setCurrTool(item) + setIsShowTestApi(true) + }} + > + {t('createTool.availableTools.test', { ns: 'tools' })} + </Button> + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + + {/* Authorization method */} + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.title', { ns: 'tools' })}</div> + <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}> + <div className="system-xs-regular text-text-primary">{t(`createTool.authMethod.types.${credential.auth_type}`, { ns: 'tools' })}</div> + <RiSettings2Line className="h-4 w-4 text-text-secondary" /> + </div> + </div> + + {/* Labels */} + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div> + <LabelSelector value={labels} onChange={handleLabelSelect} /> + </div> + + {/* Privacy Policy */} + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div> + <Input + value={customCollection.privacy_policy} + onChange={(e) => { + const newCollection = produce(customCollection, (draft) => { + draft.privacy_policy = e.target.value + }) + setCustomCollection(newCollection) + }} + className="h-10 grow" + placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''} + /> + </div> + + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.customDisclaimer', { ns: 'tools' })}</div> + <Input + value={customCollection.custom_disclaimer} + onChange={(e) => { + const newCollection = produce(customCollection, (draft) => { + draft.custom_disclaimer = e.target.value + }) + setCustomCollection(newCollection) + }} + className="h-10 grow" + placeholder={t('createTool.customDisclaimerPlaceholder', { ns: 'tools' }) || ''} + /> + </div> + + </div> + <div className={cn(isEdit ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}> + { + isEdit && ( + <Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button> + ) + } + <div className="flex space-x-2"> + <Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button> + </div> + </div> + {showEmojiPicker && ( + <EmojiPicker + onSelect={(icon, icon_background) => { + setEmoji({ content: icon, background: icon_background }) + setShowEmojiPicker(false) + }} + onClose={() => { + setShowEmojiPicker(false) + }} + /> + )} + {credentialsModalShow && ( + <ConfigCredentials + positionCenter={isAdd} + credential={credential} + onChange={setCredential} + onHide={() => setCredentialsModalShow(false)} + /> + )} + {isShowTestApi && ( + <TestApi + positionCenter={isAdd} + tool={currTool as CustomParamSchema} + customCollection={customCollection} + onHide={() => setIsShowTestApi(false)} + /> + )} + </div> </div> - </div> - - {/* Authorization method */} - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.title', { ns: 'tools' })}</div> - <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}> - <div className="system-xs-regular text-text-primary">{t(`createTool.authMethod.types.${credential.auth_type}`, { ns: 'tools' })}</div> - <RiSettings2Line className="h-4 w-4 text-text-secondary" /> - </div> - </div> - - {/* Labels */} - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div> - <LabelSelector value={labels} onChange={handleLabelSelect} /> - </div> - - {/* Privacy Policy */} - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div> - <Input - value={customCollection.privacy_policy} - onChange={(e) => { - const newCollection = produce(customCollection, (draft) => { - draft.privacy_policy = e.target.value - }) - setCustomCollection(newCollection) - }} - className="h-10 grow" - placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''} - /> - </div> - - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.customDisclaimer', { ns: 'tools' })}</div> - <Input - value={customCollection.custom_disclaimer} - onChange={(e) => { - const newCollection = produce(customCollection, (draft) => { - draft.custom_disclaimer = e.target.value - }) - setCustomCollection(newCollection) - }} - className="h-10 grow" - placeholder={t('createTool.customDisclaimerPlaceholder', { ns: 'tools' }) || ''} - /> - </div> - - </div> - <div className={cn(isEdit ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}> - { - isEdit && ( - <Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button> - ) - } - <div className="flex space-x-2"> - <Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button> - </div> - </div> - {showEmojiPicker && ( - <EmojiPicker - onSelect={(icon, icon_background) => { - setEmoji({ content: icon, background: icon_background }) - setShowEmojiPicker(false) - }} - onClose={() => { - setShowEmojiPicker(false) - }} - /> - )} - {credentialsModalShow && ( - <ConfigCredentials - positionCenter={isAdd} - credential={credential} - onChange={setCredential} - onHide={() => setCredentialsModalShow(false)} - /> - )} - {isShowTestApi && ( - <TestApi - positionCenter={isAdd} - tool={currTool as CustomParamSchema} - customCollection={customCollection} - onHide={() => setIsShowTestApi(false)} - /> - )} - </div> - )} - isShowMask={true} - clickOutsideNotOpen={true} - /> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> + </Drawer> </> ) diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx index 0eecb8ca6b..19ef1bbf54 100644 --- a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx @@ -2,11 +2,21 @@ import type { FC } from 'react' import type { Credential, CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types' import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { RiSettings2Line } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Drawer from '@/app/components/base/drawer-plus' import Input from '@/app/components/base/input' import { AuthType } from '@/app/components/tools/types' import { useLocale } from '@/context/i18n' @@ -63,70 +73,96 @@ const TestApi: FC<Props> = ({ return ( <> <Drawer - isShow - positionCenter={positionCenter} - onHide={onHide} - title={`${t('test.title', { ns: 'tools' })} ${toolName}`} - panelClassName="mt-2 w-[600px]!" - maxWidthClassName="max-w-[600px]!" - height="calc(100vh - 16px)" - headerClassName="border-b-divider-regular!" - body={( - <div className="overflow-y-auto px-6 pt-2"> - <div className="space-y-4"> - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.title', { ns: 'tools' })}</div> - <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}> - <div className="system-xs-regular text-text-primary">{t(`createTool.authMethod.types.${tempCredential.auth_type}`, { ns: 'tools' })}</div> - <RiSettings2Line className="h-4 w-4 text-text-secondary" /> + open + modal + disablePointerDismissal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} + > + <DrawerPortal> + <DrawerBackdrop forceRender /> + <DrawerViewport> + <DrawerPopup + className={cn( + 'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-150 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle', + positionCenter + ? 'data-[swipe-direction=right]:right-[max(0.5rem,calc(50%_-_300px))]' + : 'data-[swipe-direction=right]:right-2', + )} + > + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="shrink-0 border-b border-divider-regular py-4"> + <div className="flex h-6 items-center justify-between pr-5 pl-6"> + <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> + {`${t('test.title', { ns: 'tools' })} ${toolName}`} + </DrawerTitle> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + /> + </div> </div> - </div> + <div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2"> + <div className="space-y-4"> + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.title', { ns: 'tools' })}</div> + <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}> + <div className="system-xs-regular text-text-primary">{t(`createTool.authMethod.types.${tempCredential.auth_type}`, { ns: 'tools' })}</div> + <RiSettings2Line className="h-4 w-4 text-text-secondary" /> + </div> + </div> - <div> - <div className="py-2 system-sm-medium text-text-primary">{t('test.parametersValue', { ns: 'tools' })}</div> - <div className="rounded-lg border border-divider-regular"> - <table className="w-full system-xs-regular font-normal text-text-secondary"> - <thead className="text-text-tertiary uppercase"> - <tr className="border-b border-divider-regular"> - <th className="p-2 pl-3 font-medium">{t('test.parameters', { ns: 'tools' })}</th> - <th className="p-2 pl-3 font-medium">{t('test.value', { ns: 'tools' })}</th> - </tr> - </thead> - <tbody> - {parameters.map((item, index) => ( - <tr key={index} className="border-b border-divider-regular last:border-0"> - <td className="py-2 pr-2.5 pl-3"> - {item.label[language]} - </td> - <td className=""> - <Input - value={parametersValue[item.name] || ''} - onChange={e => setParametersValue({ ...parametersValue, [item.name]: e.target.value })} - type="text" - className="!hover:border-transparent !hover:bg-transparent !focus:border-transparent !focus:bg-transparent border-transparent! bg-transparent!" - /> - </td> - </tr> - ))} - </tbody> - </table> + <div> + <div className="py-2 system-sm-medium text-text-primary">{t('test.parametersValue', { ns: 'tools' })}</div> + <div className="rounded-lg border border-divider-regular"> + <table className="w-full system-xs-regular font-normal text-text-secondary"> + <thead className="text-text-tertiary uppercase"> + <tr className="border-b border-divider-regular"> + <th className="p-2 pl-3 font-medium">{t('test.parameters', { ns: 'tools' })}</th> + <th className="p-2 pl-3 font-medium">{t('test.value', { ns: 'tools' })}</th> + </tr> + </thead> + <tbody> + {parameters.map((item, index) => ( + <tr key={index} className="border-b border-divider-regular last:border-0"> + <td className="py-2 pr-2.5 pl-3"> + {item.label[language]} + </td> + <td className=""> + <Input + value={parametersValue[item.name] || ''} + onChange={e => setParametersValue({ ...parametersValue, [item.name]: e.target.value })} + type="text" + className="!hover:border-transparent !hover:bg-transparent !focus:border-transparent !focus:bg-transparent border-transparent! bg-transparent!" + /> + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + + </div> + <Button variant="primary" className="mt-4 h-10 w-full" loading={testing} disabled={testing} onClick={handleTest}>{t('test.title', { ns: 'tools' })}</Button> + <div className="mt-6"> + <div className="flex items-center space-x-3"> + <div className="system-xs-semibold text-text-tertiary">{t('test.testResult', { ns: 'tools' })}</div> + <div className="bg-[rgb(243, 244, 246)] h-px w-0 grow"></div> + </div> + <div className="mt-2 h-[200px] overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-3 py-2 system-xs-regular text-text-secondary"> + {result || <span className="text-text-quaternary">{t('test.testResultPlaceholder', { ns: 'tools' })}</span>} + </div> + </div> </div> - </div> - - </div> - <Button variant="primary" className="mt-4 h-10 w-full" loading={testing} disabled={testing} onClick={handleTest}>{t('test.title', { ns: 'tools' })}</Button> - <div className="mt-6"> - <div className="flex items-center space-x-3"> - <div className="system-xs-semibold text-text-tertiary">{t('test.testResult', { ns: 'tools' })}</div> - <div className="bg-[rgb(243, 244, 246)] h-px w-0 grow"></div> - </div> - <div className="mt-2 h-[200px] overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-3 py-2 system-xs-regular text-text-secondary"> - {result || <span className="text-text-quaternary">{t('test.testResultPlaceholder', { ns: 'tools' })}</span>} - </div> - </div> - </div> - )} - /> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> + </Drawer> {credentialsModalShow && ( <ConfigCredentials positionCenter={positionCenter} diff --git a/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx b/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx index 996a4077af..0cb3970eaa 100644 --- a/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx +++ b/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx @@ -23,16 +23,6 @@ vi.mock('../../../utils/to-form-schema', () => ({ addDefaultValue: (value: Record<string, unknown>, _schemas: unknown[]) => ({ ...value }), })) -vi.mock('@/app/components/base/drawer-plus', () => ({ - default: ({ body, title, onHide }: { body: React.ReactNode, title: string, onHide: () => void }) => ( - <div data-testid="drawer"> - <span data-testid="drawer-title">{title}</span> - <button data-testid="drawer-close" onClick={onHide}>Close</button> - {body} - </div> - ), -})) - vi.mock('@langgenius/dify-ui/toast', () => ({ default: { notify: vi.fn() }, })) @@ -104,7 +94,7 @@ describe('ConfigCredential', () => { onSaved={mockOnSaved} />, ) - expect(screen.getByTestId('drawer-title')).toHaveTextContent('tools.auth.setupModalTitle') + expect(screen.getByText('tools.auth.setupModalTitle')).toBeInTheDocument() }) it('calls onCancel when cancel button is clicked', async () => { diff --git a/web/app/components/tools/setting/build-in/config-credentials.tsx b/web/app/components/tools/setting/build-in/config-credentials.tsx index d6c62f5d88..2ee17d4298 100644 --- a/web/app/components/tools/setting/build-in/config-credentials.tsx +++ b/web/app/components/tools/setting/build-in/config-credentials.tsx @@ -3,12 +3,22 @@ import type { FC } from 'react' import type { Collection } from '../../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import Drawer from '@/app/components/base/drawer-plus' import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general' import Loading from '@/app/components/base/loading' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -69,64 +79,81 @@ const ConfigCredential: FC<Props> = ({ return ( <Drawer - isShow - onHide={onCancel} - title={t('auth.setupModalTitle', { ns: 'tools' }) as string} - titleDescription={t('auth.setupModalTitleDescription', { ns: 'tools' }) as string} - panelClassName="mt-[64px] mb-2 w-[420px]! border-components-panel-border" - maxWidthClassName="max-w-[420px]!" - height="calc(100vh - 64px)" - contentClassName="bg-components-panel-bg!" - headerClassName="border-b-divider-subtle!" - body={( - <div className="h-full px-6 py-3"> - {!credentialSchema - ? <Loading type="app" /> - : ( - <> - <Form - value={tempCredential} - onChange={(v) => { - setTempCredential(v) - }} - formSchemas={credentialSchema} - isEditMode={true} - showOnVariableMap={{}} - validating={false} - inputClassName="bg-components-input-bg-normal!" - fieldMoreInfo={item => item.url - ? ( - <a - href={item.url} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center text-xs text-text-accent" - > - {t('howToGet', { ns: 'tools' })} - <LinkExternal02 className="ml-1 h-3 w-3" /> - </a> - ) - : null} + open + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onCancel() + }} + > + <DrawerPortal> + <DrawerBackdrop /> + <DrawerViewport> + <DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-105 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border"> + <DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0"> + <div className="shrink-0 border-b border-divider-subtle py-4"> + <div className="flex h-6 items-center justify-between pr-5 pl-6"> + <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> + {t('auth.setupModalTitle', { ns: 'tools' })} + </DrawerTitle> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" /> - <div className={cn((collection.is_team_authorization && !isHideRemoveBtn) ? 'justify-between' : 'justify-end', 'mt-2 flex')}> - { - (collection.is_team_authorization && !isHideRemoveBtn) && ( - <Button onClick={onRemove}>{t('operation.remove', { ns: 'common' })}</Button> - ) - } - <div className="flex space-x-2"> - <Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button loading={isLoading || isSaving} disabled={isLoading || isSaving} variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button> - </div> - </div> - </> - )} - - </div> - )} - isShowMask={true} - clickOutsideNotOpen={false} - /> + </div> + <DrawerDescription className="pr-10 pl-6 system-xs-regular text-text-tertiary"> + {t('auth.setupModalTitleDescription', { ns: 'tools' })} + </DrawerDescription> + </div> + <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> + {!credentialSchema + ? <Loading type="app" /> + : ( + <> + <Form + value={tempCredential} + onChange={(v) => { + setTempCredential(v) + }} + formSchemas={credentialSchema} + isEditMode={true} + showOnVariableMap={{}} + validating={false} + inputClassName="bg-components-input-bg-normal!" + fieldMoreInfo={item => item.url + ? ( + <a + href={item.url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center text-xs text-text-accent" + > + {t('howToGet', { ns: 'tools' })} + <LinkExternal02 className="ml-1 h-3 w-3" /> + </a> + ) + : null} + /> + <div className={cn((collection.is_team_authorization && !isHideRemoveBtn) ? 'justify-between' : 'justify-end', 'mt-2 flex')}> + { + (collection.is_team_authorization && !isHideRemoveBtn) && ( + <Button onClick={onRemove}>{t('operation.remove', { ns: 'common' })}</Button> + ) + } + <div className="flex space-x-2"> + <Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button loading={isLoading || isSaving} disabled={isLoading || isSaving} variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button> + </div> + </div> + </> + )} + </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> + </Drawer> ) } export default React.memo(ConfigCredential) diff --git a/web/docs/lint.md b/web/docs/lint.md index ed38a7cf37..047a30d25f 100644 --- a/web/docs/lint.md +++ b/web/docs/lint.md @@ -62,7 +62,7 @@ This command lints the entire project and is intended for final verification bef If a new rule causes many existing code errors or automatic fixes generate too many diffs, do not use the `--fix` option for automatic fixes. You can introduce the rule first, then use the `--suppress-all` option to temporarily suppress these errors, and gradually fix them in subsequent changes. -For overlay migration policy and cleanup phases, see [Overlay Migration Guide]. +For overlay import policy and composition rules, see [Overlay Guide]. ## Type Check @@ -78,7 +78,7 @@ Type checking is powered by [`tsgo`] (the native TypeScript 7 compiler), which i [ESLint bulk suppressions blog post]: https://eslint.org/blog/2025/04/introducing-bulk-suppressions [ESLint multi-thread linting blog post]: https://eslint.org/blog/2025/08/multithread-linting -[Overlay Migration Guide]: ./overlay-migration.md +[Overlay Guide]: ./overlay.md [TSSLint]: https://github.com/johnsoncodehk/tsslint [`tsgo`]: https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-beta [no-leaked-conditional-rendering]: https://www.eslint-react.xyz/docs/rules/no-leaked-conditional-rendering diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md deleted file mode 100644 index 3d94d82e64..0000000000 --- a/web/docs/overlay-migration.md +++ /dev/null @@ -1,91 +0,0 @@ -# Overlay Migration Guide - -This document tracks the Dify-web migration away from legacy overlay APIs. - -> **See also:** [`packages/dify-ui/README.md`] for the permanent overlay / portal / z-index contract of the replacement primitives. This document covers the one-off migration mechanics (deprecated import paths and coexistence z-index strategy) and is expected to shrink and eventually be removed once the legacy overlays are gone. - -## Scope - -- Deprecated imports: - - `@/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` - - `@langgenius/dify-ui/select` - - `@langgenius/dify-ui/toast` -- Tracking issue: <https://github.com/langgenius/dify/issues/32767> - -## ESLint policy - -- `no-restricted-imports` blocks all deprecated imports listed above. -- The rule is enabled for normal source files (`.ts` / `.tsx`) and test files are excluded. - -## Migration phases - -1. Business/UI features outside `app/components/base/**` - - Migrate old calls to semantic primitives from `@langgenius/dify-ui/*`. - - Keep deprecated imports out of newly touched files. - - Use `@langgenius/dify-ui/tooltip` only for short, non-interactive labels where the trigger already has its own accessible name. - - Use `@langgenius/dify-ui/popover` or the web `Infotip` wrapper for explanatory, long-form, structured, or interactive content. -1. Legacy base components - - Migrate legacy base callers gradually. - - Keep deprecated imports out of newly touched files. -1. Cleanup - - Remove legacy overlay implementations when import count reaches zero. - -## z-index strategy - -All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index value: -**`z-1002`**, except Toast which stays one layer above at **`z-1003`**. - -### Why z-[1002]? - -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`, `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, -new primitives share the same z-index and rely on **DOM order** for stacking -(later portal = on top). - -Toast stays one layer above the overlay primitives so notifications remain -visible above dialogs, popovers, and other portalled surfaces without falling -back to `z-9999`. - -### Rules - -- **Do NOT add z-index overrides** (e.g. `className="z-1003"`) on new - `@langgenius/dify-ui/*` components. If you find yourself needing one, the - parent legacy overlay should be migrated instead. -- When migrating a legacy overlay that has a high z-index, remove the z-index - entirely — the new primitive's default `z-1002` handles it. -- When using Base UI trigger `render`, render a real `button` for button-like - triggers. If the trigger must render a non-button element, the primitive must - explicitly opt out of the native button behavior where that API is available. - -### Post-migration cleanup - -Once all legacy overlays are removed: - -1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui/*` primitives. -1. Reduce Toast from `z-1003` to `z-51`. -1. Remove this section from the migration guide. - -[`packages/dify-ui/README.md`]: ../../packages/dify-ui/README.md diff --git a/web/docs/overlay.md b/web/docs/overlay.md new file mode 100644 index 0000000000..76ba21b64d --- /dev/null +++ b/web/docs/overlay.md @@ -0,0 +1,44 @@ +# Overlay Best Practices + +Use `@langgenius/dify-ui/*` primitives for overlays in new and modified web +code. Do not import raw Base UI overlays or legacy web overlays from +`@/app/components/base/modal`, `@/app/components/base/dialog`, or +`@/app/components/base/drawer`. + +## Primitive choice + +- Use `@langgenius/dify-ui/dialog` for modal surfaces that need focus + management, scroll locking, escape handling, and outside-press dismissal. +- Use `@langgenius/dify-ui/alert-dialog` only for destructive or must-confirm + decisions. +- Use `@langgenius/dify-ui/drawer` for side panels, setup panels, and nested + editor panels that must behave like a drawer. Do not add separate web drawer + wrappers. +- Use `@langgenius/dify-ui/popover` or the web `Infotip` wrapper for + explanatory content, long help text, rich layout, or interactive content. +- Use `@langgenius/dify-ui/tooltip` only for short, non-interactive labels where + the trigger already has its own accessible name. + +## Preferences + +- Prefer the most specific semantic primitive over styling a generic `Dialog`. +- Prefer controlled `open` / `onOpenChange` when business state, analytics, or + cleanup must react to open state changes. +- Prefer the primitive-owned portal or content component. Do not create manual + portals around overlay primitives. +- Prefer native button trigger semantics. When passing a Base UI trigger + `render` prop, render a real `<button type="button">` for button-like + triggers; use `nativeButton={false}` only for intentional non-button triggers. +- Use `Infotip` for visible `?` help triggers. Give icon-only triggers an + accessible name. +- Keep overlay chrome inside the shared primitive or business wrapper instead of + repeating backdrop, z-index, and portal styles at call sites. + +## Layering + +All body-portalled Dify UI overlays use `z-50`. Toast uses `z-60`. The app root +must keep an isolated stacking context. + +Do not add call-site z-index overrides such as `z-9999`. If an overlay is +clipped or hidden, fix the parent overlay structure instead of raising the +child primitive. diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 864b3fa960..ab2c626482 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -172,7 +172,7 @@ export default antfu( }, }, { - name: 'dify/overlay-migration', + name: 'dify/overlay-import-policy', files: [GLOB_TS, GLOB_TSX], ignores: [ 'next/**', diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index bd8254b6fb..cc0ba060a5 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -55,26 +55,10 @@ export const WEB_RESTRICTED_IMPORT_PATTERNS = [ ] export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ - { - group: [ - '**/base/modal', - '**/base/modal/index', - ], - message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.', - }, - { - group: [ - '**/base/dialog', - '**/base/dialog/index', - ], - 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.', }, diff --git a/web/service/tools.ts b/web/service/tools.ts index ae9655d520..843fd64c6d 100644 --- a/web/service/tools.ts +++ b/web/service/tools.ts @@ -92,7 +92,7 @@ export const removeCustomCollection = (collectionName: string) => { } export const importSchemaFromURL = (url: string) => { - return get('/workspaces/current/tool-provider/api/remote', { + return get<{ schema: string }>('/workspaces/current/tool-provider/api/remote', { params: { url, }, From e48d7bb097159629923730bc5da7496d4d438ca7 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Sun, 10 May 2026 00:24:45 +0800 Subject: [PATCH 15/53] refactor(web): migrate drawer components to dify-ui and remove legacy drawer implementation (#35982) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- eslint-suppressions.json | 56 -- .../tools/tool-provider-detail-flow.test.tsx | 17 +- .../agent-tools/setting-built-in-tool.tsx | 196 ++--- .../app/configuration/configuration-view.tsx | 60 +- .../card-item/__tests__/index.spec.tsx | 8 +- .../dataset-config/card-item/index.tsx | 42 +- .../app/log/__tests__/list.spec.tsx | 32 +- web/app/components/app/log/list.tsx | 50 +- web/app/components/app/workflow-log/list.tsx | 40 +- .../base/drawer/__tests__/index.spec.tsx | 676 ------------------ .../components/base/drawer/index.stories.tsx | 114 --- web/app/components/base/drawer/index.tsx | 128 ---- .../__tests__/index.spec.tsx | 45 +- .../float-right-container/index.stories.tsx | 1 - .../base/float-right-container/index.tsx | 72 +- .../step-two/components/preview-panel.tsx | 2 +- .../datasets/documents/detail/index.tsx | 2 +- .../components/datasets/hit-testing/index.tsx | 52 +- .../plugin-detail-panel/endpoint-modal.tsx | 122 ++-- .../plugins/plugin-detail-panel/index.tsx | 78 +- .../plugin-detail-panel/strategy-detail.tsx | 178 ++--- .../trigger/event-detail-drawer.tsx | 158 ++-- .../detail/__tests__/provider-detail.spec.tsx | 11 +- .../tools/mcp/detail/provider-detail.tsx | 50 +- .../tools/provider/__tests__/detail.spec.tsx | 5 - web/app/components/tools/provider/detail.tsx | 396 +++++----- .../components/dataset-item.tsx | 39 +- 27 files changed, 946 insertions(+), 1684 deletions(-) delete mode 100644 web/app/components/base/drawer/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/drawer/index.stories.tsx delete mode 100644 web/app/components/base/drawer/index.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e49483f63c..23e2da9ee0 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -305,9 +305,6 @@ } }, "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 }, @@ -359,16 +356,6 @@ "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 @@ -475,9 +462,6 @@ } }, "web/app/components/app/log/list.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 6 }, @@ -519,9 +503,6 @@ } }, "web/app/components/app/workflow-log/list.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 2 } @@ -933,11 +914,6 @@ "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 @@ -2092,9 +2068,6 @@ } }, "web/app/components/datasets/hit-testing/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/unsupported-syntax": { "count": 1 } @@ -2540,18 +2513,10 @@ } }, "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 @@ -2573,9 +2538,6 @@ } }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -2634,9 +2596,6 @@ } }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 5 } @@ -2874,11 +2833,6 @@ "count": 1 } }, - "web/app/components/tools/mcp/detail/provider-detail.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/mcp/mcp-server-param-item.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2894,11 +2848,6 @@ "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 @@ -3711,11 +3660,6 @@ "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/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index c0dd6da1c5..5e3e94d4ff 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -120,19 +120,6 @@ vi.mock('@/utils/var', () => ({ basePath: '', })) -vi.mock('@/app/components/base/drawer', () => ({ - default: ({ isOpen, children, onClose }: { isOpen: boolean, children: React.ReactNode, onClose: () => void }) => ( - isOpen - ? ( - <div data-testid="drawer"> - {children} - <button data-testid="drawer-close" onClick={onClose}>Close Drawer</button> - </div> - ) - : null - ), -})) - vi.mock('@langgenius/dify-ui/toast', () => ({ default: { notify: vi.fn() }, toast: { @@ -525,10 +512,10 @@ describe('Tool Provider Detail Flow Integration', () => { render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) - fireEvent.click(screen.getByTestId('drawer-close')) + fireEvent.click(screen.getByRole('button', { name: 'operation.close' })) expect(mockOnHide).toHaveBeenCalled() }) }) diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index 71922d5a7e..806fdd5e93 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -4,6 +4,14 @@ import type { Collection, Tool } from '@/app/components/tools/types' import type { ToolWithProvider } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { RiArrowLeftLine, RiCloseLine, @@ -12,7 +20,6 @@ import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import Drawer from '@/app/components/base/drawer' import Loading from '@/app/components/base/loading' import TabSlider from '@/app/components/base/tab-slider-plain' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' @@ -165,98 +172,105 @@ const SettingBuiltInTool: FC<Props> = ({ return ( <Drawer - isOpen - clickOutsideNotOpen={false} - onClose={onHide} - footer={null} - mask={false} - positionCenter={false} - panelClassName={cn('mt-[64px] mr-2 mb-2 w-[420px]! max-w-[420px]! justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg! p-0! shadow-xl')} + open + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <> - {isLoading && <Loading type="app" />} - {!isLoading && ( - <> - {/* header */} - <div className="relative border-b border-divider-subtle p-4 pb-3"> - <div className="absolute top-3 right-3"> - <ActionButton onClick={onHide}> - <RiCloseLine className="h-4 w-4" /> - </ActionButton> - </div> - {showBackButton && ( - <div - className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary" - onClick={onHide} - > - <RiArrowLeftLine className="h-4 w-4" /> - {t('detailPanel.operation.back', { ns: 'plugin' })} - </div> - )} - <div className="flex items-center gap-1"> - <Icon size="tiny" className="h-6 w-6" src={collection.icon} /> - <OrgInfo - packageNameClassName="w-auto" - orgName={collection.author} - packageName={collection.name.split('/').pop() || ''} - /> - </div> - <div className="mt-1 system-md-semibold text-text-primary">{currTool?.label[language]}</div> - {!!currTool?.description[language] && ( - <Description className="mt-3 mb-2 h-auto" text={currTool.description[language]} descriptionLineRows={2}></Description> - )} - { - collection.allow_delete && collection.type === CollectionType.builtIn && ( - <PluginAuthInAgent - pluginPayload={{ - provider: collection.name, - category: AuthCategory.tool, - providerType: collection.type, - detail: collection as any, - }} - credentialId={credentialId} - onAuthorizationItemClick={onAuthorizationItemClick} - /> - ) - } - </div> - {/* form */} - <div className="h-full"> - <div className="flex h-full flex-col"> - {(hasSetting && !readonly) - ? ( - <TabSlider - className="mt-1 shrink-0 px-4" - itemClassName="py-3" - noBorderBottom - value={currType} - onChange={(value) => { - setCurrType(value) - }} - options={[ - { value: 'info', text: t('setBuiltInTools.parameters', { ns: 'tools' })! }, - { value: 'setting', text: t('setBuiltInTools.setting', { ns: 'tools' })! }, - ]} - /> - ) - : ( - <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">{t('setBuiltInTools.parameters', { ns: 'tools' })}</div> - )} - <div className="h-0 grow overflow-y-auto px-4"> - {isInfoActive ? infoUI : settingUI} - {!readonly && !isInfoActive && ( - <div className="flex shrink-0 justify-end space-x-2 rounded-b-[10px] bg-components-panel-bg py-2"> - <Button className="flex h-8 items-center px-3! text-[13px]! font-medium" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button className="flex h-8 items-center px-3! text-[13px]! font-medium" variant="primary" disabled={!isValid} onClick={() => onSave?.(tempSetting)}>{t('operation.save', { ns: 'common' })}</Button> + <DrawerPortal> + <DrawerBackdrop className="bg-transparent" /> + <DrawerViewport> + <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + {isLoading && <Loading type="app" />} + {!isLoading && ( + <> + {/* header */} + <div className="relative border-b border-divider-subtle p-4 pb-3"> + <div className="absolute top-3 right-3"> + <ActionButton onClick={onHide}> + <RiCloseLine className="h-4 w-4" /> + </ActionButton> </div> - )} - </div> - <ReadmeEntrance pluginDetail={collection as any} className="mt-auto" /> - </div> - </div> - </> - )} - </> + {showBackButton && ( + <div + className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary" + onClick={onHide} + > + <RiArrowLeftLine className="h-4 w-4" /> + {t('detailPanel.operation.back', { ns: 'plugin' })} + </div> + )} + <div className="flex items-center gap-1"> + <Icon size="tiny" className="h-6 w-6" src={collection.icon} /> + <OrgInfo + packageNameClassName="w-auto" + orgName={collection.author} + packageName={collection.name.split('/').pop() || ''} + /> + </div> + <div className="mt-1 system-md-semibold text-text-primary">{currTool?.label[language]}</div> + {!!currTool?.description[language] && ( + <Description className="mt-3 mb-2 h-auto" text={currTool.description[language]} descriptionLineRows={2}></Description> + )} + { + collection.allow_delete && collection.type === CollectionType.builtIn && ( + <PluginAuthInAgent + pluginPayload={{ + provider: collection.name, + category: AuthCategory.tool, + providerType: collection.type, + detail: collection as any, + }} + credentialId={credentialId} + onAuthorizationItemClick={onAuthorizationItemClick} + /> + ) + } + </div> + {/* form */} + <div className="h-full"> + <div className="flex h-full flex-col"> + {(hasSetting && !readonly) + ? ( + <TabSlider + className="mt-1 shrink-0 px-4" + itemClassName="py-3" + noBorderBottom + value={currType} + onChange={(value) => { + setCurrType(value) + }} + options={[ + { value: 'info', text: t('setBuiltInTools.parameters', { ns: 'tools' })! }, + { value: 'setting', text: t('setBuiltInTools.setting', { ns: 'tools' })! }, + ]} + /> + ) + : ( + <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">{t('setBuiltInTools.parameters', { ns: 'tools' })}</div> + )} + <div className="h-0 grow overflow-y-auto px-4"> + {isInfoActive ? infoUI : settingUI} + {!readonly && !isInfoActive && ( + <div className="flex shrink-0 justify-end space-x-2 rounded-b-[10px] bg-components-panel-bg py-2"> + <Button className="flex h-8 items-center px-3! text-[13px]! font-medium" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button className="flex h-8 items-center px-3! text-[13px]! font-medium" variant="primary" disabled={!isValid} onClick={() => onSave?.(tempSetting)}>{t('operation.save', { ns: 'common' })}</Button> + </div> + )} + </div> + <ReadmeEntrance pluginDetail={collection as any} className="mt-auto" /> + </div> + </div> + </> + )} + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> ) } diff --git a/web/app/components/app/configuration/configuration-view.tsx b/web/app/components/app/configuration/configuration-view.tsx index 1e2b0bf81a..04cb3ffeda 100644 --- a/web/app/components/app/configuration/configuration-view.tsx +++ b/web/app/components/app/configuration/configuration-view.tsx @@ -12,6 +12,15 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import * as React from 'react' import { useTranslation } from 'react-i18next' import AppPublisher from '@/app/components/app/app-publisher/features-wrapper' @@ -21,7 +30,6 @@ import AgentSettingButton from '@/app/components/app/configuration/config/agent- import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset' import Debug from '@/app/components/app/configuration/debug' import Divider from '@/app/components/base/divider' -import Drawer from '@/app/components/base/drawer' import { FeaturesProvider } from '@/app/components/base/features' import NewFeaturePanel from '@/app/components/base/features/new-feature-panel' import Loading from '@/app/components/base/loading' @@ -192,19 +200,43 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({ )} {isMobile && ( - <Drawer showClose isOpen={isShowDebugPanel} onClose={onHideDebugPanel} mask footer={null}> - <Debug - isAPIKeySet={contextValue.isAPIKeySet} - onSetting={onOpenAccountSettings} - inputs={contextValue.inputs} - modelParameterParams={{ - setModel: onModelChange, - onCompletionParamsChange, - }} - debugWithMultipleModel={!!debugWithMultipleModel} - multipleModelConfigs={multipleModelConfigs} - onMultipleModelConfigsChange={onMultipleModelConfigsChange} - /> + <Drawer + open={isShowDebugPanel} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHideDebugPanel() + }} + > + <DrawerPortal> + <DrawerBackdrop className="bg-black/30" /> + <DrawerViewport> + <DrawerPopup className="data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-sm"> + <DrawerContent className="flex min-h-0 flex-1 flex-col"> + <div className="mb-4 flex shrink-0 justify-end"> + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + data-testid="close-icon" + /> + </div> + <Debug + isAPIKeySet={contextValue.isAPIKeySet} + onSetting={onOpenAccountSettings} + inputs={contextValue.inputs} + modelParameterParams={{ + setModel: onModelChange, + onCompletionParamsChange, + }} + debugWithMultipleModel={!!debugWithMultipleModel} + multipleModelConfigs={multipleModelConfigs} + onMultipleModelConfigsChange={onMultipleModelConfigsChange} + /> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> )} diff --git a/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx index 1d14f7dbd2..cffed4b846 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx @@ -230,8 +230,12 @@ describe('dataset-config/card-item', () => { expect(screen.getByText('Mock settings modal'))!.toBeInTheDocument() const overlay = [...document.querySelectorAll('[class]')] - .find(element => element.className.toString().includes('bg-black/30')) + .find(element => + element instanceof HTMLElement + && element.classList.contains('bg-background-overlay') + && !element.classList.contains('bg-transparent'), + ) - expect(overlay)!.toBeInTheDocument() + expect(overlay).toBeInTheDocument() }) }) diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.tsx index 8eb856feb3..62dd17f4b1 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/index.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/index.tsx @@ -2,6 +2,14 @@ import type { FC } from 'react' import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { RiDeleteBinLine, RiEditLine, @@ -12,7 +20,6 @@ import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' -import Drawer from '@/app/components/base/drawer' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { useKnowledge } from '@/hooks/use-knowledge' import SettingsModal from '../settings-modal' @@ -112,14 +119,31 @@ const Item: FC<ItemProps> = ({ /> ) } - <Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName="mt-16 mx-2 sm:mr-2 mb-3 p-0! max-w-[640px]! rounded-xl"> - {showSettingsModal && ( - <SettingsModal - currentDataset={config} - onCancel={() => setShowSettingsModal(false)} - onSave={handleSave} - /> - )} + <Drawer + open={showSettingsModal} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + setShowSettingsModal(false) + }} + > + <DrawerPortal> + <DrawerBackdrop className={cn(!isMobile && 'bg-transparent')} /> + <DrawerViewport> + <DrawerPopup className="p-0! data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[640px] data-[swipe-direction=right]:rounded-xl"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + {showSettingsModal && ( + <SettingsModal + currentDataset={config} + onCancel={() => setShowSettingsModal(false)} + onSave={handleSave} + /> + )} + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> </div> ) diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index fe589b599a..dbd350f16b 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -84,19 +84,6 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/app/components/base/drawer', () => ({ - default: ({ children, isOpen, onClose }: { children: ReactNode, isOpen: boolean, onClose: () => void }) => ( - isOpen - ? ( - <div data-testid="drawer"> - <button onClick={onClose}>close-drawer</button> - {children} - </div> - ) - : null - ), -})) - vi.mock('@/app/components/base/loading', () => ({ default: () => <div>loading</div>, })) @@ -283,7 +270,7 @@ describe('ConversationList', () => { await waitFor(() => { expect(onUrlUpdate).toHaveBeenCalled() - expect(screen.getByTestId('drawer')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) const update = onUrlUpdate.mock.calls.at(-1)![0] @@ -293,11 +280,26 @@ describe('ConversationList', () => { }) it('should close the drawer, refresh, and clear modal flags', async () => { + mockChatConversationDetail = { + id: 'conversation-1', + created_at: 1710000000, + model_config: { + model: 'gpt-4o', + configs: { + introduction: 'Hello there', + }, + user_input_form: [], + }, + message: { + inputs: {}, + }, + } + const { onUrlUpdate } = renderConversationList({ searchParams: '?page=2&conversation_id=conversation-1', }) - fireEvent.click(screen.getByText('close-drawer')) + fireEvent.click(await screen.findByRole('button', { name: 'operation.close' })) expect(mockOnRefresh).toHaveBeenCalledTimes(1) expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 1633d53ccc..2e078b7b93 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -9,6 +9,14 @@ import { HandThumbUpIcon, } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiEditFill } from '@remixicon/react' @@ -28,7 +36,6 @@ import TextGeneration from '@/app/components/app/text-generate/item' import ActionButton from '@/app/components/base/action-button' import Chat from '@/app/components/base/chat/chat' import CopyIcon from '@/app/components/base/copy-icon' -import Drawer from '@/app/components/base/drawer' import Loading from '@/app/components/base/loading' import MessageLogModal from '@/app/components/base/message-log-modal' import { WorkflowContextProvider } from '@/app/components/workflow/context' @@ -429,7 +436,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { <div className="flex grow flex-wrap items-center justify-end gap-y-1"> {!isAdvanced && <ModelInfo model={detail.model_config.model} />} </div> - <ActionButton size="l" onClick={onClose}> + <ActionButton size="l" aria-label={t('operation.close', { ns: 'common' })} onClick={onClose}> <RiCloseLine className="h-4 w-4 text-text-tertiary" /> </ActionButton> </div> @@ -872,21 +879,32 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) </tbody> </table> <Drawer - isOpen={showDrawer} - onClose={onCloseDrawer} - mask={isMobile} - footer={null} - panelClassName="mt-16 mx-2 sm:mr-2 mb-4 p-0! max-w-[640px]! rounded-xl bg-components-panel-bg" - > - <DrawerContext.Provider value={{ - onClose: onCloseDrawer, - appDetail, + open={showDrawer} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onCloseDrawer() }} - > - {isChatMode - ? <ChatConversationDetailComp appId={appDetail.id} conversationId={currentConversation?.id} /> - : <CompletionConversationDetailComp appId={appDetail.id} conversationId={currentConversation?.id} />} - </DrawerContext.Provider> + > + <DrawerPortal> + <DrawerBackdrop className={cn(!isMobile && 'bg-transparent')} /> + <DrawerViewport> + <DrawerPopup className="bg-components-panel-bg p-0! data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-4 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[640px] data-[swipe-direction=right]:rounded-xl"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <DrawerContext.Provider value={{ + onClose: onCloseDrawer, + appDetail, + }} + > + {isChatMode + ? <ChatConversationDetailComp appId={appDetail.id} conversationId={currentConversation?.id} /> + : <CompletionConversationDetailComp appId={appDetail.id} conversationId={currentConversation?.id} />} + </DrawerContext.Provider> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> </div> ) diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index e514962d9b..78d9a329e6 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -4,10 +4,17 @@ import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunTriggeredFr import type { App } from '@/types/app' import { ArrowDownIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import Drawer from '@/app/components/base/drawer' import Loading from '@/app/components/base/loading' import Indicator from '@/app/components/header/indicator' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -183,17 +190,28 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => { </tbody> </table> <Drawer - isOpen={showDrawer} - onClose={onCloseDrawer} - mask={isMobile} - footer={null} - panelClassName="mt-16 mx-2 sm:mr-2 mb-3 p-0! max-w-[600px]! rounded-xl border border-components-panel-border" + open={showDrawer} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onCloseDrawer() + }} > - <DetailPanel - onClose={onCloseDrawer} - runID={currentLog?.workflow_run.id || ''} - canReplay={currentLog?.workflow_run.triggered_from === 'app-run' || currentLog?.workflow_run.triggered_from === 'debugging'} - /> + <DrawerPortal> + <DrawerBackdrop className={cn(!isMobile && 'bg-transparent')} /> + <DrawerViewport> + <DrawerPopup className="p-0! data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[600px] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border data-[swipe-direction=right]:border-components-panel-border"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <DetailPanel + onClose={onCloseDrawer} + runID={currentLog?.workflow_run.id || ''} + canReplay={currentLog?.workflow_run.triggered_from === 'app-run' || currentLog?.workflow_run.triggered_from === 'debugging'} + /> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> </div> ) diff --git a/web/app/components/base/drawer/__tests__/index.spec.tsx b/web/app/components/base/drawer/__tests__/index.spec.tsx deleted file mode 100644 index 1f9c1c258f..0000000000 --- a/web/app/components/base/drawer/__tests__/index.spec.tsx +++ /dev/null @@ -1,676 +0,0 @@ -import type { IDrawerProps } from '../index' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import Drawer from '../index' - -// Capture dialog onClose for testing -let capturedDialogOnClose: (() => void) | null = null - -// Mock Base UI Dialog anatomy; behavior is covered at the legacy wrapper boundary here. -vi.mock('@base-ui/react/dialog', () => ({ - Dialog: { - Root: ({ children, open, onOpenChange }: { - children: React.ReactNode - open: boolean - onOpenChange: (open: boolean) => void - }) => { - capturedDialogOnClose = () => onOpenChange(false) - if (!open) - return null - return <>{children}</> - }, - Portal: ({ children }: { - children: React.ReactNode - }) => <>{children}</>, - Backdrop: ({ children, className }: { - children?: React.ReactNode - className: string - }) => ( - <div - data-testid="dialog-backdrop" - className={className} - onClick={() => capturedDialogOnClose?.()} - > - {children} - </div> - ), - Popup: ({ children, className, ...props }: { - children: React.ReactNode - className: string - }) => ( - <div - data-testid="dialog" - className={className} - role="dialog" - {...props} - > - {children} - </div> - ), - Title: ({ children, className, render, ...props }: { - children: React.ReactNode - className?: string - render?: React.ReactElement - }) => { - const Component = render?.type ?? 'h2' - return ( - <Component data-testid="dialog-title" className={className} {...props}> - {children} - </Component> - ) - }, - }, -})) - -// Mock XMarkIcon -vi.mock('@heroicons/react/24/outline', () => ({ - XMarkIcon: ({ className, onClick }: { className: string, onClick?: () => void }) => ( - <svg data-testid="close-icon" className={className} onClick={onClick} /> - ), -})) - -// Helper function to render Drawer with default props -const defaultProps: IDrawerProps = { - isOpen: true, - onClose: vi.fn(), - children: <div data-testid="drawer-content">Content</div>, -} - -const renderDrawer = (props: Partial<IDrawerProps> = {}) => { - const mergedProps = { ...defaultProps, ...props } - return render(<Drawer {...mergedProps} />) -} - -describe('Drawer', () => { - beforeEach(() => { - vi.clearAllMocks() - capturedDialogOnClose = null - }) - - // Basic rendering tests - describe('Rendering', () => { - it('should render when isOpen is true', () => { - // Arrange & Act - renderDrawer({ isOpen: true }) - - // Assert - expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByTestId('drawer-content')).toBeInTheDocument() - }) - - it('should not render when isOpen is false', () => { - // Arrange & Act - renderDrawer({ isOpen: false }) - - // Assert - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }) - - it('should render children content', () => { - // Arrange - const childContent = <p data-testid="custom-child">Custom Content</p> - - // Act - renderDrawer({ children: childContent }) - - // Assert - expect(screen.getByTestId('custom-child')).toBeInTheDocument() - expect(screen.getByText('Custom Content')).toBeInTheDocument() - }) - }) - - // Title and description tests - describe('Title and Description', () => { - it('should render title when provided', () => { - // Arrange & Act - renderDrawer({ title: 'Test Title' }) - - // Assert - expect(screen.getByText('Test Title')).toBeInTheDocument() - }) - - it('should not render title when not provided', () => { - // Arrange & Act - renderDrawer({ title: '' }) - - // Assert - const titles = screen.queryAllByTestId('dialog-title') - const titleWithText = titles.find(el => el.textContent !== '') - expect(titleWithText).toBeUndefined() - }) - - it('should render description when provided', () => { - // Arrange & Act - renderDrawer({ description: 'Test Description' }) - - // Assert - expect(screen.getByText('Test Description')).toBeInTheDocument() - }) - - it('should not render description when not provided', () => { - // Arrange & Act - renderDrawer({ description: '' }) - - // Assert - expect(screen.queryByText('Test Description')).not.toBeInTheDocument() - }) - - it('should render both title and description together', () => { - // Arrange & Act - renderDrawer({ - title: 'My Title', - description: 'My Description', - }) - - // Assert - expect(screen.getByText('My Title')).toBeInTheDocument() - expect(screen.getByText('My Description')).toBeInTheDocument() - }) - }) - - // Close button tests - describe('Close Button', () => { - it('should render close icon when showClose is true', () => { - // Arrange & Act - renderDrawer({ showClose: true }) - - // Assert - expect(screen.getByTestId('close-icon')).toBeInTheDocument() - }) - - it('should not render close icon when showClose is false', () => { - // Arrange & Act - renderDrawer({ showClose: false }) - - // Assert - expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument() - }) - - it('should not render close icon by default', () => { - // Arrange & Act - renderDrawer({}) - - // Assert - expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument() - }) - - it('should call onClose when close icon is clicked', () => { - // Arrange - const onClose = vi.fn() - renderDrawer({ showClose: true, onClose }) - - // Act - fireEvent.click(screen.getByTestId('close-icon')) - - // Assert - expect(onClose).toHaveBeenCalledTimes(1) - }) - }) - - // Backdrop/Mask tests - describe('Backdrop and Mask', () => { - it('should render backdrop when noOverlay is false', () => { - // Arrange & Act - renderDrawer({ noOverlay: false }) - - // Assert - expect(screen.getByTestId('dialog-backdrop')).toBeInTheDocument() - }) - - it('should not render backdrop when noOverlay is true', () => { - // Arrange & Act - renderDrawer({ noOverlay: true }) - - // Assert - expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument() - }) - - it('should apply mask background when mask is true', () => { - // Arrange & Act - renderDrawer({ mask: true }) - - // Assert - const backdrop = screen.getByTestId('dialog-backdrop') - expect(backdrop.className).toContain('bg-black/30') - }) - - it('should not apply mask background when mask is false', () => { - // Arrange & Act - renderDrawer({ mask: false }) - - // Assert - const backdrop = screen.getByTestId('dialog-backdrop') - expect(backdrop.className).not.toContain('bg-black/30') - }) - - it('should call onClose when backdrop is clicked and clickOutsideNotOpen is false', () => { - // Arrange - const onClose = vi.fn() - renderDrawer({ onClose, clickOutsideNotOpen: false }) - - // Act - fireEvent.click(screen.getByTestId('dialog-backdrop')) - - // Assert - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should not call onClose when backdrop is clicked and clickOutsideNotOpen is true', () => { - // Arrange - const onClose = vi.fn() - renderDrawer({ onClose, clickOutsideNotOpen: true }) - - // Act - fireEvent.click(screen.getByTestId('dialog-backdrop')) - - // Assert - expect(onClose).not.toHaveBeenCalled() - }) - }) - - // Footer tests - describe('Footer', () => { - it('should render default footer with cancel and save buttons when footer is undefined', () => { - // Arrange & Act - renderDrawer({ footer: undefined }) - - // Assert - expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() - expect(screen.getByText('common.operation.save')).toBeInTheDocument() - }) - - it('should not render footer when footer is null', () => { - // Arrange & Act - renderDrawer({ footer: null }) - - // Assert - expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument() - expect(screen.queryByText('common.operation.save')).not.toBeInTheDocument() - }) - - it('should render custom footer when provided', () => { - // Arrange - const customFooter = <div data-testid="custom-footer">Custom Footer</div> - - // Act - renderDrawer({ footer: customFooter }) - - // Assert - expect(screen.getByTestId('custom-footer')).toBeInTheDocument() - expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument() - }) - - it('should call onCancel when cancel button is clicked', () => { - // Arrange - const onCancel = vi.fn() - renderDrawer({ onCancel }) - - // Act - const cancelButton = screen.getByText('common.operation.cancel') - fireEvent.click(cancelButton) - - // Assert - expect(onCancel).toHaveBeenCalledTimes(1) - }) - - it('should call onOk when save button is clicked', () => { - // Arrange - const onOk = vi.fn() - renderDrawer({ onOk }) - - // Act - const saveButton = screen.getByText('common.operation.save') - fireEvent.click(saveButton) - - // Assert - expect(onOk).toHaveBeenCalledTimes(1) - }) - - it('should not throw when onCancel is not provided and cancel is clicked', () => { - // Arrange - renderDrawer({ onCancel: undefined }) - - // Act & Assert - expect(() => { - fireEvent.click(screen.getByText('common.operation.cancel')) - }).not.toThrow() - }) - - it('should not throw when onOk is not provided and save is clicked', () => { - // Arrange - renderDrawer({ onOk: undefined }) - - // Act & Assert - expect(() => { - fireEvent.click(screen.getByText('common.operation.save')) - }).not.toThrow() - }) - }) - - // Custom className tests - describe('Custom ClassNames', () => { - it('should apply custom dialogClassName', () => { - // Arrange & Act - const { container } = renderDrawer({ dialogClassName: 'custom-dialog-class' }) - - // Assert - expect(container.querySelector('.custom-dialog-class')).toBeInTheDocument() - }) - - it('should apply custom dialogBackdropClassName', () => { - // Arrange & Act - renderDrawer({ dialogBackdropClassName: 'custom-backdrop-class' }) - - // Assert - expect(screen.getByTestId('dialog-backdrop').className).toContain('custom-backdrop-class') - }) - - it('should apply custom containerClassName', () => { - // Arrange & Act - const { container } = renderDrawer({ containerClassName: 'custom-container-class' }) - - // Assert - const containerDiv = container.querySelector('.custom-container-class') - expect(containerDiv).toBeInTheDocument() - }) - - it('should apply custom panelClassName', () => { - // Arrange & Act - const { container } = renderDrawer({ panelClassName: 'custom-panel-class' }) - - // Assert - const panelDiv = container.querySelector('.custom-panel-class') - expect(panelDiv).toBeInTheDocument() - }) - }) - - // Position tests - describe('Position', () => { - it('should apply center position class when positionCenter is true', () => { - // Arrange & Act - const { container } = renderDrawer({ positionCenter: true }) - - // Assert - const containerDiv = container.querySelector('.justify-center\\!') - expect(containerDiv).toBeInTheDocument() - }) - - it('should use end position by default when positionCenter is false', () => { - // Arrange & Act - const { container } = renderDrawer({ positionCenter: false }) - - // Assert - const containerDiv = container.querySelector('.justify-end') - expect(containerDiv).toBeInTheDocument() - }) - }) - - // Unmount prop tests - describe('Unmount Prop', () => { - it('should pass unmount prop to Dialog component', () => { - // Arrange & Act - renderDrawer({ unmount: true }) - - // Assert - expect(screen.getByTestId('dialog').getAttribute('data-unmount')).toBe('true') - }) - - it('should default unmount to false', () => { - // Arrange & Act - renderDrawer({}) - - // Assert - expect(screen.getByTestId('dialog').getAttribute('data-unmount')).toBe('false') - }) - }) - - // Edge cases - describe('Edge Cases', () => { - it('should handle empty string title', () => { - // Arrange & Act - renderDrawer({ title: '' }) - - // Assert - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should handle empty string description', () => { - // Arrange & Act - renderDrawer({ description: '' }) - - // Assert - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should handle special characters in title', () => { - // Arrange - const specialTitle = '<script>alert("xss")</script>' - - // Act - renderDrawer({ title: specialTitle }) - - // Assert - expect(screen.getByText(specialTitle)).toBeInTheDocument() - }) - - it('should handle very long title', () => { - // Arrange - const longTitle = 'A'.repeat(500) - - // Act - renderDrawer({ title: longTitle }) - - // Assert - expect(screen.getByText(longTitle)).toBeInTheDocument() - }) - - it('should handle complex children with multiple elements', () => { - // Arrange - const complexChildren = ( - <div data-testid="complex-children"> - <h1>Heading</h1> - <p>Paragraph</p> - <input data-testid="input-element" /> - <button data-testid="button-element">Button</button> - </div> - ) - - // Act - renderDrawer({ children: complexChildren }) - - // Assert - expect(screen.getByTestId('complex-children')).toBeInTheDocument() - expect(screen.getByText('Heading')).toBeInTheDocument() - expect(screen.getByText('Paragraph')).toBeInTheDocument() - expect(screen.getByTestId('input-element')).toBeInTheDocument() - expect(screen.getByTestId('button-element')).toBeInTheDocument() - }) - - it('should handle null children gracefully', () => { - // Arrange & Act - renderDrawer({ children: null as unknown as React.ReactNode }) - - // Assert - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should handle undefined footer without crashing', () => { - // Arrange & Act - renderDrawer({ footer: undefined }) - - // Assert - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should handle rapid open/close toggles', () => { - // Arrange - const onClose = vi.fn() - const { rerender } = render( - <Drawer {...defaultProps} isOpen={true} onClose={onClose}> - <div>Content</div> - </Drawer>, - ) - - // Act - Toggle multiple times - rerender( - <Drawer {...defaultProps} isOpen={false} onClose={onClose}> - <div>Content</div> - </Drawer>, - ) - rerender( - <Drawer {...defaultProps} isOpen={true} onClose={onClose}> - <div>Content</div> - </Drawer>, - ) - rerender( - <Drawer {...defaultProps} isOpen={false} onClose={onClose}> - <div>Content</div> - </Drawer>, - ) - - // Assert - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }) - }) - - // Combined prop scenarios - describe('Combined Prop Scenarios', () => { - it('should render with all optional props', () => { - // Arrange & Act - renderDrawer({ - title: 'Full Feature Title', - description: 'Full Feature Description', - dialogClassName: 'custom-dialog', - dialogBackdropClassName: 'custom-backdrop', - containerClassName: 'custom-container', - panelClassName: 'custom-panel', - showClose: true, - mask: true, - positionCenter: true, - unmount: true, - noOverlay: false, - footer: <div data-testid="custom-full-footer">Footer</div>, - }) - - // Assert - expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByText('Full Feature Title')).toBeInTheDocument() - expect(screen.getByText('Full Feature Description')).toBeInTheDocument() - expect(screen.getByTestId('close-icon')).toBeInTheDocument() - expect(screen.getByTestId('custom-full-footer')).toBeInTheDocument() - }) - - it('should render minimal drawer with only required props', () => { - // Arrange - const minimalProps: IDrawerProps = { - isOpen: true, - onClose: vi.fn(), - children: <div>Minimal Content</div>, - } - - // Act - render(<Drawer {...minimalProps} />) - - // Assert - expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByText('Minimal Content')).toBeInTheDocument() - }) - - it('should handle showClose with title simultaneously', () => { - // Arrange & Act - renderDrawer({ - title: 'Title with Close', - showClose: true, - }) - - // Assert - expect(screen.getByText('Title with Close')).toBeInTheDocument() - expect(screen.getByTestId('close-icon')).toBeInTheDocument() - }) - - it('should handle noOverlay with clickOutsideNotOpen', () => { - // Arrange - const onClose = vi.fn() - - // Act - renderDrawer({ - noOverlay: true, - clickOutsideNotOpen: true, - onClose, - }) - - // Assert - backdrop should not exist - expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument() - }) - }) - - // Dialog onClose callback tests (e.g., Escape key) - describe('Dialog onClose Callback', () => { - it('should call onClose when Dialog triggers close and clickOutsideNotOpen is false', () => { - // Arrange - const onClose = vi.fn() - renderDrawer({ onClose, clickOutsideNotOpen: false }) - - // Act - Simulate Dialog's onClose (e.g., pressing Escape) - capturedDialogOnClose?.() - - // Assert - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should not call onClose when Dialog triggers close and clickOutsideNotOpen is true', () => { - // Arrange - const onClose = vi.fn() - renderDrawer({ onClose, clickOutsideNotOpen: true }) - - // Act - Simulate Dialog's onClose (e.g., pressing Escape) - capturedDialogOnClose?.() - - // Assert - expect(onClose).not.toHaveBeenCalled() - }) - - it('should call onClose by default when Dialog triggers close', () => { - // Arrange - const onClose = vi.fn() - renderDrawer({ onClose }) - - // Act - capturedDialogOnClose?.() - - // Assert - expect(onClose).toHaveBeenCalledTimes(1) - }) - }) - - // Event handler interaction tests - describe('Event Handler Interactions', () => { - it('should handle multiple consecutive close icon clicks', () => { - // Arrange - const onClose = vi.fn() - renderDrawer({ showClose: true, onClose }) - - // Act - const closeIcon = screen.getByTestId('close-icon') - fireEvent.click(closeIcon) - fireEvent.click(closeIcon) - fireEvent.click(closeIcon) - - // Assert - expect(onClose).toHaveBeenCalledTimes(3) - }) - - it('should handle onCancel and onOk being the same function', () => { - // Arrange - const handler = vi.fn() - renderDrawer({ onCancel: handler, onOk: handler }) - - // Act - fireEvent.click(screen.getByText('common.operation.cancel')) - fireEvent.click(screen.getByText('common.operation.save')) - - // Assert - expect(handler).toHaveBeenCalledTimes(2) - }) - }) -}) diff --git a/web/app/components/base/drawer/index.stories.tsx b/web/app/components/base/drawer/index.stories.tsx deleted file mode 100644 index 57ab35281c..0000000000 --- a/web/app/components/base/drawer/index.stories.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useState } from 'react' -import { fn } from 'storybook/test' -import Drawer from '.' - -const meta = { - title: 'Base/Feedback/Drawer', - component: Drawer, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: 'Sliding panel built on Base UI dialog primitives. Supports optional mask, custom footer, and close behaviour.', - }, - }, - }, - tags: ['autodocs'], -} satisfies Meta<typeof Drawer> - -export default meta -type Story = StoryObj<typeof meta> - -const DrawerDemo = (props: React.ComponentProps<typeof Drawer>) => { - const [open, setOpen] = useState(false) - - return ( - <div className="flex h-[400px] items-center justify-center bg-background-default-subtle"> - <button - type="button" - className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700" - onClick={() => setOpen(true)} - > - Open drawer - </button> - - <Drawer - {...props} - isOpen={open} - onClose={() => setOpen(false)} - title={props.title ?? 'Edit configuration'} - description={props.description ?? 'Adjust settings in the side panel and save.'} - footer={props.footer ?? undefined} - > - <div className="mt-4 space-y-3 text-sm text-text-secondary"> - <p> - This example renders arbitrary content inside the drawer body. Use it for contextual forms, settings, or informational panels. - </p> - <div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs"> - Content area - </div> - </div> - </Drawer> - </div> - ) -} - -export const Playground: Story = { - render: args => <DrawerDemo {...args} />, - args: { - children: null, - isOpen: false, - onClose: fn(), - }, - parameters: { - docs: { - source: { - language: 'tsx', - code: ` -const [open, setOpen] = useState(false) - -<Drawer - isOpen={open} - onClose={() => setOpen(false)} - title="Edit configuration" - description="Adjust settings in the side panel and save." -> - ... -</Drawer> - `.trim(), - }, - }, - }, -} - -export const CustomFooter: Story = { - render: args => ( - <DrawerDemo - {...args} - footer={( - <div className="mt-6 flex justify-end gap-2"> - <button className="rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary" onClick={() => args.onCancel?.()}>Discard</button> - <button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white">Save changes</button> - </div> - )} - /> - ), - args: { - children: null, - isOpen: false, - onClose: fn(), - }, - parameters: { - docs: { - source: { - language: 'tsx', - code: ` -<Drawer footer={<CustomFooter />}> - ... -</Drawer> - `.trim(), - }, - }, - }, -} diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx deleted file mode 100644 index d6c0d58e7c..0000000000 --- a/web/app/components/base/drawer/index.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client' -// eslint-disable-next-line no-restricted-imports -- Temporary legacy drawer exception: remove this direct Base UI wrapper after callers migrate to dify-ui drawer primitives. -import { Dialog as BaseDialog } from '@base-ui/react/dialog' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { useTranslation } from 'react-i18next' - -export type IDrawerProps = { - title?: string - description?: string - dialogClassName?: string - dialogBackdropClassName?: string - containerClassName?: string - panelClassName?: string - children: React.ReactNode - footer?: React.ReactNode - mask?: boolean - positionCenter?: boolean - isOpen: boolean - showClose?: boolean - clickOutsideNotOpen?: boolean - onClose: () => void - onCancel?: () => void - onOk?: () => void - unmount?: boolean - noOverlay?: boolean -} - -export default function Drawer({ - title = '', - description = '', - dialogClassName = '', - dialogBackdropClassName = '', - containerClassName = '', - panelClassName = '', - children, - footer, - mask = true, - positionCenter, - showClose = false, - isOpen, - clickOutsideNotOpen, - onClose, - onCancel, - onOk, - unmount = false, - noOverlay = false, -}: IDrawerProps) { - const { t } = useTranslation() - return ( - <BaseDialog.Root - open={isOpen} - disablePointerDismissal={clickOutsideNotOpen} - onOpenChange={(open) => { - if (!open && !clickOutsideNotOpen) - onClose() - }} - > - <BaseDialog.Portal> - <div className={cn('fixed inset-0 z-30 overflow-y-auto', dialogClassName)}> - <div className={cn('flex h-screen w-screen justify-end', positionCenter && 'justify-center!', containerClassName)}> - {!noOverlay && ( - <BaseDialog.Backdrop - className={cn('fixed inset-0 z-40', mask && 'bg-black/30', dialogBackdropClassName)} - /> - )} - <BaseDialog.Popup - data-unmount={unmount} - className={cn('relative z-50 flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassName)} - > - <> - <div className="flex justify-between"> - {title && ( - <BaseDialog.Title - render={<h3 />} - className="text-lg leading-6 font-medium text-text-primary" - > - {title} - </BaseDialog.Title> - )} - {showClose && ( - <div className="mb-4 flex cursor-pointer items-center"> - <span - className="i-heroicons-x-mark h-4 w-4 text-text-tertiary" - onClick={onClose} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') - onClose() - }} - role="button" - tabIndex={0} - aria-label={t('operation.close', { ns: 'common' })} - data-testid="close-icon" - /> - </div> - )} - </div> - {description && <div className="mt-2 text-xs font-normal text-text-tertiary">{description}</div>} - {children} - </> - {footer || (footer === null - ? null - : ( - <div className="mt-10 flex flex-row justify-end"> - <Button - className="mr-2" - onClick={() => { - onCancel?.() - }} - > - {t('operation.cancel', { ns: 'common' })} - </Button> - <Button - onClick={() => { - onOk?.() - }} - > - {t('operation.save', { ns: 'common' })} - </Button> - </div> - ))} - </BaseDialog.Popup> - </div> - </div> - </BaseDialog.Portal> - </BaseDialog.Root> - ) -} diff --git a/web/app/components/base/float-right-container/__tests__/index.spec.tsx b/web/app/components/base/float-right-container/__tests__/index.spec.tsx index 236a30dd20..4466b2cadc 100644 --- a/web/app/components/base/float-right-container/__tests__/index.spec.tsx +++ b/web/app/components/base/float-right-container/__tests__/index.spec.tsx @@ -32,7 +32,6 @@ describe('FloatRightContainer', () => { isMobile={true} isOpen={false} onClose={vi.fn()} - unmount={true} > <div>Closed mobile content</div> </FloatRightContainer>, @@ -99,53 +98,12 @@ describe('FloatRightContainer', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('should call onClose when close is done using escape key', async () => { - const onClose = vi.fn() - render( - <FloatRightContainer - isMobile={true} - isOpen={true} - onClose={onClose} - showClose={true} - > - <div>Closable content</div> - </FloatRightContainer>, - ) - - const closeIcon = screen.getByTestId('close-icon') - closeIcon.focus() - await userEvent.keyboard('{Enter}') - - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should call onClose when close is done using space key', async () => { - const onClose = vi.fn() - render( - <FloatRightContainer - isMobile={true} - isOpen={true} - onClose={onClose} - showClose={true} - > - <div>Closable content</div> - </FloatRightContainer>, - ) - - const closeIcon = screen.getByTestId('close-icon') - closeIcon.focus() - await userEvent.keyboard(' ') - - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should apply drawer className props in mobile drawer mode', async () => { + it('should apply panel className in mobile drawer mode', async () => { render( <FloatRightContainer isMobile={true} isOpen={true} onClose={vi.fn()} - dialogClassName="custom-dialog-class" panelClassName="custom-panel-class" > <div>Class forwarding content</div> @@ -153,7 +111,6 @@ describe('FloatRightContainer', () => { ) const dialog = await screen.findByRole('dialog') - expect(document.querySelector('.custom-dialog-class')).toBeInTheDocument() const panel = document.querySelector('.custom-panel-class') expect(panel).toBeInTheDocument() diff --git a/web/app/components/base/float-right-container/index.stories.tsx b/web/app/components/base/float-right-container/index.stories.tsx index 5887afd1e3..ec26bb7be0 100644 --- a/web/app/components/base/float-right-container/index.stories.tsx +++ b/web/app/components/base/float-right-container/index.stories.tsx @@ -49,7 +49,6 @@ const ContainerDemo = () => { isOpen={open} onClose={() => setOpen(false)} title="Responsive panel" - description="Switch the toggle to see drawer vs inline behaviour." mask > <div className="rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary"> diff --git a/web/app/components/base/float-right-container/index.tsx b/web/app/components/base/float-right-container/index.tsx index 7435fd9643..db3b73da95 100644 --- a/web/app/components/base/float-right-container/index.tsx +++ b/web/app/components/base/float-right-container/index.tsx @@ -1,17 +1,79 @@ 'use client' -import type { IDrawerProps } from '@/app/components/base/drawer' -import Drawer from '@/app/components/base/drawer' +import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' +import { useTranslation } from 'react-i18next' type IFloatRightContainerProps = { isMobile: boolean + isOpen: boolean + onClose: () => void children?: React.ReactNode -} & IDrawerProps + showClose?: boolean + panelClassName?: string + title?: string + mask?: boolean +} + +const FloatRightContainer = ({ + isMobile, + children, + isOpen, + onClose, + showClose = false, + panelClassName, + title, + mask = true, +}: IFloatRightContainerProps) => { + const { t } = useTranslation() -const FloatRightContainer = ({ isMobile, children, isOpen, ...drawerProps }: IFloatRightContainerProps) => { return ( <> {isMobile && ( - <Drawer isOpen={isOpen} {...drawerProps}>{children}</Drawer> + <Drawer + open={isOpen} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onClose() + }} + > + <DrawerPortal> + <DrawerBackdrop className={cn(!mask && 'bg-transparent')} /> + <DrawerViewport> + <DrawerPopup className={cn('data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-sm', panelClassName)}> + <DrawerContent className="flex min-h-0 flex-1 flex-col"> + {(title || showClose) && ( + <div className="mb-4 flex shrink-0 items-center justify-between"> + {title && ( + <DrawerTitle className="text-lg leading-6 font-medium text-text-primary"> + {title} + </DrawerTitle> + )} + {showClose && ( + <DrawerCloseButton + aria-label={t('operation.close', { ns: 'common' })} + className="h-6 w-6 rounded-md" + data-testid="close-icon" + /> + )} + </div> + )} + {children} + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> + </Drawer> )} {(!isMobile && isOpen) && ( <>{children}</> diff --git a/web/app/components/datasets/create/step-two/components/preview-panel.tsx b/web/app/components/datasets/create/step-two/components/preview-panel.tsx index 254a28619c..9f10863288 100644 --- a/web/app/components/datasets/create/step-two/components/preview-panel.tsx +++ b/web/app/components/datasets/create/step-two/components/preview-panel.tsx @@ -54,7 +54,7 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({ const { t } = useTranslation() return ( - <FloatRightContainer isMobile={isMobile} isOpen={true} onClose={noop} footer={null}> + <FloatRightContainer isMobile={isMobile} isOpen={true} onClose={noop}> <PreviewContainer header={( <PreviewHeader title={t('stepTwo.preview', { ns: 'datasetCreation' })}> diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index caae703f6b..43fc99851d 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -292,7 +292,7 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { )} </div> )} - <FloatRightContainer showClose isOpen={showMetadata} onClose={() => setShowMetadata(false)} isMobile={isMobile} panelClassName="justify-start!" footer={null}> + <FloatRightContainer showClose isOpen={showMetadata} onClose={() => setShowMetadata(false)} isMobile={isMobile} panelClassName="justify-start!"> <Metadata className="mt-3 mr-2" datasetId={datasetId} diff --git a/web/app/components/datasets/hit-testing/index.tsx b/web/app/components/datasets/hit-testing/index.tsx index ec9100bb97..7c1ce62860 100644 --- a/web/app/components/datasets/hit-testing/index.tsx +++ b/web/app/components/datasets/hit-testing/index.tsx @@ -10,12 +10,19 @@ import type { } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' -import Drawer from '@/app/components/base/drawer' import FloatRightContainer from '@/app/components/base/float-right-container' import Loading from '@/app/components/base/loading' import Pagination from '@/app/components/base/pagination' @@ -158,7 +165,6 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { isMobile={isMobile} isOpen={isShowRightPanel} onClose={hideRightPanel} - footer={null} > <div className="flex min-w-0 flex-1 flex-col pt-3"> {isRetrievalLoading @@ -181,23 +187,33 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { </div> </FloatRightContainer> <Drawer - unmount={true} - isOpen={isShowModifyRetrievalModal} - onClose={() => setIsShowModifyRetrievalModal(false)} - footer={null} - mask={isMobile} - panelClassName="mt-16 mx-2 sm:mr-2 mb-3 p-0! max-w-[640px]! rounded-xl" - > - <ModifyRetrievalModal - indexMethod={currentDataset?.indexing_technique || ''} - value={retrievalConfig} - isShow={isShowModifyRetrievalModal} - onHide={() => setIsShowModifyRetrievalModal(false)} - onSave={(value) => { - setRetrievalConfig(value) + open={isShowModifyRetrievalModal} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) setIsShowModifyRetrievalModal(false) - }} - /> + }} + > + <DrawerPortal> + <DrawerBackdrop className={cn(!isMobile && 'bg-transparent')} /> + <DrawerViewport> + <DrawerPopup className="p-0! data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[640px] data-[swipe-direction=right]:rounded-xl"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <ModifyRetrievalModal + indexMethod={currentDataset?.indexing_technique || ''} + value={retrievalConfig} + isShow={isShowModifyRetrievalModal} + onHide={() => setIsShowModifyRetrievalModal(false)} + onSave={(value) => { + setRetrievalConfig(value) + setIsShowModifyRetrievalModal(false) + }} + /> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> </div> ) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx index 0fe0fff6df..5bac8c827b 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx @@ -4,12 +4,19 @@ import type { FormSchema } from '../../base/form/types' import type { PluginDetail } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightUpLine, RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import Drawer from '@/app/components/base/drawer' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import { useRenderI18nObject } from '@/hooks/use-i18n' import { ReadmeEntrance } from '../readme-panel/entrance' @@ -75,60 +82,67 @@ const EndpointModal: FC<Props> = ({ return ( <Drawer - isOpen - clickOutsideNotOpen={false} - onClose={onCancel} - footer={null} - mask - positionCenter={false} - panelClassName={cn('mt-[64px] mr-2 mb-2 w-[420px]! max-w-[420px]! justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg! p-0! shadow-xl')} + open + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onCancel() + }} > - <> - <div className="p-4 pb-2"> - <div className="flex items-center justify-between"> - <div className="system-xl-semibold text-text-primary">{t('detailPanel.endpointModalTitle', { ns: 'plugin' })}</div> - <ActionButton onClick={onCancel}> - <RiCloseLine className="h-4 w-4" /> - </ActionButton> - </div> - <div className="mt-0.5 system-xs-regular text-text-tertiary">{t('detailPanel.endpointModalDesc', { ns: 'plugin' })}</div> - <ReadmeEntrance pluginDetail={pluginDetail} className="px-0 pt-3" /> - </div> - <div className="grow overflow-y-auto"> - <div className="px-4 py-2"> - <Form - value={tempCredential} - onChange={(v) => { - setTempCredential(v) - }} - formSchemas={formSchemas as any} - isEditMode={true} - showOnVariableMap={{}} - validating={false} - inputClassName="bg-components-input-bg-normal hover:bg-components-input-bg-hover" - fieldMoreInfo={item => item.url - ? ( - <a - href={item.url} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center body-xs-regular text-text-accent-secondary" - > - {t('howToGet', { ns: 'tools' })} - <RiArrowRightUpLine className="ml-1 h-3 w-3" /> - </a> - ) - : null} - /> - </div> - <div className={cn('flex justify-end p-4 pt-0')}> - <div className="flex gap-2"> - <Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button> - </div> - </div> - </div> - </> + <DrawerPortal> + <DrawerBackdrop className="bg-black/30" /> + <DrawerViewport> + <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="p-4 pb-2"> + <div className="flex items-center justify-between"> + <div className="system-xl-semibold text-text-primary">{t('detailPanel.endpointModalTitle', { ns: 'plugin' })}</div> + <ActionButton onClick={onCancel}> + <RiCloseLine className="h-4 w-4" /> + </ActionButton> + </div> + <div className="mt-0.5 system-xs-regular text-text-tertiary">{t('detailPanel.endpointModalDesc', { ns: 'plugin' })}</div> + <ReadmeEntrance pluginDetail={pluginDetail} className="px-0 pt-3" /> + </div> + <div className="grow overflow-y-auto"> + <div className="px-4 py-2"> + <Form + value={tempCredential} + onChange={(v) => { + setTempCredential(v) + }} + formSchemas={formSchemas as any} + isEditMode={true} + showOnVariableMap={{}} + validating={false} + inputClassName="bg-components-input-bg-normal hover:bg-components-input-bg-hover" + fieldMoreInfo={item => item.url + ? ( + <a + href={item.url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center body-xs-regular text-text-accent-secondary" + > + {t('howToGet', { ns: 'tools' })} + <RiArrowRightUpLine className="ml-1 h-3 w-3" /> + </a> + ) + : null} + /> + </div> + <div className={cn('flex justify-end p-4 pt-0')}> + <div className="flex gap-2"> + <Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button> + </div> + </div> + </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> ) } diff --git a/web/app/components/plugins/plugin-detail-panel/index.tsx b/web/app/components/plugins/plugin-detail-panel/index.tsx index 3041d2e2a6..877b15c51e 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/index.tsx @@ -2,8 +2,15 @@ import type { FC } from 'react' import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { useCallback, useEffect } from 'react' -import Drawer from '@/app/components/base/drawer' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { ReadmeEntrance } from '../readme-panel/entrance' import ActionList from './action-list' @@ -53,37 +60,46 @@ const PluginDetailPanel: FC<Props> = ({ return ( <Drawer - isOpen={!!detail} - clickOutsideNotOpen={false} - onClose={onHide} - footer={null} - mask={false} - positionCenter={false} - panelClassName={cn('mt-[64px] mr-2 mb-2 w-[420px]! max-w-[420px]! justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg! p-0! shadow-xl')} + open={!!detail} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} > - {detail && ( - <> - <DetailHeader detail={detail} onUpdate={handleUpdate} onHide={onHide} /> - <div className="grow overflow-y-auto"> - <div className="flex min-h-full flex-col"> - <div className="flex-1"> - {detail.declaration.category === PluginCategoryEnum.trigger && ( - <> - <SubscriptionList pluginDetail={detail} /> - <TriggerEventsList /> - </> - )} - {!!detail.declaration.tool && <ActionList detail={detail} />} - {!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />} - {!!detail.declaration.endpoint && <EndpointList detail={detail} />} - {!!detail.declaration.model && <ModelList detail={detail} />} - {!!detail.declaration.datasource && <DatasourceActionList detail={detail} />} - </div> - <ReadmeEntrance pluginDetail={detail} className="mt-auto" /> - </div> - </div> - </> - )} + <DrawerPortal> + <DrawerBackdrop className="bg-transparent" /> + <DrawerViewport> + <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + {detail && ( + <> + <DetailHeader detail={detail} onUpdate={handleUpdate} onHide={onHide} /> + <div className="grow overflow-y-auto"> + <div className="flex min-h-full flex-col"> + <div className="flex-1"> + {detail.declaration.category === PluginCategoryEnum.trigger && ( + <> + <SubscriptionList pluginDetail={detail} /> + <TriggerEventsList /> + </> + )} + {!!detail.declaration.tool && <ActionList detail={detail} />} + {!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />} + {!!detail.declaration.endpoint && <EndpointList detail={detail} />} + {!!detail.declaration.model && <ModelList detail={detail} />} + {!!detail.declaration.datasource && <DatasourceActionList detail={detail} />} + </div> + <ReadmeEntrance pluginDetail={detail} className="mt-auto" /> + </div> + </div> + </> + )} + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> ) } diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx b/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx index 8fda455b26..824697566b 100644 --- a/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx +++ b/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx @@ -5,6 +5,14 @@ import type { } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { RiArrowLeftLine, RiCloseLine, @@ -14,7 +22,6 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Divider from '@/app/components/base/divider' -import Drawer from '@/app/components/base/drawer' import Icon from '@/app/components/plugins/card/base/card-icon' import Description from '@/app/components/plugins/card/base/description' import { API_PREFIX } from '@/config' @@ -75,92 +82,99 @@ const StrategyDetail: FC<Props> = ({ return ( <Drawer - isOpen - clickOutsideNotOpen={false} - onClose={onHide} - footer={null} - mask={false} - positionCenter={false} - panelClassName={cn('mt-[64px] mr-2 mb-2 w-[420px]! max-w-[420px]! justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg! p-0! shadow-xl')} + open + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <> - {/* header */} - <div className="relative border-b border-divider-subtle p-4 pb-3"> - <div className="absolute top-3 right-3"> - <ActionButton onClick={onHide}> - <RiCloseLine className="h-4 w-4" /> - </ActionButton> - </div> - <div - className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary" - onClick={onHide} - > - <RiArrowLeftLine className="h-4 w-4" /> - BACK - </div> - <div className="flex items-center gap-1"> - <Icon size="tiny" className="h-6 w-6" src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${provider.tenant_id}&filename=${provider.icon}`} /> - <div className="">{getValueFromI18nObject(provider.label)}</div> - </div> - <div className="mt-1 system-md-semibold text-text-primary">{getValueFromI18nObject(detail.identity.label)}</div> - <Description className="mt-3" text={getValueFromI18nObject(detail.description)} descriptionLineRows={2}></Description> - </div> - {/* form */} - <div className="h-full"> - <div className="flex h-full flex-col overflow-y-auto"> - <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">{t('setBuiltInTools.parameters', { ns: 'tools' })}</div> - <div className="px-4"> - {detail.parameters.length > 0 && ( - <div className="space-y-1 py-2"> - {detail.parameters.map((item: any, index) => ( - <div key={index} className="py-1"> - <div className="flex items-center gap-2"> - <div className="code-sm-semibold text-text-secondary">{getValueFromI18nObject(item.label)}</div> - <div className="system-xs-regular text-text-tertiary"> - {getType(item.type)} - </div> - {item.required && ( - <div className="system-xs-medium text-text-warning-secondary">{t('setBuiltInTools.required', { ns: 'tools' })}</div> - )} + <DrawerPortal> + <DrawerBackdrop className="bg-transparent" /> + <DrawerViewport> + <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + {/* header */} + <div className="relative border-b border-divider-subtle p-4 pb-3"> + <div className="absolute top-3 right-3"> + <ActionButton onClick={onHide}> + <RiCloseLine className="h-4 w-4" /> + </ActionButton> + </div> + <div + className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary" + onClick={onHide} + > + <RiArrowLeftLine className="h-4 w-4" /> + BACK + </div> + <div className="flex items-center gap-1"> + <Icon size="tiny" className="h-6 w-6" src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${provider.tenant_id}&filename=${provider.icon}`} /> + <div className="">{getValueFromI18nObject(provider.label)}</div> + </div> + <div className="mt-1 system-md-semibold text-text-primary">{getValueFromI18nObject(detail.identity.label)}</div> + <Description className="mt-3" text={getValueFromI18nObject(detail.description)} descriptionLineRows={2}></Description> + </div> + {/* form */} + <div className="h-full"> + <div className="flex h-full flex-col overflow-y-auto"> + <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">{t('setBuiltInTools.parameters', { ns: 'tools' })}</div> + <div className="px-4"> + {detail.parameters.length > 0 && ( + <div className="space-y-1 py-2"> + {detail.parameters.map((item: any, index) => ( + <div key={index} className="py-1"> + <div className="flex items-center gap-2"> + <div className="code-sm-semibold text-text-secondary">{getValueFromI18nObject(item.label)}</div> + <div className="system-xs-regular text-text-tertiary"> + {getType(item.type)} + </div> + {item.required && ( + <div className="system-xs-medium text-text-warning-secondary">{t('setBuiltInTools.required', { ns: 'tools' })}</div> + )} + </div> + {item.human_description && ( + <div className="mt-0.5 system-xs-regular text-text-tertiary"> + {getValueFromI18nObject(item.human_description)} + </div> + )} + </div> + ))} </div> - {item.human_description && ( - <div className="mt-0.5 system-xs-regular text-text-tertiary"> - {getValueFromI18nObject(item.human_description)} + )} + </div> + {detail.output_schema && ( + <> + <div className="px-4"> + <Divider className="mt-2!" /> + </div> + <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">OUTPUT</div> + {outputSchema.length > 0 && ( + <div className="space-y-1 px-4 py-2"> + {outputSchema.map((outputItem, index) => ( + <div key={index} className="py-1"> + <div className="flex items-center gap-2"> + <div className="code-sm-semibold text-text-secondary">{outputItem.name}</div> + <div className="system-xs-regular text-text-tertiary">{outputItem.type}</div> + </div> + {outputItem.description && ( + <div className="mt-0.5 system-xs-regular text-text-tertiary"> + {outputItem.description} + </div> + )} + </div> + ))} </div> )} - </div> - ))} + </> + )} </div> - )} - </div> - {detail.output_schema && ( - <> - <div className="px-4"> - <Divider className="mt-2!" /> - </div> - <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">OUTPUT</div> - {outputSchema.length > 0 && ( - <div className="space-y-1 px-4 py-2"> - {outputSchema.map((outputItem, index) => ( - <div key={index} className="py-1"> - <div className="flex items-center gap-2"> - <div className="code-sm-semibold text-text-secondary">{outputItem.name}</div> - <div className="system-xs-regular text-text-tertiary">{outputItem.type}</div> - </div> - {outputItem.description && ( - <div className="mt-0.5 system-xs-regular text-text-tertiary"> - {outputItem.description} - </div> - )} - </div> - ))} - </div> - )} - </> - )} - </div> - </div> - </> + </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> ) } diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx index eb56a0178c..3de2b30f1c 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx @@ -4,6 +4,14 @@ import type { FC } from 'react' import type { TriggerEvent } from '@/app/components/plugins/types' import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { RiArrowLeftLine, RiCloseLine, @@ -11,7 +19,6 @@ import { import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Divider from '@/app/components/base/divider' -import Drawer from '@/app/components/base/drawer' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import Icon from '@/app/components/plugins/card/base/card-icon' import Description from '@/app/components/plugins/card/base/description' @@ -82,78 +89,87 @@ export const EventDetailDrawer: FC<EventDetailDrawerProps> = (props) => { return ( <Drawer - isOpen - clickOutsideNotOpen={false} - onClose={onClose} - footer={null} - mask={false} - positionCenter={false} - panelClassName={cn('mt-[64px] mr-2 mb-2 w-[420px]! max-w-[420px]! justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg! p-0! shadow-xl')} + open + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onClose() + }} > - <div className="relative border-b border-divider-subtle p-4 pb-3"> - <div className="absolute top-3 right-3"> - <ActionButton onClick={onClose}> - <RiCloseLine className="h-4 w-4" /> - </ActionButton> - </div> - <div - className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary" - onClick={onClose} - > - <RiArrowLeftLine className="h-4 w-4" /> - {t('detailPanel.operation.back', { ns: 'plugin' })} - </div> - <div className="flex items-center gap-1"> - <Icon size="tiny" className="h-6 w-6" src={providerInfo.icon!} /> - <OrgInfo - packageNameClassName="w-auto" - orgName={providerInfo.author} - packageName={providerInfo.name.split('/').pop() || ''} - /> - </div> - <div className="mt-1 system-md-semibold text-text-primary">{eventInfo?.identity?.label[language]}</div> - <Description className="mt-3 mb-2 h-auto" text={eventInfo.description[language]!} descriptionLineRows={2}></Description> - </div> - <div className="flex h-full flex-col gap-2 overflow-y-auto px-4 pt-4 pb-2"> - <div className="system-sm-semibold-uppercase text-text-secondary">{t('setBuiltInTools.parameters', { ns: 'tools' })}</div> - {parametersSchemas.length > 0 - ? ( - parametersSchemas.map((item, index) => ( - <div key={index} className="py-1"> - <div className="flex items-center gap-2"> - <div className="code-sm-semibold text-text-secondary">{item.label[language]}</div> - <div className="system-xs-regular text-text-tertiary"> - {getType(item.type, t)} - </div> - {item.required && ( - <div className="system-xs-medium text-text-warning-secondary">{t('setBuiltInTools.required', { ns: 'tools' })}</div> - )} - </div> - {item.description && ( - <div className="mt-0.5 system-xs-regular text-text-tertiary"> - {item.description?.[language]} - </div> - )} + <DrawerPortal> + <DrawerBackdrop className="bg-transparent" /> + <DrawerViewport> + <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="relative border-b border-divider-subtle p-4 pb-3"> + <div className="absolute top-3 right-3"> + <ActionButton onClick={onClose}> + <RiCloseLine className="h-4 w-4" /> + </ActionButton> </div> - )) - ) - : <div className="system-xs-regular text-text-tertiary">{t('events.item.noParameters', { ns: 'pluginTrigger' })}</div>} - <Divider className="mt-1 mb-2 h-px" /> - <div className="flex flex-col gap-2"> - <div className="system-sm-semibold-uppercase text-text-secondary">{t('events.output', { ns: 'pluginTrigger' })}</div> - <div className="relative left-[-7px]"> - {outputFields.map(item => ( - <Field - key={item.name} - name={item.name} - payload={item.field} - required={item.required} - rootClassName="code-sm-semibold text-text-secondary" - /> - ))} - </div> - </div> - </div> + <div + className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary" + onClick={onClose} + > + <RiArrowLeftLine className="h-4 w-4" /> + {t('detailPanel.operation.back', { ns: 'plugin' })} + </div> + <div className="flex items-center gap-1"> + <Icon size="tiny" className="h-6 w-6" src={providerInfo.icon!} /> + <OrgInfo + packageNameClassName="w-auto" + orgName={providerInfo.author} + packageName={providerInfo.name.split('/').pop() || ''} + /> + </div> + <div className="mt-1 system-md-semibold text-text-primary">{eventInfo?.identity?.label[language]}</div> + <Description className="mt-3 mb-2 h-auto" text={eventInfo.description[language]!} descriptionLineRows={2}></Description> + </div> + <div className="flex h-full flex-col gap-2 overflow-y-auto px-4 pt-4 pb-2"> + <div className="system-sm-semibold-uppercase text-text-secondary">{t('setBuiltInTools.parameters', { ns: 'tools' })}</div> + {parametersSchemas.length > 0 + ? ( + parametersSchemas.map((item, index) => ( + <div key={index} className="py-1"> + <div className="flex items-center gap-2"> + <div className="code-sm-semibold text-text-secondary">{item.label[language]}</div> + <div className="system-xs-regular text-text-tertiary"> + {getType(item.type, t)} + </div> + {item.required && ( + <div className="system-xs-medium text-text-warning-secondary">{t('setBuiltInTools.required', { ns: 'tools' })}</div> + )} + </div> + {item.description && ( + <div className="mt-0.5 system-xs-regular text-text-tertiary"> + {item.description?.[language]} + </div> + )} + </div> + )) + ) + : <div className="system-xs-regular text-text-tertiary">{t('events.item.noParameters', { ns: 'pluginTrigger' })}</div>} + <Divider className="mt-1 mb-2 h-px" /> + <div className="flex flex-col gap-2"> + <div className="system-sm-semibold-uppercase text-text-secondary">{t('events.output', { ns: 'pluginTrigger' })}</div> + <div className="relative left-[-7px]"> + {outputFields.map(item => ( + <Field + key={item.name} + name={item.name} + payload={item.field} + required={item.required} + rootClassName="code-sm-semibold text-text-secondary" + /> + ))} + </div> + </div> + </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> ) } diff --git a/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx index 05380916b2..4d69d89516 100644 --- a/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx @@ -6,15 +6,6 @@ import * as React from 'react' import { describe, expect, it, vi } from 'vitest' import MCPDetailPanel from '../provider-detail' -// Mock the drawer component -vi.mock('@/app/components/base/drawer', () => ({ - default: ({ children, isOpen }: { children: ReactNode, isOpen: boolean }) => { - if (!isOpen) - return null - return <div data-testid="drawer">{children}</div> - }, -})) - // Mock the content component to expose onUpdate callback vi.mock('../content', () => ({ default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => ( @@ -71,7 +62,7 @@ describe('MCPDetailPanel', () => { <MCPDetailPanel {...defaultProps} detail={detail} />, { wrapper: createWrapper() }, ) - expect(screen.getByTestId('drawer')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) it('should render content when detail is provided', () => { diff --git a/web/app/components/tools/mcp/detail/provider-detail.tsx b/web/app/components/tools/mcp/detail/provider-detail.tsx index 7b2d351637..e704e6d262 100644 --- a/web/app/components/tools/mcp/detail/provider-detail.tsx +++ b/web/app/components/tools/mcp/detail/provider-detail.tsx @@ -2,8 +2,15 @@ import type { FC } from 'react' import type { ToolWithProvider } from '../../../workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import * as React from 'react' -import Drawer from '@/app/components/base/drawer' import MCPDetailContent from './content' type Props = { @@ -32,23 +39,32 @@ const MCPDetailPanel: FC<Props> = ({ return ( <Drawer - isOpen={!!detail} - clickOutsideNotOpen={false} - onClose={onHide} - footer={null} - mask={false} - positionCenter={false} - panelClassName={cn('mt-[64px] mr-2 mb-2 w-[420px]! max-w-[420px]! justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg! p-0! shadow-xl')} + open={!!detail} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} > - {detail && ( - <MCPDetailContent - detail={detail} - onHide={onHide} - onUpdate={handleUpdate} - isTriggerAuthorize={isTriggerAuthorize} - onFirstCreate={onFirstCreate} - /> - )} + <DrawerPortal> + <DrawerBackdrop className="bg-transparent" /> + <DrawerViewport> + <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + {detail && ( + <MCPDetailContent + detail={detail} + onHide={onHide} + onUpdate={handleUpdate} + isTriggerAuthorize={isTriggerAuthorize} + onFirstCreate={onFirstCreate} + /> + )} + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> ) } diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index edb052fa41..5a26589e11 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -74,11 +74,6 @@ vi.mock('@/utils/var', () => ({ basePath: '', })) -vi.mock('@/app/components/base/drawer', () => ({ - default: ({ children, isOpen }: { children: React.ReactNode, isOpen: boolean }) => - isOpen ? <div data-testid="drawer">{children}</div> : null, -})) - const mockToastSuccess = vi.hoisted(() => vi.fn()) const mockToastError = vi.hoisted(() => vi.fn()) vi.mock('@langgenius/dify-ui/toast', () => ({ diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index 9080ee2c7d..359f6af88c 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -12,6 +12,14 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine, @@ -20,7 +28,6 @@ import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import Drawer from '@/app/components/base/drawer' import { LinkExternal02, Settings01 } from '@/app/components/base/icons/src/vender/line/general' import Loading from '@/app/components/base/loading' import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -230,204 +237,213 @@ const ProviderDetail = ({ return ( <Drawer - isOpen={!!collection} - clickOutsideNotOpen={false} - onClose={onHide} - footer={null} - mask={false} - positionCenter={false} - panelClassName={cn('mt-[64px] mr-2 mb-2 w-[420px]! max-w-[420px]! justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg! p-0! shadow-xl')} + open={!!collection} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + onHide() + }} > - <div className="flex h-full flex-col p-4"> - <div className="shrink-0"> - <div className="mb-3 flex"> - <Icon src={collection.icon} /> - <div className="ml-3 w-0 grow"> - <div className="flex h-5 items-center"> - <Title title={collection.label[language]!} /> - </div> - <div className="mt-0.5 mb-1 flex h-4 items-center justify-between"> - <OrgInfo - packageNameClassName="w-auto" - orgName={collection.author} - packageName={collection.name} - /> - </div> - </div> - <div className="flex gap-1"> - <ActionButton onClick={onHide}> - <RiCloseLine className="h-4 w-4" /> - </ActionButton> - </div> - </div> - </div> - {!!collection.description[language] && ( - <Description text={collection.description[language]} descriptionLineRows={2}></Description> - )} - <div className="flex gap-1 border-b-[0.5px] border-divider-subtle"> - {collection.type === CollectionType.custom && !isDetailLoading && ( - <Button - className={cn('my-3 w-full shrink-0')} - onClick={() => setIsShowEditCustomCollectionModal(true)} - > - <Settings01 className="mr-1 h-4 w-4 text-text-tertiary" /> - <div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div> - </Button> - )} - {collection.type === CollectionType.workflow && !isDetailLoading && customCollection && ( - <> - <Button - variant="primary" - className={cn('my-3 w-[183px] shrink-0')} - > - <a className="flex items-center" href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`} rel="noreferrer" target="_blank"> - <div className="system-sm-medium">{t('openInStudio', { ns: 'tools' })}</div> - <LinkExternal02 className="ml-1 h-4 w-4" /> - </a> - </Button> - <Button - className={cn('my-3 w-[183px] shrink-0')} - onClick={() => setWorkflowToolDrawerOpen(true)} - disabled={!isCurrentWorkspaceManager} - > - <div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div> - </Button> - </> - )} - </div> - <div className="flex min-h-0 flex-1 flex-col pt-3"> - {isDetailLoading && <div className="flex h-[200px]"><Loading type="app" /></div>} - {!isDetailLoading && ( - <> - <div className="shrink-0"> - {(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && isAuthed && ( - <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> - {t('detailPanel.actionNum', { ns: 'plugin', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' })} - {needAuth && ( + <DrawerPortal> + <DrawerBackdrop className="bg-transparent" /> + <DrawerViewport> + <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <div className="flex h-full flex-col p-4"> + <div className="shrink-0"> + <div className="mb-3 flex"> + <Icon src={collection.icon} /> + <div className="ml-3 w-0 grow"> + <div className="flex h-5 items-center"> + <Title title={collection.label[language]!} /> + </div> + <div className="mt-0.5 mb-1 flex h-4 items-center justify-between"> + <OrgInfo + packageNameClassName="w-auto" + orgName={collection.author} + packageName={collection.name} + /> + </div> + </div> + <div className="flex gap-1"> + <ActionButton aria-label={t('operation.close', { ns: 'common' })} onClick={onHide}> + <RiCloseLine className="h-4 w-4" /> + </ActionButton> + </div> + </div> + </div> + {!!collection.description[language] && ( + <Description text={collection.description[language]} descriptionLineRows={2}></Description> + )} + <div className="flex gap-1 border-b-[0.5px] border-divider-subtle"> + {collection.type === CollectionType.custom && !isDetailLoading && ( + <Button + className={cn('my-3 w-full shrink-0')} + onClick={() => setIsShowEditCustomCollectionModal(true)} + > + <Settings01 className="mr-1 h-4 w-4 text-text-tertiary" /> + <div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div> + </Button> + )} + {collection.type === CollectionType.workflow && !isDetailLoading && customCollection && ( + <> <Button - variant="secondary" - size="small" - onClick={() => { - if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model) - showSettingAuthModal() - }} + variant="primary" + className={cn('my-3 w-[183px] shrink-0')} + > + <a className="flex items-center" href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`} rel="noreferrer" target="_blank"> + <div className="system-sm-medium">{t('openInStudio', { ns: 'tools' })}</div> + <LinkExternal02 className="ml-1 h-4 w-4" /> + </a> + </Button> + <Button + className={cn('my-3 w-[183px] shrink-0')} + onClick={() => setWorkflowToolDrawerOpen(true)} disabled={!isCurrentWorkspaceManager} > - <Indicator className="mr-2" color="green" /> - {t('auth.authorized', { ns: 'tools' })} + <div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div> </Button> - )} - </div> - )} - {(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && needAuth && !isAuthed && ( - <> - <div className="system-sm-semibold-uppercase text-text-secondary"> - <span className="">{t('includeToolNum', { ns: 'tools', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> - <span className="px-1">·</span> - <span className="text-util-colors-orange-orange-600">{t('auth.setup', { ns: 'tools' }).toLocaleUpperCase()}</span> - </div> - <Button - variant="primary" - className={cn('my-3 w-full shrink-0')} - onClick={() => { - if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model) - showSettingAuthModal() - }} - disabled={!isCurrentWorkspaceManager} - > - {t('auth.unauthorized', { ns: 'tools' })} - </Button> - </> - )} - {(collection.type === CollectionType.custom) && ( - <div className="system-sm-semibold-uppercase text-text-secondary"> - <span className="">{t('includeToolNum', { ns: 'tools', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> - </div> - )} - {(collection.type === CollectionType.workflow) && ( - <div className="system-sm-semibold-uppercase text-text-secondary"> - <span className="">{t('createTool.toolInput.title', { ns: 'tools' }).toLocaleUpperCase()}</span> - </div> - )} - </div> - <div className="mt-1 flex-1 overflow-y-auto py-2"> - {collection.type !== CollectionType.workflow && toolList.map(tool => ( - <ToolItem - key={tool.name} - disabled={false} + </> + )} + </div> + <div className="flex min-h-0 flex-1 flex-col pt-3"> + {isDetailLoading && <div className="flex h-[200px]"><Loading type="app" /></div>} + {!isDetailLoading && ( + <> + <div className="shrink-0"> + {(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && isAuthed && ( + <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> + {t('detailPanel.actionNum', { ns: 'plugin', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' })} + {needAuth && ( + <Button + variant="secondary" + size="small" + onClick={() => { + if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model) + showSettingAuthModal() + }} + disabled={!isCurrentWorkspaceManager} + > + <Indicator className="mr-2" color="green" /> + {t('auth.authorized', { ns: 'tools' })} + </Button> + )} + </div> + )} + {(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && needAuth && !isAuthed && ( + <> + <div className="system-sm-semibold-uppercase text-text-secondary"> + <span className="">{t('includeToolNum', { ns: 'tools', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> + <span className="px-1">·</span> + <span className="text-util-colors-orange-orange-600">{t('auth.setup', { ns: 'tools' }).toLocaleUpperCase()}</span> + </div> + <Button + variant="primary" + className={cn('my-3 w-full shrink-0')} + onClick={() => { + if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model) + showSettingAuthModal() + }} + disabled={!isCurrentWorkspaceManager} + > + {t('auth.unauthorized', { ns: 'tools' })} + </Button> + </> + )} + {(collection.type === CollectionType.custom) && ( + <div className="system-sm-semibold-uppercase text-text-secondary"> + <span className="">{t('includeToolNum', { ns: 'tools', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> + </div> + )} + {(collection.type === CollectionType.workflow) && ( + <div className="system-sm-semibold-uppercase text-text-secondary"> + <span className="">{t('createTool.toolInput.title', { ns: 'tools' }).toLocaleUpperCase()}</span> + </div> + )} + </div> + <div className="mt-1 flex-1 overflow-y-auto py-2"> + {collection.type !== CollectionType.workflow && toolList.map(tool => ( + <ToolItem + key={tool.name} + disabled={false} + collection={collection} + tool={tool} + isBuiltIn={isBuiltIn} + isModel={isModel} + /> + ))} + {collection.type === CollectionType.workflow && (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map(item => ( + <div key={item.name} className="mb-1 py-1"> + <div className="mb-1 flex items-center gap-2"> + <span className="code-sm-semibold text-text-secondary">{item.name}</span> + <span className="system-xs-regular text-text-tertiary">{item.type}</span> + <span className="system-xs-medium text-text-warning-secondary">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span> + </div> + <div className="system-xs-regular text-text-tertiary">{item.llm_description}</div> + </div> + ))} + </div> + </> + )} + </div> + {showSettingAuth && ( + <ConfigCredential collection={collection} - tool={tool} - isBuiltIn={isBuiltIn} - isModel={isModel} + onCancel={() => setShowSettingAuth(false)} + onSaved={async (value) => { + await updateBuiltInToolCredential(collection.name, value) + toast.success(t('api.actionSuccess', { ns: 'common' })) + await onRefreshData() + setShowSettingAuth(false) + }} + onRemove={async () => { + await removeBuiltInToolCredential(collection.name) + toast.success(t('api.actionSuccess', { ns: 'common' })) + await onRefreshData() + setShowSettingAuth(false) + }} /> - ))} - {collection.type === CollectionType.workflow && (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map(item => ( - <div key={item.name} className="mb-1 py-1"> - <div className="mb-1 flex items-center gap-2"> - <span className="code-sm-semibold text-text-secondary">{item.name}</span> - <span className="system-xs-regular text-text-tertiary">{item.type}</span> - <span className="system-xs-medium text-text-warning-secondary">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span> + )} + {isShowEditCollectionToolModal && ( + <EditCustomToolModal + payload={customCollection} + onHide={() => setIsShowEditCustomCollectionModal(false)} + onEdit={doUpdateCustomToolCollection} + onRemove={onClickCustomToolDelete} + /> + )} + {workflowToolDrawerOpen && ( + <WorkflowToolDrawer + payload={customCollection as unknown as WorkflowToolDrawerPayload} + onHide={() => setWorkflowToolDrawerOpen(false)} + onRemove={onClickWorkflowToolDelete} + onSave={updateWorkflowToolProvider} + /> + )} + <AlertDialog open={showConfirmDelete} onOpenChange={open => !open && setShowConfirmDelete(false)}> + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> + {t('createTool.deleteToolConfirmTitle', { ns: 'tools' })} + </AlertDialogTitle> + <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> + {t('createTool.deleteToolConfirmContent', { ns: 'tools' })} + </AlertDialogDescription> </div> - <div className="system-xs-regular text-text-tertiary">{item.llm_description}</div> - </div> - ))} + <AlertDialogActions> + <AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={handleConfirmDelete}> + {t('operation.confirm', { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> </div> - </> - )} - </div> - {showSettingAuth && ( - <ConfigCredential - collection={collection} - onCancel={() => setShowSettingAuth(false)} - onSaved={async (value) => { - await updateBuiltInToolCredential(collection.name, value) - toast.success(t('api.actionSuccess', { ns: 'common' })) - await onRefreshData() - setShowSettingAuth(false) - }} - onRemove={async () => { - await removeBuiltInToolCredential(collection.name) - toast.success(t('api.actionSuccess', { ns: 'common' })) - await onRefreshData() - setShowSettingAuth(false) - }} - /> - )} - {isShowEditCollectionToolModal && ( - <EditCustomToolModal - payload={customCollection} - onHide={() => setIsShowEditCustomCollectionModal(false)} - onEdit={doUpdateCustomToolCollection} - onRemove={onClickCustomToolDelete} - /> - )} - {workflowToolDrawerOpen && ( - <WorkflowToolDrawer - payload={customCollection as unknown as WorkflowToolDrawerPayload} - onHide={() => setWorkflowToolDrawerOpen(false)} - onRemove={onClickWorkflowToolDelete} - onSave={updateWorkflowToolProvider} - /> - )} - <AlertDialog open={showConfirmDelete} onOpenChange={open => !open && setShowConfirmDelete(false)}> - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t('createTool.deleteToolConfirmTitle', { ns: 'tools' })} - </AlertDialogTitle> - <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t('createTool.deleteToolConfirmContent', { ns: 'tools' })} - </AlertDialogDescription> - </div> - <AlertDialogActions> - <AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton onClick={handleConfirmDelete}> - {t('operation.confirm', { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> - </div> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx index 90e9caae9d..8182a5ab87 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx @@ -1,6 +1,15 @@ 'use client' import type { FC } from 'react' import type { DataSet } from '@/models/datasets' +import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { RiDeleteBinLine, RiEditLine, @@ -13,7 +22,6 @@ import SettingsModal from '@/app/components/app/configuration/dataset-config/set import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' -import Drawer from '@/app/components/base/drawer' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import FeatureIcon from '@/app/components/header/account-setting/model-provider-page/model-selector/feature-icon' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -131,12 +139,29 @@ const DatasetItem: FC<Props> = ({ } {isShowSettingsModal && ( - <Drawer isOpen={isShowSettingsModal} onClose={hideSettingsModal} footer={null} mask={isMobile} panelClassName="mt-16 mx-2 sm:mr-2 mb-3 p-0! max-w-[640px]! rounded-xl"> - <SettingsModal - currentDataset={payload} - onCancel={hideSettingsModal} - onSave={handleSave} - /> + <Drawer + open={isShowSettingsModal} + modal + swipeDirection="right" + onOpenChange={(open) => { + if (!open) + hideSettingsModal() + }} + > + <DrawerPortal> + <DrawerBackdrop className={cn(!isMobile && 'bg-transparent')} /> + <DrawerViewport> + <DrawerPopup className="p-0! data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[640px] data-[swipe-direction=right]:rounded-xl"> + <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> + <SettingsModal + currentDataset={payload} + onCancel={hideSettingsModal} + onSave={handleSave} + /> + </DrawerContent> + </DrawerPopup> + </DrawerViewport> + </DrawerPortal> </Drawer> )} </div> From c67ce6f66dc1a464813ee9d997adda9299851a87 Mon Sep 17 00:00:00 2001 From: Asuka Minato <i@asukaminato.eu.org> Date: Sun, 10 May 2026 13:16:29 +0900 Subject: [PATCH 16/53] chore: unify api && clean some type ignore (#35984) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/admin.py | 12 +---- .../console/app/advanced_prompt_template.py | 2 +- api/controllers/console/app/agent.py | 9 ++-- api/controllers/console/app/annotation.py | 22 ++++----- api/controllers/console/app/app.py | 2 +- api/controllers/console/app/audio.py | 2 +- api/controllers/console/app/completion.py | 10 +---- api/controllers/console/app/conversation.py | 17 ++----- .../console/app/conversation_variables.py | 2 +- api/controllers/console/app/ops_trace.py | 15 ++----- api/controllers/console/app/statistic.py | 24 +++++----- api/controllers/console/app/workflow.py | 6 +-- .../console/app/workflow_app_log.py | 4 +- .../console/app/workflow_comment.py | 10 ++--- .../console/app/workflow_draft_variable.py | 25 ++++------- api/controllers/console/app/workflow_run.py | 8 ++-- .../console/app/workflow_statistic.py | 16 +++---- .../console/app/workflow_trigger.py | 2 +- api/controllers/console/auth/activate.py | 2 +- .../console/auth/data_source_bearer_auth.py | 8 +--- .../console/auth/email_register.py | 6 +-- .../console/auth/forgot_password.py | 2 - api/controllers/console/auth/login.py | 10 +---- .../datasource_content_preview.py | 5 +-- .../console/explore/recommended_app.py | 2 +- api/controllers/console/explore/trial.py | 20 +-------- api/controllers/console/extension.py | 2 +- api/controllers/console/workspace/account.py | 45 +++++++++---------- api/controllers/console/workspace/endpoint.py | 10 +---- api/controllers/console/workspace/members.py | 22 +++++---- .../console/workspace/model_providers.py | 26 +++++------ api/controllers/console/workspace/models.py | 29 +++++------- api/controllers/console/workspace/plugin.py | 16 +++---- .../console/workspace/workspace.py | 19 ++++---- api/controllers/files/image_preview.py | 14 ++---- api/controllers/files/tool_files.py | 7 +-- api/controllers/files/upload.py | 9 ++-- .../service_api/dataset/dataset.py | 10 +---- .../service_api/dataset/document.py | 3 -- 39 files changed, 162 insertions(+), 293 deletions(-) diff --git a/api/controllers/console/admin.py b/api/controllers/console/admin.py index a32c3420bb..bb2f477e3d 100644 --- a/api/controllers/console/admin.py +++ b/api/controllers/console/admin.py @@ -21,8 +21,6 @@ from libs.token import extract_access_token from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp from services.billing_service import BillingService, LangContentDict -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class InsertExploreAppPayload(BaseModel): app_id: str = Field(...) @@ -59,15 +57,7 @@ class InsertExploreBannerPayload(BaseModel): model_config = {"populate_by_name": True} -console_ns.schema_model( - InsertExploreAppPayload.__name__, - InsertExploreAppPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) - -console_ns.schema_model( - InsertExploreBannerPayload.__name__, - InsertExploreBannerPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_schema_models(console_ns, InsertExploreAppPayload, InsertExploreBannerPayload) def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: diff --git a/api/controllers/console/app/advanced_prompt_template.py b/api/controllers/console/app/advanced_prompt_template.py index ed66da1be5..ad21671176 100644 --- a/api/controllers/console/app/advanced_prompt_template.py +++ b/api/controllers/console/app/advanced_prompt_template.py @@ -34,7 +34,7 @@ class AdvancedPromptTemplateList(Resource): @login_required @account_initialization_required def get(self): - args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) prompt_args: AdvancedPromptTemplateArgs = { "app_mode": args.app_mode, "model_mode": args.model_mode, diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index cfdb9cf417..c05600ced5 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -2,6 +2,7 @@ from flask import request from flask_restx import Resource, fields from pydantic import BaseModel, Field, field_validator +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required @@ -10,8 +11,6 @@ from libs.login import login_required from models.model import AppMode from services.agent_service import AgentService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class AgentLogQuery(BaseModel): message_id: str = Field(..., description="Message UUID") @@ -23,9 +22,7 @@ class AgentLogQuery(BaseModel): return uuid_value(value) -console_ns.schema_model( - AgentLogQuery.__name__, AgentLogQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, AgentLogQuery) @console_ns.route("/apps/<uuid:app_id>/agent/logs") @@ -44,6 +41,6 @@ class AgentLogApi(Resource): @get_app_model(mode=[AppMode.AGENT_CHAT]) def get(self, app_model): """Get agent logs""" - args = AgentLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AgentLogQuery.model_validate(request.args.to_dict(flat=True)) return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id) diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index 528785931e..5970e55285 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -33,8 +33,6 @@ from services.annotation_service import ( UpsertAnnotationArgs, ) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class AnnotationReplyPayload(BaseModel): score_threshold: float = Field(..., description="Score threshold for annotation matching") @@ -87,17 +85,6 @@ class AnnotationFilePayload(BaseModel): return uuid_value(value) -def reg(model: type[BaseModel]) -> None: - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(AnnotationReplyPayload) -reg(AnnotationSettingUpdatePayload) -reg(AnnotationListQuery) -reg(CreateAnnotationPayload) -reg(UpdateAnnotationPayload) -reg(AnnotationReplyStatusQuery) -reg(AnnotationFilePayload) register_schema_models( console_ns, Annotation, @@ -105,6 +92,13 @@ register_schema_models( AnnotationExportList, AnnotationHitHistory, AnnotationHitHistoryList, + AnnotationReplyPayload, + AnnotationSettingUpdatePayload, + AnnotationListQuery, + CreateAnnotationPayload, + UpdateAnnotationPayload, + AnnotationReplyStatusQuery, + AnnotationFilePayload, ) @@ -218,7 +212,7 @@ class AnnotationApi(Resource): @account_initialization_required @edit_permission_required def get(self, app_id): - args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page limit = args.limit keyword = args.keyword diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 58ed6efc14..5023d46893 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -701,7 +701,7 @@ class AppExportApi(Resource): @edit_permission_required def get(self, app_model): """Export app""" - args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) payload = AppExportResponse( data=AppDslService.export_dsl( diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index 91fbe4a85a..5b673f3394 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -173,7 +173,7 @@ class TextModesApi(Resource): @account_initialization_required def get(self, app_model): try: - args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True)) response = AudioService.transcript_tts_voices( tenant_id=app_model.tenant_id, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index fe274e4c9a..6a20296cff 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import InternalServerError, NotFound import services +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( AppUnavailableError, @@ -37,7 +38,6 @@ from services.app_task_service import AppTaskService from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class BaseMessagePayload(BaseModel): @@ -65,13 +65,7 @@ class ChatMessagePayload(BaseMessagePayload): return uuid_value(value) -console_ns.schema_model( - CompletionMessagePayload.__name__, - CompletionMessagePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - ChatMessagePayload.__name__, ChatMessagePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload) # define completion message api for user diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index b2b1049f0c..c7347933cb 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -39,8 +39,6 @@ from models.model import AppMode from services.conversation_service import ConversationService from services.errors.conversation import ConversationNotExistsError -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class BaseConversationQuery(BaseModel): keyword: str | None = Field(default=None, description="Search keyword") @@ -70,15 +68,6 @@ class ChatConversationQuery(BaseConversationQuery): ) -console_ns.schema_model( - CompletionConversationQuery.__name__, - CompletionConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - ChatConversationQuery.__name__, - ChatConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) - register_schema_models( console_ns, CompletionConversationQuery, @@ -89,6 +78,8 @@ register_schema_models( ConversationWithSummaryPaginationResponse, ConversationDetailResponse, ResultResponse, + CompletionConversationQuery, + ChatConversationQuery, ) @@ -107,7 +98,7 @@ class CompletionConversationApi(Resource): @edit_permission_required def get(self, app_model): current_user, _ = current_account_with_tenant() - args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) query = sa.select(Conversation).where( Conversation.app_id == app_model.id, Conversation.mode == "completion", Conversation.is_deleted.is_(False) @@ -221,7 +212,7 @@ class ChatConversationApi(Resource): @edit_permission_required def get(self, app_model): current_user, _ = current_account_with_tenant() - args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) subquery = ( sa.select(Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id")) diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index 9c8b095b9f..60a2bfc799 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -100,7 +100,7 @@ class ConversationVariablesApi(Resource): @account_initialization_required @get_app_model(mode=AppMode.ADVANCED_CHAT) def get(self, app_model): - args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) stmt = ( select(ConversationVariable) diff --git a/api/controllers/console/app/ops_trace.py b/api/controllers/console/app/ops_trace.py index cbcf513162..ee2fc39f86 100644 --- a/api/controllers/console/app/ops_trace.py +++ b/api/controllers/console/app/ops_trace.py @@ -5,14 +5,13 @@ from flask_restx import Resource, fields from pydantic import BaseModel, Field from werkzeug.exceptions import BadRequest +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist from controllers.console.wraps import account_initialization_required, setup_required from libs.login import login_required from services.ops_service import OpsService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class TraceProviderQuery(BaseModel): tracing_provider: str = Field(..., description="Tracing provider name") @@ -23,13 +22,7 @@ class TraceConfigPayload(BaseModel): tracing_config: dict[str, Any] = Field(..., description="Tracing configuration data") -console_ns.schema_model( - TraceProviderQuery.__name__, - TraceProviderQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - TraceConfigPayload.__name__, TraceConfigPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, TraceProviderQuery, TraceConfigPayload) @console_ns.route("/apps/<uuid:app_id>/trace-config") @@ -50,7 +43,7 @@ class TraceAppConfigApi(Resource): @login_required @account_initialization_required def get(self, app_id): - args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) try: trace_config = OpsService.get_tracing_app_config(app_id=app_id, tracing_provider=args.tracing_provider) @@ -121,7 +114,7 @@ class TraceAppConfigApi(Resource): @account_initialization_required def delete(self, app_id): """Delete an existing trace app configuration""" - args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) try: result = OpsService.delete_tracing_app_config(app_id=app_id, tracing_provider=args.tracing_provider) diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py index ffa28b1c95..d23b2837c9 100644 --- a/api/controllers/console/app/statistic.py +++ b/api/controllers/console/app/statistic.py @@ -5,6 +5,7 @@ from flask import abort, jsonify, request from flask_restx import Resource, fields from pydantic import BaseModel, Field, field_validator +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required @@ -15,8 +16,6 @@ from libs.helper import convert_datetime_to_date from libs.login import current_account_with_tenant, login_required from models import AppMode -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class StatisticTimeRangeQuery(BaseModel): start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") @@ -30,10 +29,7 @@ class StatisticTimeRangeQuery(BaseModel): return value -console_ns.schema_model( - StatisticTimeRangeQuery.__name__, - StatisticTimeRangeQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_schema_models(console_ns, StatisticTimeRangeQuery) @console_ns.route("/apps/<uuid:app_id>/statistics/daily-messages") @@ -54,7 +50,7 @@ class DailyMessageStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -111,7 +107,7 @@ class DailyConversationStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -167,7 +163,7 @@ class DailyTerminalsStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -224,7 +220,7 @@ class DailyTokenCostStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -284,7 +280,7 @@ class AverageSessionInteractionStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("c.created_at") sql_query = f"""SELECT @@ -360,7 +356,7 @@ class UserSatisfactionRateStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("m.created_at") sql_query = f"""SELECT @@ -426,7 +422,7 @@ class AverageResponseTimeStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -482,7 +478,7 @@ class TokensPerSecondStatistic(Resource): @account_initialization_required def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index e18688f069..4f532b437c 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -60,7 +60,7 @@ logger = logging.getLogger(__name__) _file_access_controller = DatabaseFileAccessController() LISTENING_RETRY_IN = 2000 -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" + RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published" MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS = 1000 WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE = 50 @@ -912,7 +912,7 @@ class DefaultBlockConfigApi(Resource): """ Get default block config """ - args = DefaultBlockConfigQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = DefaultBlockConfigQuery.model_validate(request.args.to_dict(flat=True)) filters = None if args.q: @@ -1005,7 +1005,7 @@ class PublishedAllWorkflowApi(Resource): """ current_user, _ = current_account_with_tenant() - args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page limit = args.limit user_id = args.user_id diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index 4b39590235..ddc900eb2d 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -185,7 +185,7 @@ class WorkflowAppLogApi(Resource): """ Get workflow app logs """ - args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # get paginate workflow app logs workflow_app_service = WorkflowAppService() @@ -228,7 +228,7 @@ class WorkflowArchivedLogApi(Resource): """ Get workflow archived logs """ - args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) workflow_app_service = WorkflowAppService() with sessionmaker(db.engine, expire_on_commit=False).begin() as session: diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index e7c3e982a6..c003be1303 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -23,7 +23,6 @@ from services.account_service import TenantService from services.workflow_comment_service import WorkflowCommentService logger = logging.getLogger(__name__) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class WorkflowCommentCreatePayload(BaseModel): @@ -52,13 +51,14 @@ class WorkflowCommentMentionUsersPayload(BaseModel): users: list[AccountWithRole] -for model in ( +register_schema_models( + console_ns, + AccountWithRole, + WorkflowCommentMentionUsersPayload, WorkflowCommentCreatePayload, WorkflowCommentUpdatePayload, WorkflowCommentReplyPayload, -): - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) -register_schema_models(console_ns, AccountWithRole, WorkflowCommentMentionUsersPayload) +) workflow_comment_basic_model = console_ns.model("WorkflowCommentBasic", workflow_comment_basic_fields) workflow_comment_detail_model = console_ns.model("WorkflowCommentDetail", workflow_comment_detail_fields) diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index c688a69074..3c887c33dc 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -8,6 +8,7 @@ from flask_restx import Resource, fields, marshal, marshal_with from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( DraftWorkflowNotExist, @@ -33,7 +34,6 @@ from services.workflow_service import WorkflowService logger = logging.getLogger(__name__) _file_access_controller = DatabaseFileAccessController() -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class WorkflowDraftVariableListQuery(BaseModel): @@ -56,21 +56,12 @@ class EnvironmentVariableUpdatePayload(BaseModel): environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow") -console_ns.schema_model( - WorkflowDraftVariableListQuery.__name__, - WorkflowDraftVariableListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - WorkflowDraftVariableUpdatePayload.__name__, - WorkflowDraftVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - ConversationVariableUpdatePayload.__name__, - ConversationVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - EnvironmentVariableUpdatePayload.__name__, - EnvironmentVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), +register_schema_models( + console_ns, + WorkflowDraftVariableListQuery, + WorkflowDraftVariableUpdatePayload, + ConversationVariableUpdatePayload, + EnvironmentVariableUpdatePayload, ) @@ -260,7 +251,7 @@ class WorkflowVariableCollectionApi(Resource): """ Get draft workflow """ - args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # fetch draft workflow by app_model workflow_service = WorkflowService() diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index e42aae6090..97d2003209 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -154,7 +154,7 @@ class AdvancedChatAppWorkflowRunListApi(Resource): """ Get advanced chat app workflow run list """ - args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) args: WorkflowRunListArgs = {"limit": args_model.limit} if args_model.last_id is not None: args["last_id"] = args_model.last_id @@ -250,7 +250,7 @@ class AdvancedChatAppWorkflowRunCountApi(Resource): """ Get advanced chat workflow runs count statistics """ - args_model = WorkflowRunCountQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args_model = WorkflowRunCountQuery.model_validate(request.args.to_dict(flat=True)) args = args_model.model_dump(exclude_none=True) # Default to DEBUGGING if not specified @@ -290,7 +290,7 @@ class WorkflowRunListApi(Resource): """ Get workflow run list """ - args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) args: WorkflowRunListArgs = {"limit": args_model.limit} if args_model.last_id is not None: args["last_id"] = args_model.last_id @@ -331,7 +331,7 @@ class WorkflowRunCountApi(Resource): """ Get workflow runs count statistics """ - args_model = WorkflowRunCountQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args_model = WorkflowRunCountQuery.model_validate(request.args.to_dict(flat=True)) args = args_model.model_dump(exclude_none=True) # Default to DEBUGGING for workflow if not specified (backward compatibility) diff --git a/api/controllers/console/app/workflow_statistic.py b/api/controllers/console/app/workflow_statistic.py index e48cf42762..ca899d8784 100644 --- a/api/controllers/console/app/workflow_statistic.py +++ b/api/controllers/console/app/workflow_statistic.py @@ -3,6 +3,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy.orm import sessionmaker +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required @@ -13,8 +14,6 @@ from models.enums import WorkflowRunTriggeredFrom from models.model import AppMode from repositories.factory import DifyAPIRepositoryFactory -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class WorkflowStatisticQuery(BaseModel): start: str | None = Field(default=None, description="Start date and time (YYYY-MM-DD HH:MM)") @@ -28,10 +27,7 @@ class WorkflowStatisticQuery(BaseModel): return value -console_ns.schema_model( - WorkflowStatisticQuery.__name__, - WorkflowStatisticQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_schema_models(console_ns, WorkflowStatisticQuery) @console_ns.route("/apps/<uuid:app_id>/workflow/statistics/daily-conversations") @@ -53,7 +49,7 @@ class WorkflowDailyRunsStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) assert account.timezone is not None @@ -93,7 +89,7 @@ class WorkflowDailyTerminalsStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) assert account.timezone is not None @@ -133,7 +129,7 @@ class WorkflowDailyTokenCostStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) assert account.timezone is not None @@ -173,7 +169,7 @@ class WorkflowAverageAppInteractionStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) assert account.timezone is not None diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index a6715fa200..a80b4f5d0c 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -94,7 +94,7 @@ class WebhookTriggerApi(Resource): @console_ns.response(200, "Success", console_ns.models[WebhookTriggerResponse.__name__]) def get(self, app_model: App): """Get webhook trigger for a node""" - args = Parser.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = Parser.model_validate(request.args.to_dict(flat=True)) node_id = args.node_id diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index f7061f820f..0c05cf2fe3 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -63,7 +63,7 @@ class ActivateCheckApi(Resource): console_ns.models[ActivationCheckResponse.__name__], ) def get(self): - args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) workspaceId = args.workspace_id token = args.token diff --git a/api/controllers/console/auth/data_source_bearer_auth.py b/api/controllers/console/auth/data_source_bearer_auth.py index 905d0daef0..db0d36af6e 100644 --- a/api/controllers/console/auth/data_source_bearer_auth.py +++ b/api/controllers/console/auth/data_source_bearer_auth.py @@ -1,6 +1,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field +from controllers.common.schema import register_schema_models from libs.login import current_account_with_tenant, login_required from services.auth.api_key_auth_service import ApiKeyAuthService @@ -8,8 +9,6 @@ from .. import console_ns from ..auth.error import ApiKeyAuthFailedError from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ApiKeyAuthBindingPayload(BaseModel): category: str = Field(...) @@ -17,10 +16,7 @@ class ApiKeyAuthBindingPayload(BaseModel): credentials: dict = Field(...) -console_ns.schema_model( - ApiKeyAuthBindingPayload.__name__, - ApiKeyAuthBindingPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_schema_models(console_ns, ApiKeyAuthBindingPayload) @console_ns.route("/api-key-auth/data-source") diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index 1fd781b4fc..f6b8aedf22 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, field_validator from configs import dify_config from constants.languages import languages +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.auth.error import ( EmailAlreadyInUseError, @@ -23,8 +24,6 @@ from services.errors.account import AccountNotFoundError, AccountRegisterError from ..error import AccountInFreezeError, EmailSendIpLimitError from ..wraps import email_password_login_enabled, email_register_enabled, setup_required -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class EmailRegisterSendPayload(BaseModel): email: EmailStr = Field(..., description="Email address") @@ -48,8 +47,7 @@ class EmailRegisterResetPayload(BaseModel): return valid_password(value) -for model in (EmailRegisterSendPayload, EmailRegisterValidityPayload, EmailRegisterResetPayload): - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) +register_schema_models(console_ns, EmailRegisterSendPayload, EmailRegisterValidityPayload, EmailRegisterResetPayload) @console_ns.route("/email-register/send-email") diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py index ed390a5f89..c34dd1ac85 100644 --- a/api/controllers/console/auth/forgot_password.py +++ b/api/controllers/console/auth/forgot_password.py @@ -28,8 +28,6 @@ from services.entities.auth_entities import ( ) from services.feature_service import FeatureService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ForgotPasswordEmailResponse(BaseModel): result: str = Field(description="Operation result") diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 8216b3d0da..19c98f3a1a 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -9,6 +9,7 @@ from werkzeug.exceptions import Unauthorized import services from configs import dify_config from constants.languages import get_valid_language +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.auth.error import ( AuthenticationFailedError, @@ -50,7 +51,6 @@ from services.errors.account import AccountRegisterError from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" logger = logging.getLogger(__name__) @@ -71,13 +71,7 @@ class EmailCodeLoginPayload(BaseModel): language: str | None = Field(default=None) -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(LoginPayload) -reg(EmailPayload) -reg(EmailCodeLoginPayload) +register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPayload) @console_ns.route("/login") diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py index 7caf5b52ed..a43caa8f56 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py @@ -4,6 +4,7 @@ from flask_restx import ( # type: ignore from pydantic import BaseModel from werkzeug.exceptions import Forbidden +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import account_initialization_required, setup_required @@ -12,8 +13,6 @@ from models import Account from models.dataset import Pipeline from services.rag_pipeline.rag_pipeline import RagPipelineService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class Parser(BaseModel): inputs: dict @@ -21,7 +20,7 @@ class Parser(BaseModel): credential_id: str | None = None -console_ns.schema_model(Parser.__name__, Parser.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) +register_schema_models(console_ns, Parser) @console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/datasource/nodes/<string:node_id>/preview") diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index 572f9773a1..bd0e875666 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -80,7 +80,7 @@ class RecommendedAppListApi(Resource): @account_initialization_required def get(self): # language args - args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) language = args.language if language and language in languages: language_prefix = language diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 1456301a24..025c517d20 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -10,7 +10,7 @@ from werkzeug.exceptions import Forbidden, InternalServerError, NotFound import services from controllers.common.fields import Parameters as ParametersResponse from controllers.common.fields import Site as SiteResponse -from controllers.common.schema import get_or_create_model +from controllers.common.schema import get_or_create_model, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( AppUnavailableError, @@ -120,10 +120,6 @@ workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipel workflow_model = get_or_create_model("TrialWorkflow", workflow_fields_copy) -# Pydantic models for request validation -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - - class WorkflowRunRequest(BaseModel): inputs: dict files: list | None = None @@ -153,19 +149,7 @@ class CompletionRequest(BaseModel): retriever_from: str = "explore_app" -# Register schemas for Swagger documentation -console_ns.schema_model( - WorkflowRunRequest.__name__, WorkflowRunRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -console_ns.schema_model( - ChatRequest.__name__, ChatRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -console_ns.schema_model( - TextToSpeechRequest.__name__, TextToSpeechRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -console_ns.schema_model( - CompletionRequest.__name__, CompletionRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, WorkflowRunRequest, ChatRequest, TextToSpeechRequest, CompletionRequest) class TrialAppWorkflowRunApi(TrialAppResource): diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index 7a6356d052..9ffc18e4c2 100644 --- a/api/controllers/console/extension.py +++ b/api/controllers/console/extension.py @@ -89,7 +89,7 @@ class CodeBasedExtensionAPI(Resource): @login_required @account_initialization_required def get(self): - query = CodeBasedExtensionQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + query = CodeBasedExtensionQuery.model_validate(request.args.to_dict(flat=True)) return CodeBasedExtensionResponse( module=query.module, diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index d69a59ecb7..68520e540b 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -52,8 +52,6 @@ from services.account_service import AccountService from services.billing_service import BillingService from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class AccountInitPayload(BaseModel): interface_language: str @@ -161,27 +159,26 @@ class CheckEmailUniquePayload(BaseModel): email: EmailStr -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(AccountInitPayload) -reg(AccountNamePayload) -reg(AccountAvatarPayload) -reg(AccountAvatarQuery) -reg(AccountInterfaceLanguagePayload) -reg(AccountInterfaceThemePayload) -reg(AccountTimezonePayload) -reg(AccountPasswordPayload) -reg(AccountDeletePayload) -reg(AccountDeletionFeedbackPayload) -reg(EducationActivatePayload) -reg(EducationAutocompleteQuery) -reg(ChangeEmailSendPayload) -reg(ChangeEmailValidityPayload) -reg(ChangeEmailResetPayload) -reg(CheckEmailUniquePayload) -register_schema_models(console_ns, AccountResponse) +register_schema_models( + console_ns, + AccountResponse, + AccountInitPayload, + AccountNamePayload, + AccountAvatarPayload, + AccountAvatarQuery, + AccountInterfaceLanguagePayload, + AccountInterfaceThemePayload, + AccountTimezonePayload, + AccountPasswordPayload, + AccountDeletePayload, + AccountDeletionFeedbackPayload, + EducationActivatePayload, + EducationAutocompleteQuery, + ChangeEmailSendPayload, + ChangeEmailValidityPayload, + ChangeEmailResetPayload, + CheckEmailUniquePayload, +) def _serialize_account(account) -> dict[str, Any]: @@ -326,7 +323,7 @@ class AccountAvatarApi(Resource): @account_initialization_required def get(self): current_user, current_tenant_id = current_account_with_tenant() - args = AccountAvatarQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AccountAvatarQuery.model_validate(request.args.to_dict(flat=True)) avatar = args.avatar if avatar.startswith(("http://", "https://")): diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index d4be07382a..925f3e1197 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -20,8 +20,6 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder from libs.login import current_account_with_tenant, login_required from services.plugin.endpoint_service import EndpointService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class EndpointCreatePayload(BaseModel): plugin_unique_identifier: str @@ -80,10 +78,6 @@ class EndpointDisableResponse(BaseModel): success: bool = Field(description="Operation success") -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - register_schema_models( console_ns, EndpointCreatePayload, @@ -215,7 +209,7 @@ class EndpointListApi(Resource): def get(self): user, tenant_id = current_account_with_tenant() - args = EndpointListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = EndpointListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page page_size = args.page_size @@ -248,7 +242,7 @@ class EndpointListForSinglePluginApi(Resource): def get(self): user, tenant_id = current_account_with_tenant() - args = EndpointListForPluginQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = EndpointListForPluginQuery.model_validate(request.args.to_dict(flat=True)) page = args.page page_size = args.page_size diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index e3bf4c95b8..c2533c9872 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -33,8 +33,6 @@ from services.account_service import AccountService, RegisterService, TenantServ from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class MemberInvitePayload(BaseModel): emails: list[str] = Field(default_factory=list) @@ -59,17 +57,17 @@ class OwnerTransferPayload(BaseModel): token: str -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(MemberInvitePayload) -reg(MemberRoleUpdatePayload) -reg(OwnerTransferEmailPayload) -reg(OwnerTransferCheckPayload) -reg(OwnerTransferPayload) register_enum_models(console_ns, TenantAccountRole) -register_schema_models(console_ns, AccountWithRole, AccountWithRoleList) +register_schema_models( + console_ns, + AccountWithRole, + AccountWithRoleList, + MemberInvitePayload, + MemberRoleUpdatePayload, + OwnerTransferEmailPayload, + OwnerTransferCheckPayload, + OwnerTransferPayload, +) @console_ns.route("/workspaces/current/members") diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index 4b10561fdb..2f75218c0f 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -5,6 +5,7 @@ from flask import request, send_file from flask_restx import Resource from pydantic import BaseModel, Field, field_validator +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required from graphon.model_runtime.entities.model_entities import ModelType @@ -15,8 +16,6 @@ from libs.login import current_account_with_tenant, login_required from services.billing_service import BillingService from services.model_provider_service import ModelProviderService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ParserModelList(BaseModel): model_type: ModelType | None = None @@ -75,18 +74,17 @@ class ParserPreferredProviderType(BaseModel): preferred_provider_type: Literal["system", "custom"] -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(ParserModelList) -reg(ParserCredentialId) -reg(ParserCredentialCreate) -reg(ParserCredentialUpdate) -reg(ParserCredentialDelete) -reg(ParserCredentialSwitch) -reg(ParserCredentialValidate) -reg(ParserPreferredProviderType) +register_schema_models( + console_ns, + ParserModelList, + ParserCredentialId, + ParserCredentialCreate, + ParserCredentialUpdate, + ParserCredentialDelete, + ParserCredentialSwitch, + ParserCredentialValidate, + ParserPreferredProviderType, +) @console_ns.route("/workspaces/current/model-providers") diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py index b2d07ff8f9..7f7d6379c3 100644 --- a/api/controllers/console/workspace/models.py +++ b/api/controllers/console/workspace/models.py @@ -17,7 +17,6 @@ from services.model_load_balancing_service import ModelLoadBalancingService from services.model_provider_service import ModelProviderService logger = logging.getLogger(__name__) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class ParserGetDefault(BaseModel): @@ -107,6 +106,12 @@ class ParserParameter(BaseModel): model: str +class ParserSwitch(BaseModel): + model: str + model_type: ModelType + credential_id: str + + register_schema_models( console_ns, ParserGetDefault, @@ -119,6 +124,7 @@ register_schema_models( ParserDeleteCredential, ParserParameter, Inner, + ParserSwitch, ) register_enum_models(console_ns, ModelType) @@ -133,7 +139,7 @@ class DefaultModelApi(Resource): def get(self): _, tenant_id = current_account_with_tenant() - args = ParserGetDefault.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserGetDefault.model_validate(request.args.to_dict(flat=True)) model_provider_service = ModelProviderService() default_model_entity = model_provider_service.get_default_model_of_model_type( @@ -261,7 +267,7 @@ class ModelProviderModelCredentialApi(Resource): def get(self, provider: str): _, tenant_id = current_account_with_tenant() - args = ParserGetCredentials.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserGetCredentials.model_validate(request.args.to_dict(flat=True)) model_provider_service = ModelProviderService() current_credential = model_provider_service.get_model_credential( @@ -387,17 +393,6 @@ class ModelProviderModelCredentialApi(Resource): return {"result": "success"}, 204 -class ParserSwitch(BaseModel): - model: str - model_type: ModelType - credential_id: str - - -console_ns.schema_model( - ParserSwitch.__name__, ParserSwitch.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) - - @console_ns.route("/workspaces/current/model-providers/<path:provider>/models/credentials/switch") class ModelProviderModelCredentialSwitchApi(Resource): @console_ns.expect(console_ns.models[ParserSwitch.__name__]) @@ -468,9 +463,7 @@ class ParserValidate(BaseModel): credentials: dict[str, Any] -console_ns.schema_model( - ParserValidate.__name__, ParserValidate.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, ParserSwitch, ParserValidate) @console_ns.route("/workspaces/current/model-providers/<path:provider>/models/credentials/validate") @@ -515,7 +508,7 @@ class ModelProviderModelParameterRuleApi(Resource): @login_required @account_initialization_required def get(self, provider: str): - args = ParserParameter.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserParameter.model_validate(request.args.to_dict(flat=True)) _, tenant_id = current_account_with_tenant() model_provider_service = ModelProviderService() diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index b3e344ccea..93e7f3acab 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -211,7 +211,7 @@ class PluginListApi(Resource): @account_initialization_required def get(self): _, tenant_id = current_account_with_tenant() - args = ParserList.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserList.model_validate(request.args.to_dict(flat=True)) try: plugins_with_total = PluginService.list_with_total(tenant_id, args.page, args.page_size) except PluginDaemonClientSideError as e: @@ -261,7 +261,7 @@ class PluginIconApi(Resource): @console_ns.expect(console_ns.models[ParserIcon.__name__]) @setup_required def get(self): - args = ParserIcon.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserIcon.model_validate(request.args.to_dict(flat=True)) try: icon_bytes, mimetype = PluginService.get_asset(args.tenant_id, args.filename) @@ -279,7 +279,7 @@ class PluginAssetApi(Resource): @login_required @account_initialization_required def get(self): - args = ParserAsset.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserAsset.model_validate(request.args.to_dict(flat=True)) _, tenant_id = current_account_with_tenant() try: @@ -421,7 +421,7 @@ class PluginFetchMarketplacePkgApi(Resource): @plugin_permission_required(install_required=True) def get(self): _, tenant_id = current_account_with_tenant() - args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True)) try: return jsonable_encoder( @@ -446,7 +446,7 @@ class PluginFetchManifestApi(Resource): def get(self): _, tenant_id = current_account_with_tenant() - args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True)) try: return jsonable_encoder( @@ -466,7 +466,7 @@ class PluginFetchInstallTasksApi(Resource): def get(self): _, tenant_id = current_account_with_tenant() - args = ParserTasks.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserTasks.model_validate(request.args.to_dict(flat=True)) try: return jsonable_encoder({"tasks": PluginService.fetch_install_tasks(tenant_id, args.page, args.page_size)}) @@ -660,7 +660,7 @@ class PluginFetchDynamicSelectOptionsApi(Resource): current_user, tenant_id = current_account_with_tenant() user_id = current_user.id - args = ParserDynamicOptions.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserDynamicOptions.model_validate(request.args.to_dict(flat=True)) try: options = PluginParameterService.get_dynamic_select_options( @@ -822,7 +822,7 @@ class PluginReadmeApi(Resource): @account_initialization_required def get(self): _, tenant_id = current_account_with_tenant() - args = ParserReadme.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserReadme.model_validate(request.args.to_dict(flat=True)) return jsonable_encoder( {"readme": PluginService.fetch_plugin_readme(tenant_id, args.plugin_unique_identifier, args.language)} ) diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 565099db61..a15d8b5918 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -16,6 +16,7 @@ from controllers.common.errors import ( TooManyFilesError, UnsupportedFileTypeError, ) +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.admin import admin_required from controllers.console.error import AccountNotLinkTenantError @@ -39,7 +40,6 @@ from services.file_service import FileService from services.workspace_service import WorkspaceService logger = logging.getLogger(__name__) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class WorkspaceListQuery(BaseModel): @@ -91,15 +91,14 @@ class TenantInfoResponse(ResponseModel): return value -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(WorkspaceListQuery) -reg(SwitchWorkspacePayload) -reg(WorkspaceCustomConfigPayload) -reg(WorkspaceInfoPayload) -reg(TenantInfoResponse) +register_schema_models( + console_ns, + WorkspaceListQuery, + SwitchWorkspacePayload, + WorkspaceCustomConfigPayload, + WorkspaceInfoPayload, + TenantInfoResponse, +) provider_fields = { "provider_name": fields.String, diff --git a/api/controllers/files/image_preview.py b/api/controllers/files/image_preview.py index a91e745f80..be7886e831 100644 --- a/api/controllers/files/image_preview.py +++ b/api/controllers/files/image_preview.py @@ -8,13 +8,12 @@ from werkzeug.exceptions import NotFound import services from controllers.common.errors import UnsupportedFileTypeError from controllers.common.file_response import enforce_download_for_html +from controllers.common.schema import register_schema_models from controllers.files import files_ns from extensions.ext_database import db from services.account_service import TenantService from services.file_service import FileService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class FileSignatureQuery(BaseModel): timestamp: str = Field(..., description="Unix timestamp used in the signature") @@ -26,12 +25,7 @@ class FilePreviewQuery(FileSignatureQuery): as_attachment: bool = Field(default=False, description="Whether to download as attachment") -files_ns.schema_model( - FileSignatureQuery.__name__, FileSignatureQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -files_ns.schema_model( - FilePreviewQuery.__name__, FilePreviewQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(files_ns, FileSignatureQuery, FilePreviewQuery) @files_ns.route("/<uuid:file_id>/image-preview") @@ -58,7 +52,7 @@ class ImagePreviewApi(Resource): def get(self, file_id): file_id = str(file_id) - args = FileSignatureQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = FileSignatureQuery.model_validate(request.args.to_dict(flat=True)) timestamp = args.timestamp nonce = args.nonce sign = args.sign @@ -100,7 +94,7 @@ class FilePreviewApi(Resource): def get(self, file_id): file_id = str(file_id) - args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True)) try: generator, upload_file = FileService(db.engine).get_file_generator_by_file_id( diff --git a/api/controllers/files/tool_files.py b/api/controllers/files/tool_files.py index 2f1e2f28bd..8ae16ce7f4 100644 --- a/api/controllers/files/tool_files.py +++ b/api/controllers/files/tool_files.py @@ -7,12 +7,11 @@ from werkzeug.exceptions import Forbidden, NotFound from controllers.common.errors import UnsupportedFileTypeError from controllers.common.file_response import enforce_download_for_html +from controllers.common.schema import register_schema_models from controllers.files import files_ns from core.tools.signature import verify_tool_file_signature from core.tools.tool_file_manager import ToolFileManager -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ToolFileQuery(BaseModel): timestamp: str = Field(..., description="Unix timestamp") @@ -21,9 +20,7 @@ class ToolFileQuery(BaseModel): as_attachment: bool = Field(default=False, description="Download as attachment") -files_ns.schema_model( - ToolFileQuery.__name__, ToolFileQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(files_ns, ToolFileQuery) @files_ns.route("/tools/<uuid:file_id>.<string:extension>") diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index ed3278a28b..462e9ef58e 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -20,8 +20,6 @@ from ..console.wraps import setup_required from ..files import files_ns from ..inner_api.plugin.wraps import get_user -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class PluginUploadQuery(BaseModel): timestamp: str = Field(..., description="Unix timestamp for signature verification") @@ -31,9 +29,8 @@ class PluginUploadQuery(BaseModel): user_id: str | None = Field(default=None, description="User identifier") -files_ns.schema_model( - PluginUploadQuery.__name__, PluginUploadQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(files_ns, PluginUploadQuery) + register_schema_models(files_ns, FileResponse) @@ -69,7 +66,7 @@ class PluginUploadFileApi(Resource): FileTooLargeError: File exceeds size limit UnsupportedFileTypeError: File type not supported """ - args = PluginUploadQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = PluginUploadQuery.model_validate(request.args.to_dict(flat=True)) file = request.files.get("file") if file is None: diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 3eb773fa7c..9af66f1960 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_valid from werkzeug.exceptions import Forbidden, NotFound import services -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_enum_models, register_schema_models from controllers.console.wraps import edit_permission_required from controllers.service_api import service_api_ns from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError @@ -34,13 +34,7 @@ from services.tag_service import ( UpdateTagPayload, ) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - - -service_api_ns.schema_model( - DatasetPermissionEnum.__name__, - TypeAdapter(DatasetPermissionEnum).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_enum_models(service_api_ns, DatasetPermissionEnum) class DatasetCreatePayload(BaseModel): diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 0b09facf58..1cf757912f 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -77,9 +77,6 @@ class DocumentTextCreatePayload(BaseModel): return value -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - - class DocumentTextUpdate(BaseModel): name: str | None = None text: str | None = None From 7b5c371b9d79e553f433a7d88c6fa51d2aa08425 Mon Sep 17 00:00:00 2001 From: Asuka Minato <i@asukaminato.eu.org> Date: Sun, 10 May 2026 15:04:42 +0900 Subject: [PATCH 17/53] chore: api para type (#35985) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/admin.py | 9 ++- api/controllers/console/app/annotation.py | 75 ++++++++----------- api/controllers/console/app/app.py | 9 ++- api/controllers/console/app/ops_trace.py | 17 +++-- .../console/explore/recommended_app.py | 6 +- api/controllers/console/files.py | 2 +- api/controllers/console/workspace/plugin.py | 2 +- .../console/workspace/workspace.py | 2 +- api/controllers/files/upload.py | 2 +- api/controllers/service_api/app/file.py | 2 +- .../service_api/dataset/document.py | 4 +- .../rag_pipeline/rag_pipeline_workflow.py | 2 +- api/controllers/web/files.py | 2 +- .../prompt_template/manager.py | 2 +- api/services/annotation_service.py | 2 +- api/services/audio_service.py | 2 +- .../trigger_subscription_builder_service.py | 8 +- api/services/trigger/webhook_service.py | 2 +- .../services/test_webhook_service.py | 4 +- .../controllers/files/test_upload.py | 4 +- .../test_prompt_template_manager.py | 15 ++-- .../unit_tests/services/test_audio_service.py | 53 ++++++++----- .../services/test_webhook_service.py | 8 +- 23 files changed, 120 insertions(+), 114 deletions(-) diff --git a/api/controllers/console/admin.py b/api/controllers/console/admin.py index bb2f477e3d..ae2b1007dd 100644 --- a/api/controllers/console/admin.py +++ b/api/controllers/console/admin.py @@ -3,6 +3,7 @@ import io from collections.abc import Callable from functools import wraps from typing import cast +from uuid import UUID from flask import request from flask_restx import Resource @@ -181,7 +182,7 @@ class InsertExploreAppApi(Resource): @console_ns.response(204, "App removed successfully") @only_edition_cloud @admin_required - def delete(self, app_id): + def delete(self, app_id: UUID): with session_factory.create_session() as session: recommended_app = session.execute( select(RecommendedApp).where(RecommendedApp.app_id == str(app_id)) @@ -394,11 +395,11 @@ class BatchAddNotificationAccountsApi(Resource): raise BadRequest("Invalid file type. Only CSV (.csv) and TXT (.txt) files are allowed.") try: - content = file.read().decode("utf-8") + content = file.stream.read().decode("utf-8") except UnicodeDecodeError: try: - file.seek(0) - content = file.read().decode("gbk") + file.stream.seek(0) + content = file.stream.read().decode("gbk") except UnicodeDecodeError: raise BadRequest("Unable to decode the file. Please use UTF-8 or GBK encoding.") diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index 5970e55285..cfeaec4af9 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -1,4 +1,5 @@ from typing import Any, Literal +from uuid import UUID from flask import abort, make_response, request from flask_restx import Resource @@ -115,8 +116,7 @@ class AnnotationReplyActionApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def post(self, app_id, action: Literal["enable", "disable"]): - app_id = str(app_id) + def post(self, app_id: UUID, action: Literal["enable", "disable"]): args = AnnotationReplyPayload.model_validate(console_ns.payload) match action: case "enable": @@ -125,9 +125,9 @@ class AnnotationReplyActionApi(Resource): "embedding_provider_name": args.embedding_provider_name, "embedding_model_name": args.embedding_model_name, } - result = AppAnnotationService.enable_app_annotation(enable_args, app_id) + result = AppAnnotationService.enable_app_annotation(enable_args, str(app_id)) case "disable": - result = AppAnnotationService.disable_app_annotation(app_id) + result = AppAnnotationService.disable_app_annotation(str(app_id)) return result, 200 @@ -142,9 +142,8 @@ class AppAnnotationSettingDetailApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_id): - app_id = str(app_id) - result = AppAnnotationService.get_app_annotation_setting_by_app_id(app_id) + def get(self, app_id: UUID): + result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id)) return result, 200 @@ -160,14 +159,13 @@ class AppAnnotationSettingUpdateApi(Resource): @login_required @account_initialization_required @edit_permission_required - def post(self, app_id, annotation_setting_id): - app_id = str(app_id) + def post(self, app_id: UUID, annotation_setting_id): annotation_setting_id = str(annotation_setting_id) args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload) setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold} - result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, setting_args) + result = AppAnnotationService.update_app_annotation_setting(str(app_id), annotation_setting_id, setting_args) return result, 200 @@ -183,7 +181,7 @@ class AnnotationReplyActionStatusApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def get(self, app_id, job_id, action): + def get(self, app_id: UUID, job_id, action): job_id = str(job_id) app_annotation_job_key = f"{action}_app_annotation_job_{str(job_id)}" cache_result = redis_client.get(app_annotation_job_key) @@ -211,14 +209,13 @@ class AnnotationApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_id): + def get(self, app_id: UUID): args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page limit = args.limit keyword = args.keyword - app_id = str(app_id) - annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(app_id, page, limit, keyword) + annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(str(app_id), page, limit, keyword) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) response = AnnotationList( data=annotation_models, @@ -240,8 +237,7 @@ class AnnotationApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def post(self, app_id): - app_id = str(app_id) + def post(self, app_id: UUID): args = CreateAnnotationPayload.model_validate(console_ns.payload) upsert_args: UpsertAnnotationArgs = {} if args.answer is not None: @@ -252,15 +248,14 @@ class AnnotationApi(Resource): upsert_args["message_id"] = args.message_id if args.question is not None: upsert_args["question"] = args.question - annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, app_id) + annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, str(app_id)) return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") @setup_required @login_required @account_initialization_required @edit_permission_required - def delete(self, app_id): - app_id = str(app_id) + def delete(self, app_id: UUID): # Use request.args.getlist to get annotation_ids array directly annotation_ids = request.args.getlist("annotation_id") @@ -274,11 +269,11 @@ class AnnotationApi(Resource): "message": "annotation_ids are required if the parameter is provided.", }, 400 - result = AppAnnotationService.delete_app_annotations_in_batch(app_id, annotation_ids) + result = AppAnnotationService.delete_app_annotations_in_batch(str(app_id), annotation_ids) return result, 204 # If no annotation_ids are provided, handle clearing all annotations else: - AppAnnotationService.clear_all_annotations(app_id) + AppAnnotationService.clear_all_annotations(str(app_id)) return {"result": "success"}, 204 @@ -297,9 +292,8 @@ class AnnotationExportApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_id): - app_id = str(app_id) - annotation_list = AppAnnotationService.export_annotation_list_by_app_id(app_id) + def get(self, app_id: UUID): + annotation_list = AppAnnotationService.export_annotation_list_by_app_id(str(app_id)) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) response_data = AnnotationExportList(data=annotation_models).model_dump(mode="json") @@ -325,26 +319,22 @@ class AnnotationUpdateDeleteApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def post(self, app_id, annotation_id): - app_id = str(app_id) - annotation_id = str(annotation_id) + def post(self, app_id: UUID, annotation_id: UUID): args = UpdateAnnotationPayload.model_validate(console_ns.payload) update_args: UpdateAnnotationArgs = {} if args.answer is not None: update_args["answer"] = args.answer if args.question is not None: update_args["question"] = args.question - annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_id, annotation_id) + annotation = AppAnnotationService.update_app_annotation_directly(update_args, str(app_id), str(annotation_id)) return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") @setup_required @login_required @account_initialization_required @edit_permission_required - def delete(self, app_id, annotation_id): - app_id = str(app_id) - annotation_id = str(annotation_id) - AppAnnotationService.delete_app_annotation(app_id, annotation_id) + def delete(self, app_id: UUID, annotation_id: UUID): + AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id)) return {"result": "success"}, 204 @@ -365,11 +355,9 @@ class AnnotationBatchImportApi(Resource): @annotation_import_rate_limit @annotation_import_concurrency_limit @edit_permission_required - def post(self, app_id): + def post(self, app_id: UUID): from configs import dify_config - app_id = str(app_id) - # check file if "file" not in request.files: raise NoFileUploadedError() @@ -385,9 +373,9 @@ class AnnotationBatchImportApi(Resource): raise ValueError("Invalid file type. Only CSV files are allowed") # Check file size before processing - file.seek(0, 2) # Seek to end of file - file_size = file.tell() - file.seek(0) # Reset to beginning + file.stream.seek(0, 2) # Seek to end of file + file_size = file.stream.tell() + file.stream.seek(0) # Reset to beginning max_size_bytes = dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT * 1024 * 1024 if file_size > max_size_bytes: @@ -400,7 +388,7 @@ class AnnotationBatchImportApi(Resource): if file_size == 0: raise ValueError("The uploaded file is empty") - return AppAnnotationService.batch_import_app_annotations(app_id, file) + return AppAnnotationService.batch_import_app_annotations(str(app_id), file) @console_ns.route("/apps/<uuid:app_id>/annotations/batch-import-status/<uuid:job_id>") @@ -415,8 +403,7 @@ class AnnotationBatchImportStatusApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def get(self, app_id, job_id): - job_id = str(job_id) + def get(self, app_id: UUID, job_id: UUID): indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}" cache_result = redis_client.get(indexing_cache_key) if cache_result is None: @@ -450,13 +437,11 @@ class AnnotationHitHistoryListApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_id, annotation_id): + def get(self, app_id: UUID, annotation_id: UUID): page = request.args.get("page", default=1, type=int) limit = request.args.get("limit", default=20, type=int) - app_id = str(app_id) - annotation_id = str(annotation_id) annotation_hit_history_list, total = AppAnnotationService.get_annotation_hit_histories( - app_id, annotation_id, page, limit + str(app_id), str(annotation_id), page, limit ) history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python( annotation_hit_history_list, from_attributes=True diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 5023d46893..a8ab5bec48 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -3,6 +3,7 @@ import re import uuid from datetime import datetime from typing import Any, Literal +from uuid import UUID from flask import request from flask_restx import Resource @@ -840,10 +841,10 @@ class AppTraceApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_id): + def get(self, app_id: UUID): """Get app trace""" with session_factory.create_session() as session: - app_trace_config = OpsTraceManager.get_app_tracing_config(app_id, session) + app_trace_config = OpsTraceManager.get_app_tracing_config(str(app_id), session) return app_trace_config @@ -857,12 +858,12 @@ class AppTraceApi(Resource): @login_required @account_initialization_required @edit_permission_required - def post(self, app_id): + def post(self, app_id: UUID): # add app trace args = AppTracePayload.model_validate(console_ns.payload) OpsTraceManager.update_app_tracing_config( - app_id=app_id, + app_id=str(app_id), enabled=args.enabled, tracing_provider=args.tracing_provider, ) diff --git a/api/controllers/console/app/ops_trace.py b/api/controllers/console/app/ops_trace.py index ee2fc39f86..9227d00a21 100644 --- a/api/controllers/console/app/ops_trace.py +++ b/api/controllers/console/app/ops_trace.py @@ -1,4 +1,5 @@ from typing import Any +from uuid import UUID from flask import request from flask_restx import Resource, fields @@ -42,11 +43,11 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_id): + def get(self, app_id: UUID): args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) try: - trace_config = OpsService.get_tracing_app_config(app_id=app_id, tracing_provider=args.tracing_provider) + trace_config = OpsService.get_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider) if not trace_config: return {"has_not_configured": True} return trace_config @@ -64,13 +65,13 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required - def post(self, app_id): + def post(self, app_id: UUID): """Create a new trace app configuration""" args = TraceConfigPayload.model_validate(console_ns.payload) try: result = OpsService.create_tracing_app_config( - app_id=app_id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config + app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config ) if not result: raise TracingConfigIsExist() @@ -89,13 +90,13 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required - def patch(self, app_id): + def patch(self, app_id: UUID): """Update an existing trace app configuration""" args = TraceConfigPayload.model_validate(console_ns.payload) try: result = OpsService.update_tracing_app_config( - app_id=app_id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config + app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config ) if not result: raise TracingConfigNotExist() @@ -112,12 +113,12 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required - def delete(self, app_id): + def delete(self, app_id: UUID): """Delete an existing trace app configuration""" args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) try: - result = OpsService.delete_tracing_app_config(app_id=app_id, tracing_provider=args.tracing_provider) + result = OpsService.delete_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider) if not result: raise TracingConfigNotExist() return {"result": "success"}, 204 diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index bd0e875666..5821b91489 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -1,4 +1,5 @@ from typing import Any +from uuid import UUID from flask import request from flask_restx import Resource @@ -99,6 +100,5 @@ class RecommendedAppListApi(Resource): class RecommendedAppApi(Resource): @login_required @account_initialization_required - def get(self, app_id): - app_id = str(app_id) - return RecommendedAppService.get_recommend_app_detail(app_id) + def get(self, app_id: UUID): + return RecommendedAppService.get_recommend_app_detail(str(app_id)) diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 109a3cd0d3..9fa5b0f5c1 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -82,7 +82,7 @@ class FileApi(Resource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, source=source, diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index 93e7f3acab..a6d4a60beb 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -177,7 +177,7 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes: FileStorage.content_length is not reliable for multipart test uploads and may be zero even when content exists, so the controllers validate against the loaded bytes instead. """ - content = file.read() + content = file.stream.read() if len(content) > max_size: raise ValueError("File size exceeds the maximum allowed size") diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index a15d8b5918..84890f0443 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -321,7 +321,7 @@ class WebappLogoWorkspaceApi(Resource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, ) diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index 462e9ef58e..7d588b95dd 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -100,7 +100,7 @@ class PluginUploadFileApi(Resource): tool_file = ToolFileManager().create_file_by_raw( user_id=user.id, tenant_id=tenant_id, - file_binary=file.read(), + file_binary=file.stream.read(), mimetype=mimetype, filename=filename, conversation_id=None, diff --git a/api/controllers/service_api/app/file.py b/api/controllers/service_api/app/file.py index 6f6dadf768..687d34076d 100644 --- a/api/controllers/service_api/app/file.py +++ b/api/controllers/service_api/app/file.py @@ -58,7 +58,7 @@ class FileApi(Resource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=end_user, ) diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 1cf757912f..cb48fe6715 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -432,7 +432,7 @@ class DocumentAddByFileApi(DatasetApiResource): raise ValueError("current_user is required") upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, source="datasets", @@ -506,7 +506,7 @@ def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, source="datasets", diff --git a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py index 2dc98bfbf7..8bc43bccd5 100644 --- a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py @@ -241,7 +241,7 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, ) diff --git a/api/controllers/web/files.py b/api/controllers/web/files.py index 0036c90800..6128490104 100644 --- a/api/controllers/web/files.py +++ b/api/controllers/web/files.py @@ -73,7 +73,7 @@ class FileApi(WebApiResource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=end_user, source="datasets" if source == "datasets" else None, diff --git a/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py b/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py index 4c07445df3..f4bbbe5d8b 100644 --- a/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py +++ b/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py @@ -75,7 +75,7 @@ class PromptTemplateConfigManager: if not config.get("prompt_type"): config["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE - prompt_type_vals = [typ.value for typ in PromptTemplateEntity.PromptType] + prompt_type_vals = list(PromptTemplateEntity.PromptType) if config["prompt_type"] not in prompt_type_vals: raise ValueError(f"prompt_type must be in {prompt_type_vals}") diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index 0229a1f43a..aa6b8ffc6e 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -425,7 +425,7 @@ class AppAnnotationService: return {"deleted_count": deleted_count} @classmethod - def batch_import_app_annotations(cls, app_id, file: FileStorage): + def batch_import_app_annotations(cls, app_id: str, file: FileStorage): """ Batch import annotations from CSV file with enhanced security checks. diff --git a/api/services/audio_service.py b/api/services/audio_service.py index 60948e652b..c80b2f43fd 100644 --- a/api/services/audio_service.py +++ b/api/services/audio_service.py @@ -54,7 +54,7 @@ class AudioService: if extension not in [f"audio/{ext}" for ext in AUDIO_EXTENSIONS]: raise UnsupportedAudioTypeServiceError() - file_content = file.read() + file_content = file.stream.read() file_size = len(file_content) if file_size > FILE_SIZE_LIMIT: diff --git a/api/services/trigger/trigger_subscription_builder_service.py b/api/services/trigger/trigger_subscription_builder_service.py index 889717df72..cff735b39d 100644 --- a/api/services/trigger/trigger_subscription_builder_service.py +++ b/api/services/trigger/trigger_subscription_builder_service.py @@ -121,9 +121,7 @@ class TriggerSubscriptionBuilderService: if not subscription_builder.name: raise ValueError("Subscription builder name is required") - credential_type = CredentialType.of( - subscription_builder.credential_type or CredentialType.UNAUTHORIZED.value - ) + credential_type = CredentialType.of(subscription_builder.credential_type or CredentialType.UNAUTHORIZED) if credential_type == CredentialType.UNAUTHORIZED: # manually create TriggerProviderService.add_trigger_subscription( @@ -321,9 +319,7 @@ class TriggerSubscriptionBuilderService: raise ValueError("Subscription builder name is required") # Build - credential_type = CredentialType.of( - subscription_builder.credential_type or CredentialType.UNAUTHORIZED.value - ) + credential_type = CredentialType.of(subscription_builder.credential_type or CredentialType.UNAUTHORIZED) if credential_type == CredentialType.UNAUTHORIZED: # manually create TriggerProviderService.add_trigger_subscription( diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 5d99900a04..592f678421 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -402,7 +402,7 @@ class WebhookService: for name, file in files.items(): if file and file.filename: try: - file_content = file.read() + file_content = file.stream.read() mimetype = file.content_type or mimetypes.guess_type(file.filename)[0] or "application/octet-stream" file_obj = cls._create_file_from_binary(file_content, mimetype, webhook_trigger) processed_files[name] = file_obj.to_dict() diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service.py b/api/tests/test_containers_integration_tests/services/test_webhook_service.py index 6d5c7380b7..52b1229302 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service.py @@ -543,8 +543,8 @@ class TestWebhookService: "bad_file": MagicMock(filename="test.bad", content_type="text/plain"), } - files["good_file"].read.return_value = b"content" - files["bad_file"].read.side_effect = Exception("Read error") + files["good_file"].stream.read.return_value = b"content" + files["bad_file"].stream.read.side_effect = Exception("Read error") webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" diff --git a/api/tests/unit_tests/controllers/files/test_upload.py b/api/tests/unit_tests/controllers/files/test_upload.py index e8f3cd4b66..ff6ba0e9a1 100644 --- a/api/tests/unit_tests/controllers/files/test_upload.py +++ b/api/tests/unit_tests/controllers/files/test_upload.py @@ -1,3 +1,4 @@ +import io import types from unittest.mock import patch @@ -30,9 +31,10 @@ class DummyFile: self.filename = filename self.mimetype = mimetype self._content = content + self.stream = io.BytesIO(content) def read(self): - return self._content + return self.stream.read() class DummyToolFile: diff --git a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py index 3fd21ab22b..62e1d22129 100644 --- a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py +++ b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py @@ -1,3 +1,4 @@ +from collections import UserString from unittest.mock import MagicMock import pytest @@ -12,21 +13,25 @@ from core.app.app_config.easy_ui_based_app.prompt_template.manager import ( # ----------------------------- -class DummyEnumValue: +class DummyEnumValue(UserString): def __init__(self, value): + super().__init__(value) self.value = value class DummyPromptType: def __init__(self): - self.SIMPLE = "simple" - self.ADVANCED = "advanced" + self.SIMPLE = DummyEnumValue("simple") + self.ADVANCED = DummyEnumValue("advanced") def value_of(self, value): - return value + for enum_value in self: + if enum_value.value == value: + return enum_value + raise ValueError(f"invalid prompt type value {value}") def __iter__(self): - return iter([DummyEnumValue("simple"), DummyEnumValue("advanced")]) + return iter([self.SIMPLE, self.ADVANCED]) # ----------------------------- diff --git a/api/tests/unit_tests/services/test_audio_service.py b/api/tests/unit_tests/services/test_audio_service.py index 83258fd1b7..5d148974f8 100644 --- a/api/tests/unit_tests/services/test_audio_service.py +++ b/api/tests/unit_tests/services/test_audio_service.py @@ -173,7 +173,8 @@ class AudioServiceTestDataFactory: file = Mock(spec=FileStorage) file.filename = filename file.mimetype = mimetype - file.read = Mock(return_value=content) + file.stream = Mock() + file.stream.read = Mock(return_value=content) for key, value in kwargs.items(): setattr(file, key, value) return file @@ -216,7 +217,7 @@ class TestAudioServiceASR: """Test speech-to-text (ASR) operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_asr_success_chat_mode(self, mock_model_manager_class, factory): + def test_transcript_asr_success_chat_mode(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test successful ASR transcription in CHAT mode.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -241,7 +242,9 @@ class TestAudioServiceASR: mock_model_manager_class.assert_called_once_with(tenant_id=app.tenant_id, user_id="user-123") @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_asr_success_advanced_chat_mode(self, mock_model_manager_class, factory): + def test_transcript_asr_success_advanced_chat_mode( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test successful ASR transcription in ADVANCED_CHAT mode.""" # Arrange workflow = factory.create_workflow_mock(features_dict={"speech_to_text": {"enabled": True}}) @@ -263,7 +266,7 @@ class TestAudioServiceASR: # Assert assert result == {"text": "Workflow transcribed text"} - def test_transcript_asr_raises_error_when_feature_disabled_chat_mode(self, factory): + def test_transcript_asr_raises_error_when_feature_disabled_chat_mode(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when speech-to-text is disabled in CHAT mode.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": False}) @@ -277,7 +280,9 @@ class TestAudioServiceASR: with pytest.raises(ValueError, match="Speech to text is not enabled"): AudioService.transcript_asr(app_model=app, file=file) - def test_transcript_asr_raises_error_when_feature_disabled_workflow_mode(self, factory): + def test_transcript_asr_raises_error_when_feature_disabled_workflow_mode( + self, factory: AudioServiceTestDataFactory + ): """Test that ASR raises error when speech-to-text is disabled in WORKFLOW mode.""" # Arrange workflow = factory.create_workflow_mock(features_dict={"speech_to_text": {"enabled": False}}) @@ -291,7 +296,7 @@ class TestAudioServiceASR: with pytest.raises(ValueError, match="Speech to text is not enabled"): AudioService.transcript_asr(app_model=app, file=file) - def test_transcript_asr_raises_error_when_workflow_missing(self, factory): + def test_transcript_asr_raises_error_when_workflow_missing(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when workflow is missing in WORKFLOW mode.""" # Arrange app = factory.create_app_mock( @@ -304,7 +309,7 @@ class TestAudioServiceASR: with pytest.raises(ValueError, match="Speech to text is not enabled"): AudioService.transcript_asr(app_model=app, file=file) - def test_transcript_asr_raises_error_when_no_file_uploaded(self, factory): + def test_transcript_asr_raises_error_when_no_file_uploaded(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when no file is uploaded.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -317,7 +322,7 @@ class TestAudioServiceASR: with pytest.raises(NoAudioUploadedServiceError): AudioService.transcript_asr(app_model=app, file=None) - def test_transcript_asr_raises_error_for_unsupported_audio_type(self, factory): + def test_transcript_asr_raises_error_for_unsupported_audio_type(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error for unsupported audio file types.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -331,7 +336,7 @@ class TestAudioServiceASR: with pytest.raises(UnsupportedAudioTypeServiceError): AudioService.transcript_asr(app_model=app, file=file) - def test_transcript_asr_raises_error_for_large_file(self, factory): + def test_transcript_asr_raises_error_for_large_file(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when file exceeds size limit (30MB).""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -348,7 +353,9 @@ class TestAudioServiceASR: AudioService.transcript_asr(app_model=app, file=file) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_asr_raises_error_when_no_model_instance(self, mock_model_manager_class, factory): + def test_transcript_asr_raises_error_when_no_model_instance( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test that ASR raises error when no model instance is available.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -371,7 +378,7 @@ class TestAudioServiceTTS: """Test text-to-speech (TTS) operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_with_text_success(self, mock_model_manager_class, factory): + def test_transcript_tts_with_text_success(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test successful TTS with text input.""" # Arrange app_model_config = factory.create_app_model_config_mock( @@ -405,7 +412,7 @@ class TestAudioServiceTTS: ) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_with_default_voice(self, mock_model_manager_class, factory): + def test_transcript_tts_with_default_voice(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test TTS uses default voice when none specified.""" # Arrange app_model_config = factory.create_app_model_config_mock( @@ -435,7 +442,9 @@ class TestAudioServiceTTS: assert call_args.kwargs["voice"] == "default-voice" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_gets_first_available_voice_when_none_configured(self, mock_model_manager_class, factory): + def test_transcript_tts_gets_first_available_voice_when_none_configured( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test TTS gets first available voice when none is configured.""" # Arrange app_model_config = factory.create_app_model_config_mock( @@ -467,7 +476,7 @@ class TestAudioServiceTTS: @patch("services.audio_service.WorkflowService", autospec=True) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_workflow_mode_with_draft( - self, mock_model_manager_class, mock_workflow_service_class, factory + self, mock_model_manager_class, mock_workflow_service_class, factory: AudioServiceTestDataFactory ): """Test TTS in WORKFLOW mode with draft workflow.""" # Arrange @@ -499,7 +508,7 @@ class TestAudioServiceTTS: assert result == b"draft audio" mock_workflow_service.get_draft_workflow.assert_called_once_with(app_model=app) - def test_transcript_tts_raises_error_when_text_missing(self, factory): + def test_transcript_tts_raises_error_when_text_missing(self, factory: AudioServiceTestDataFactory): """Test that TTS raises error when text is missing.""" # Arrange app = factory.create_app_mock() @@ -509,7 +518,9 @@ class TestAudioServiceTTS: AudioService.transcript_tts(app_model=app, text=None) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_raises_error_when_no_voices_available(self, mock_model_manager_class, factory): + def test_transcript_tts_raises_error_when_no_voices_available( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test that TTS raises error when no voices are available.""" # Arrange app_model_config = factory.create_app_model_config_mock( @@ -535,7 +546,7 @@ class TestAudioServiceTTSVoices: """Test TTS voice listing operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_voices_success(self, mock_model_manager_class, factory): + def test_transcript_tts_voices_success(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test successful retrieval of TTS voices.""" # Arrange tenant_id = "tenant-123" @@ -560,7 +571,9 @@ class TestAudioServiceTTSVoices: mock_model_instance.get_tts_voices.assert_called_once_with(language) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_voices_raises_error_when_no_model_instance(self, mock_model_manager_class, factory): + def test_transcript_tts_voices_raises_error_when_no_model_instance( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test that TTS voices raises error when no model instance is available.""" # Arrange tenant_id = "tenant-123" @@ -575,7 +588,9 @@ class TestAudioServiceTTSVoices: AudioService.transcript_tts_voices(tenant_id=tenant_id, language=language) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_voices_propagates_exceptions(self, mock_model_manager_class, factory): + def test_transcript_tts_voices_propagates_exceptions( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test that TTS voices propagates exceptions from model instance.""" # Arrange tenant_id = "tenant-123" diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index 95edc436d7..a2b56fe777 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -268,8 +268,8 @@ class TestWebhookServiceUnit: } # Mock file reads - files["file1"].read.return_value = b"content1" - files["file2"].read.return_value = b"content2" + files["file1"].stream.read.return_value = b"content1" + files["file2"].stream.read.return_value = b"content2" webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" @@ -304,8 +304,8 @@ class TestWebhookServiceUnit: "bad_file": MagicMock(filename="test.bad", content_type="text/plain"), } - files["good_file"].read.return_value = b"content" - files["bad_file"].read.side_effect = Exception("Read error") + files["good_file"].stream.read.return_value = b"content" + files["bad_file"].stream.read.side_effect = Exception("Read error") webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" From b99ba74aa46c5aa6ebf321a17052bf711d27c851 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 10 May 2026 17:35:27 +0800 Subject: [PATCH 18/53] chore(web): remove drawer overlay import restriction (#35990) --- web/eslint.config.mjs | 18 ------------------ web/eslint.constants.mjs | 10 ---------- 2 files changed, 28 deletions(-) diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index ab2c626482..a46256ebf1 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -14,7 +14,6 @@ import { GENERATED_IGNORES, HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS, NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS, - OVERLAY_RESTRICTED_IMPORT_PATTERNS, WEB_RESTRICTED_IMPORT_PATTERNS, } from './eslint.constants.mjs' import dify from './plugins/eslint/index.js' @@ -171,21 +170,4 @@ export default antfu( }], }, }, - { - name: 'dify/overlay-import-policy', - files: [GLOB_TS, GLOB_TSX], - ignores: [ - 'next/**', - ...GLOB_TESTS, - ], - rules: { - 'no-restricted-imports': ['error', { - paths: NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS, - patterns: [ - ...WEB_RESTRICTED_IMPORT_PATTERNS, - ...OVERLAY_RESTRICTED_IMPORT_PATTERNS, - ], - }], - }, - }, ) diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index cc0ba060a5..1d342fbf4d 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -54,16 +54,6 @@ export const WEB_RESTRICTED_IMPORT_PATTERNS = [ ...FLOATING_UI_RESTRICTED_IMPORT_PATTERNS, ] -export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ - { - group: [ - '**/base/drawer', - '**/base/drawer/index', - ], - message: 'Deprecated: use @langgenius/dify-ui/drawer instead. See issue #32767.', - }, -] - export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = { prefix: 'i-', propMappings: { From b95e6f6a7a33cd1623633be388222143bff5210d Mon Sep 17 00:00:00 2001 From: Blackoutta <37723456+Blackoutta@users.noreply.github.com> Date: Sun, 10 May 2026 20:10:16 +0800 Subject: [PATCH 19/53] feat: support editable class labels in question classifier (#35430) --- eslint-suppressions.json | 23 +-- .../prompt-editor/__tests__/index.spec.tsx | 8 + .../components/base/prompt-editor/index.tsx | 16 +- web/app/components/workflow/constants.ts | 4 + .../__tests__/integration.spec.tsx | 6 + .../__tests__/node.spec.tsx | 10 +- .../__tests__/panel.spec.tsx | 1 + .../__tests__/use-config.spec.ts | 147 ++++++++++++++++++ .../components/__tests__/class-item.spec.tsx | 36 ++++- .../components/class-item.tsx | 99 +++++++++++- .../components/class-label-utils.ts | 66 ++++++++ .../components/class-list.tsx | 51 ++++-- .../nodes/question-classifier/default.ts | 5 +- .../nodes/question-classifier/node.tsx | 10 +- .../nodes/question-classifier/panel.tsx | 5 + .../nodes/question-classifier/types.ts | 1 + .../nodes/question-classifier/use-config.ts | 118 ++++++++++---- .../__tests__/node.spec.tsx | 5 +- .../nodes/question-classifier/node.tsx | 5 +- web/i18n/en-US/workflow.json | 4 + web/i18n/ja-JP/workflow.json | 4 + web/i18n/zh-Hans/workflow.json | 4 + web/i18n/zh-Hant/workflow.json | 4 + 23 files changed, 535 insertions(+), 97 deletions(-) create mode 100644 web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/question-classifier/components/class-label-utils.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 23e2da9ee0..2de84456ee 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1465,7 +1465,7 @@ }, "web/app/components/base/prompt-editor/index.tsx": { "ts/no-explicit-any": { - "count": 4 + "count": 3 } }, "web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": { @@ -3858,30 +3858,9 @@ "count": 9 } }, - "web/app/components/workflow/nodes/question-classifier/components/class-item.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/workflow/nodes/question-classifier/components/class-list.tsx": { "react/set-state-in-effect": { "count": 1 - }, - "react/unsupported-syntax": { - "count": 2 - } - }, - "web/app/components/workflow/nodes/question-classifier/default.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/question-classifier/use-config.ts": { - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 } }, "web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts": { diff --git a/web/app/components/base/prompt-editor/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/__tests__/index.spec.tsx index 9d75b9e061..31e25ab19e 100644 --- a/web/app/components/base/prompt-editor/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/index.spec.tsx @@ -365,6 +365,14 @@ describe('PromptEditor', () => { expect(() => unmount()).not.toThrow() }) + it('should rerender without ref-driven update loops', () => { + const { rerender } = render(<PromptEditor value="first" />) + + expect(() => { + rerender(<PromptEditor value="second" />) + }).not.toThrow() + }) + it('should render hitl block when show=true', () => { render( <PromptEditor diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index fce70d2781..29d0d71715 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -29,7 +29,7 @@ import { TextNode, } from 'lexical' import * as React from 'react' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useEventEmitterContextContext } from '@/context/event-emitter' import { UPDATE_DATASETS_EVENT_EMITTER, @@ -203,12 +203,16 @@ const PromptEditor: FC<PromptEditorProps> = ({ } as any) }, [eventEmitter, historyBlock?.history]) - const [floatingAnchorElem, setFloatingAnchorElem] = useState(null) + const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null) - const onRef = (_floatingAnchorElem: any) => { - if (_floatingAnchorElem !== null) - setFloatingAnchorElem(_floatingAnchorElem) - } + const onRef = useCallback((nextFloatingAnchorElem: HTMLDivElement | null) => { + setFloatingAnchorElem((currentFloatingAnchorElem) => { + if (currentFloatingAnchorElem === nextFloatingAnchorElem) + return currentFloatingAnchorElem + + return nextFloatingAnchorElem + }) + }, []) return ( <LexicalComposer initialConfig={{ ...initialConfig, editable }}> diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index ed9a072824..101d15a140 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -172,6 +172,10 @@ export const QUESTION_CLASSIFIER_OUTPUT_STRUCT = [ variable: 'class_name', type: VarType.string, }, + { + variable: 'class_label', + type: VarType.string, + }, { variable: 'usage', type: VarType.object, diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx index ada3fc43cc..c4f8a41d47 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx @@ -55,6 +55,12 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec default: ({ defaultModel }: any) => <div>{defaultModel.provider}:{defaultModel.model}</div>, })) +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: any) => <div>{children}</div>, + TooltipTrigger: ({ children }: any) => <>{children}</>, + TooltipContent: ({ children }: any) => <div>{children}</div>, +})) + vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({ default: ({ value }: any) => <div>{value}</div>, })) diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx index ad411639e9..3356226a85 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx @@ -76,12 +76,18 @@ describe('question-classifier/node', () => { render( <Node {...baseNodeProps} - data={createData({ classes: [createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })] })} + data={createData({ + classes: [ + createTopic({ label: 'Billing' } as Partial<Topic>), + createTopic({ id: 'topic-2', name: 'Refunds', label: 'Refund desk' } as Partial<Topic>), + ], + })} />, ) expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument() - expect(screen.getByText('Billing questions')).toBeInTheDocument() + expect(screen.getByText('Billing')).toBeInTheDocument() + expect(screen.getByText('Refund desk')).toBeInTheDocument() expect(screen.getByText('handle-topic-1')).toBeInTheDocument() expect(screen.getByText('handle-topic-2')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx index c205f57d08..f92bb5abb3 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx @@ -144,6 +144,7 @@ describe('question-classifier/panel', () => { expect(handleVisionResolutionEnabledChange).toHaveBeenCalledWith(true) expect(handleVisionResolutionChange).toHaveBeenCalledWith({ resolution: 'high' }) expect(screen.getByText('class_name:string')).toBeInTheDocument() + expect(screen.getByText('class_label:string')).toBeInTheDocument() expect(screen.getByText('usage:object')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts new file mode 100644 index 0000000000..89af550de1 --- /dev/null +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts @@ -0,0 +1,147 @@ +import type { QuestionClassifierNodeType } from '../types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useIsChatMode, + useNodesReadOnly, + useWorkflow, +} from '@/app/components/workflow/hooks' +import useConfigVision from '@/app/components/workflow/hooks/use-config-vision' +import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useStore } from '@/app/components/workflow/store' +import { BlockEnum } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' +import useConfig from '../use-config' + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: vi.fn(), + useIsChatMode: vi.fn(), + useWorkflow: vi.fn(), +})) + +vi.mock('reactflow', () => ({ + useUpdateNodeInternals: vi.fn(() => vi.fn()), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ + __esModule: true, + default: vi.fn(), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: vi.fn(), +})) + +vi.mock('@/app/components/workflow/hooks/use-config-vision', () => ({ + __esModule: true, + default: vi.fn(), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({ + __esModule: true, + default: vi.fn(), +})) + +const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) +const mockUseIsChatMode = vi.mocked(useIsChatMode) +const mockUseWorkflow = vi.mocked(useWorkflow) +const mockUseNodeCrud = vi.mocked(useNodeCrud) +const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = vi.mocked(useModelListAndDefaultModelAndCurrentProviderAndModel) +const mockUseStore = vi.mocked(useStore) +const mockUseConfigVision = vi.mocked(useConfigVision) +const mockUseAvailableVarList = vi.mocked(useAvailableVarList) + +const createPayload = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({ + type: BlockEnum.QuestionClassifier, + title: 'Question Classifier', + desc: '', + model: { + provider: '', + name: '', + mode: AppModeEnum.CHAT, + completion_params: {}, + }, + classes: [{ id: 'topic-1', name: 'Billing questions', label: 'CLASS 1' }], + query_variable_selector: ['start-node', 'sys.query'], + instruction: 'Route by topic', + vision: { + enabled: false, + }, + ...overrides, +}) + +describe('question-classifier/use-config', () => { + const setInputs = vi.fn() + let latestVisionOptions: { + onChange: (payload: QuestionClassifierNodeType['vision']) => void + } | null = null + + beforeEach(() => { + vi.clearAllMocks() + latestVisionOptions = null + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false }) + mockUseIsChatMode.mockReturnValue(true) + mockUseWorkflow.mockReturnValue({ + getBeforeNodesInSameBranch: vi.fn(() => []), + } as unknown as ReturnType<typeof useWorkflow>) + mockUseNodeCrud.mockReturnValue({ + inputs: createPayload(), + setInputs, + }) + mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ + modelList: [], + defaultModel: undefined, + currentProvider: undefined, + currentModel: undefined, + } as ReturnType<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>) + mockUseStore.mockImplementation((selector) => { + return selector({ nodesDefaultConfigs: {} } as never) + }) + mockUseConfigVision.mockImplementation((_model, options) => { + latestVisionOptions = options as typeof latestVisionOptions + return { + isVisionModel: false, + handleVisionResolutionEnabledChange: vi.fn(), + handleVisionResolutionChange: vi.fn(), + handleModelChanged: vi.fn(() => { + latestVisionOptions?.onChange({ enabled: false }) + }), + } + }) + mockUseAvailableVarList.mockReturnValue({ + availableVars: [], + availableNodes: [], + availableNodesWithParent: [], + } as unknown as ReturnType<typeof useAvailableVarList>) + }) + + it('preserves the selected model when the vision follow-up updates after model selection', async () => { + const { result } = renderHook(() => useConfig('question-classifier-node', createPayload())) + + act(() => { + result.current.handleModelChanged({ + provider: 'openai', + modelId: 'gpt-4o', + mode: AppModeEnum.CHAT, + }) + }) + + await waitFor(() => { + expect(setInputs).toHaveBeenLastCalledWith(expect.objectContaining({ + model: expect.objectContaining({ + provider: 'openai', + name: 'gpt-4o', + mode: AppModeEnum.CHAT, + }), + vision: { + enabled: false, + }, + })) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-item.spec.tsx b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-item.spec.tsx index 6ba88016e0..ab1d0e0224 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-item.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-item.spec.tsx @@ -3,8 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ClassItem from '../class-item' -const mockEditorRender = vi.hoisted(() => vi.fn()) - vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({ __esModule: true, default: () => ({ @@ -16,13 +14,12 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({ __esModule: true, default: (props: { - title: string + title: React.ReactNode value: string onChange: (value: string) => void onRemove: () => void showRemove?: boolean }) => { - mockEditorRender(props) return ( <div> <div>{props.title}</div> @@ -70,9 +67,32 @@ describe('question-classifier/class-item', () => { name: 'Billing questions updated', }) expect(onRemove).toHaveBeenCalledTimes(1) - expect(mockEditorRender).toHaveBeenCalledWith(expect.objectContaining({ - title: 'workflow.nodes.questionClassifiers.class 1', - value: 'Billing questions', - })) + expect(screen.getByRole('button', { name: 'CLASS 1' })).toBeInTheDocument() + }) + + it('preserves a custom label when editing the classifier name', () => { + const onChange = vi.fn() + + render( + <ClassItem + nodeId="node-1" + payload={{ id: 'topic-1', name: 'Billing questions', label: 'Billing' } as Topic} + onChange={onChange} + onRemove={vi.fn()} + index={1} + filterVar={() => true} + />, + ) + + fireEvent.change(screen.getByLabelText('class-name'), { + target: { value: 'Billing questions updated' }, + }) + + expect(onChange).toHaveBeenCalledWith({ + id: 'topic-1', + name: 'Billing questions updated', + label: 'Billing', + }) + expect(screen.getByRole('button', { name: 'Billing' })).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx index 1e90d4590d..139c314def 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx @@ -2,12 +2,13 @@ import type { FC } from 'react' import type { Topic } from '../types' import type { ValueSelector, Var } from '@/app/components/workflow/types' -import { uniqueId } from 'es-toolkit/compat' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' +import { getCanonicalClassLabel, getDisplayClassLabel } from './class-label-utils' const i18nPrefix = 'nodes.questionClassifiers' @@ -21,6 +22,7 @@ type Props = { index: number readonly?: boolean filterVar: (payload: Var, valueSelector: ValueSelector) => boolean + onLabelEditStart?: () => void } const ClassItem: FC<Props> = ({ @@ -33,18 +35,49 @@ const ClassItem: FC<Props> = ({ index, readonly, filterVar, + onLabelEditStart, }) => { const { t } = useTranslation() - const [instanceId, setInstanceId] = useState(() => uniqueId()) + const reactId = useId() + const [isEditingLabel, setIsEditingLabel] = useState(false) + const [draftLabel, setDraftLabel] = useState('') + const labelInputRef = useRef<HTMLInputElement>(null) + const displayLabel = getDisplayClassLabel(payload.label, index, t) + const instanceId = `${nodeId}-${reactId}` useEffect(() => { - setInstanceId(`${nodeId}-${uniqueId()}`) - }, [nodeId]) + if (isEditingLabel) + labelInputRef.current?.select() + }, [isEditingLabel]) const handleNameChange = useCallback((value: string) => { onChange({ ...payload, name: value }) }, [onChange, payload]) + const handleLabelSave = useCallback((nextValue: string) => { + const normalizedLabel = getCanonicalClassLabel(nextValue, index, t) + setIsEditingLabel(false) + setDraftLabel(normalizedLabel) + const shouldPersistLabel = normalizedLabel !== displayLabel + || (payload.label !== undefined && payload.label !== normalizedLabel) + if (shouldPersistLabel) + onChange({ ...payload, label: normalizedLabel }) + }, [displayLabel, index, onChange, payload, t]) + + const handleLabelCancel = useCallback(() => { + setDraftLabel(displayLabel) + setIsEditingLabel(false) + }, [displayLabel]) + + const handleLabelEditStart = useCallback(() => { + if (readonly) + return + + setDraftLabel(displayLabel) + setIsEditingLabel(true) + onLabelEditStart?.() + }, [displayLabel, onLabelEditStart, readonly]) + const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, { onlyLeafNodeVar: false, hideChatVar: false, @@ -52,11 +85,65 @@ const ClassItem: FC<Props> = ({ filterVar, }) + const title = isEditingLabel + ? ( + <input + ref={labelInputRef} + value={draftLabel} + aria-label={t(`${i18nPrefix}.labelEditorAriaLabel`, { ns: 'workflow' })} + className={cn( + 'h-6 w-full rounded-md border border-divider-regular bg-components-input-bg-normal px-2 text-xs font-semibold text-text-secondary ring-0 outline-none', + 'focus:border-components-input-border-active', + )} + onChange={event => setDraftLabel(event.target.value)} + onBlur={() => handleLabelSave(draftLabel)} + onClick={event => event.stopPropagation()} + onDoubleClick={event => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleLabelSave(draftLabel) + } + + if (event.key === 'Escape') { + event.preventDefault() + handleLabelCancel() + } + }} + autoFocus + /> + ) + : readonly + ? ( + <div className="-ml-1 px-1 py-0.5 text-left text-xs leading-4 font-semibold text-text-secondary"> + {displayLabel} + </div> + ) + : ( + <button + type="button" + aria-label={displayLabel} + className={cn( + '-ml-1 rounded px-1 py-0.5 text-left text-xs leading-4 font-semibold text-text-secondary transition-colors', + 'cursor-text hover:bg-state-base-hover', + )} + onDoubleClick={handleLabelEditStart} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleLabelEditStart() + } + }} + > + {displayLabel} + </button> + ) + return ( <Editor className={className} headerClassName={headerClassName} - title={`${t(`${i18nPrefix}.class`, { ns: 'workflow' })} ${index}`} + title={title} placeholder={t(`${i18nPrefix}.topicPlaceholder`, { ns: 'workflow' })!} value={payload.name} onChange={handleNameChange} diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-label-utils.ts b/web/app/components/workflow/nodes/question-classifier/components/class-label-utils.ts new file mode 100644 index 0000000000..aea17588b8 --- /dev/null +++ b/web/app/components/workflow/nodes/question-classifier/components/class-label-utils.ts @@ -0,0 +1,66 @@ +'use client' +import type { TFunction } from 'i18next' + +const i18nPrefix = 'nodes.questionClassifiers' +const LEGACY_DEFAULT_LABEL_PREFIX = 'CLASS' +const DEFAULT_EQUIVALENT_PREFIXES = ['CLASS', '分类', '分類', 'クラス'] + +const getCanonicalDefaultClassLabel = (index: number) => `${LEGACY_DEFAULT_LABEL_PREFIX} ${index}` + +const getTranslatedDefaultClassLabel = (t: TFunction, index: number) => { + const translated = t(`${i18nPrefix}.defaultLabel`, { ns: 'workflow', index }) + if (typeof translated !== 'string') + return undefined + + const resolvedLabel = translated.replace('{{index}}', String(index)) + const rawWorkflowKey = `workflow.${i18nPrefix}.defaultLabel` + const rawKey = `${i18nPrefix}.defaultLabel` + if ( + resolvedLabel === rawWorkflowKey + || resolvedLabel === rawKey + || resolvedLabel.startsWith(`${rawWorkflowKey}:`) + || resolvedLabel.startsWith(`${rawKey}:`) + ) { + return undefined + } + + return resolvedLabel +} + +const normalizeClassLabel = (label?: string | null) => label?.trim() ?? '' + +export const getDefaultClassLabel = (_t: TFunction, index: number) => getCanonicalDefaultClassLabel(index) + +export const getDisplayClassLabel = ( + label: string | undefined, + index: number, + t: TFunction, +) => normalizeClassLabel(label) || getTranslatedDefaultClassLabel(t, index) || getCanonicalDefaultClassLabel(index) + +export const isDefaultClassLabel = ( + label: string | undefined, + index: number, + t: TFunction, +) => { + const normalizedLabel = normalizeClassLabel(label) + if (!normalizedLabel) + return true + + return DEFAULT_EQUIVALENT_PREFIXES.some(prefix => normalizedLabel === `${prefix} ${index}`) + || normalizedLabel === getTranslatedDefaultClassLabel(t, index) +} + +export const getCanonicalClassLabel = ( + label: string | undefined, + index: number, + t: TFunction, +) => { + const normalizedLabel = normalizeClassLabel(label) + if (!normalizedLabel) + return getCanonicalDefaultClassLabel(index) + + if (isDefaultClassLabel(normalizedLabel, index, t)) + return getCanonicalDefaultClassLabel(index) + + return normalizedLabel +} diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx index fead81fb19..1a80266de5 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx @@ -14,8 +14,10 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid import { useEdgesInteractions } from '../../../hooks' import AddButton from '../../_base/components/add-button' import Item from './class-item' +import { getDefaultClassLabel, isDefaultClassLabel } from './class-label-utils' const i18nPrefix = 'nodes.questionClassifiers' +const INLINE_LABEL_HINT_STORAGE_KEY = 'question-classifier-inline-label-hint-dismissed' type Props = { nodeId: string @@ -40,6 +42,17 @@ const ClassList: FC<Props> = ({ const [shouldScrollToEnd, setShouldScrollToEnd] = useState(false) const prevListLength = useRef(list.length) const [collapsed, setCollapsed] = useState(false) + const [isRenameHintDismissed, setIsRenameHintDismissed] = useState(() => { + if (typeof window === 'undefined') + return true + + try { + return window.localStorage.getItem(INLINE_LABEL_HINT_STORAGE_KEY) === 'true' + } + catch { + return false + } + }) const handleClassChange = useCallback((index: number) => { return (value: Topic) => { @@ -52,13 +65,17 @@ const ClassList: FC<Props> = ({ const handleAddClass = useCallback(() => { const newList = produce(list, (draft) => { - draft.push({ id: `${Date.now()}`, name: '' }) + draft.push({ + id: `${Date.now()}`, + name: '', + label: getDefaultClassLabel(t, draft.length + 1), + }) }) onChange(newList) setShouldScrollToEnd(true) if (collapsed) setCollapsed(false) - }, [list, onChange, collapsed]) + }, [collapsed, list, onChange, t]) const handleRemoveClass = useCallback((index: number) => { return () => { @@ -72,7 +89,6 @@ const ClassList: FC<Props> = ({ const topicCount = list.length - // Scroll to the newly added item after the list updates useEffect(() => { if (shouldScrollToEnd && list.length > prevListLength.current) setShouldScrollToEnd(false) @@ -83,6 +99,22 @@ const ClassList: FC<Props> = ({ setCollapsed(!collapsed) }, [collapsed]) + const dismissRenameHint = useCallback(() => { + if (isRenameHintDismissed) + return + + setIsRenameHintDismissed(true) + try { + window.localStorage.setItem(INLINE_LABEL_HINT_STORAGE_KEY, 'true') + } + catch { + } + }, [isRenameHintDismissed]) + + const shouldShowRenameHint = !readonly && !isRenameHintDismissed && list.some((item, index) => { + return isDefaultClassLabel(item.label, index + 1, t) + }) + return ( <> <div className="mb-2 flex items-center justify-between" onClick={handleCollapse}> @@ -100,6 +132,11 @@ const ClassList: FC<Props> = ({ )} </div> </div> + {shouldShowRenameHint && ( + <div className="mb-2 rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-tertiary"> + {t(`${i18nPrefix}.renameHint`, { ns: 'workflow' })} + </div> + )} {!collapsed && ( <div @@ -117,12 +154,7 @@ const ClassList: FC<Props> = ({ > { list.map((item, index) => { - const canDrag = (() => { - if (readonly) - return false - - return topicCount >= 2 - })() + const canDrag = !readonly && topicCount >= 2 return ( <div key={item.id} @@ -153,6 +185,7 @@ const ClassList: FC<Props> = ({ index={index + 1} readonly={readonly} filterVar={filterVar} + onLabelEditStart={dismissRenameHint} /> </div> </div> diff --git a/web/app/components/workflow/nodes/question-classifier/default.ts b/web/app/components/workflow/nodes/question-classifier/default.ts index 1ee0d3e8d1..c8f882ae31 100644 --- a/web/app/components/workflow/nodes/question-classifier/default.ts +++ b/web/app/components/workflow/nodes/question-classifier/default.ts @@ -1,3 +1,4 @@ +import type { TFunction } from 'i18next' import type { NodeDefault } from '../../types' import type { QuestionClassifierNodeType } from './types' import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types' @@ -28,10 +29,12 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = { { id: '1', name: '', + label: 'CLASS 1', }, { id: '2', name: '', + label: 'CLASS 2', }, ], _targetBranches: [ @@ -48,7 +51,7 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = { enabled: false, }, }, - checkValid(payload: QuestionClassifierNodeType, t: any) { + checkValid(payload: QuestionClassifierNodeType, t: TFunction<'workflow'>) { let errorMessages = '' if (!errorMessages && (!payload.query_variable_selector || payload.query_variable_selector.length === 0)) errorMessages = t(`${i18nPrefix}errorMsg.fieldRequired`, { ns: 'workflow', field: t(`${i18nPrefix}nodes.questionClassifiers.inputVars`, { ns: 'workflow' }) }) diff --git a/web/app/components/workflow/nodes/question-classifier/node.tsx b/web/app/components/workflow/nodes/question-classifier/node.tsx index 305eacc204..ac932f4767 100644 --- a/web/app/components/workflow/nodes/question-classifier/node.tsx +++ b/web/app/components/workflow/nodes/question-classifier/node.tsx @@ -11,19 +11,19 @@ import { import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { NodeSourceHandle } from '../_base/components/node-handle' import ReadonlyInputWithSelectVar from '../_base/components/readonly-input-with-select-var' - -const i18nPrefix = 'nodes.questionClassifiers' +import { getDisplayClassLabel } from './components/class-label-utils' const MAX_CLASS_TEXT_LENGTH = 50 type TruncatedClassItemProps = { - topic: { id: string, name: string } + topic: { id: string, name: string, label?: string } index: number nodeId: string t: TFunction } const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId, t }) => { + const displayLabel = getDisplayClassLabel(topic.label, index + 1, t) const truncatedText = topic.name.length > MAX_CLASS_TEXT_LENGTH ? `${topic.name.slice(0, MAX_CLASS_TEXT_LENGTH)}...` : topic.name @@ -42,8 +42,8 @@ const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId, return ( <div className="flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-[5px] py-[3px]"> - <div className="system-2xs-semibold-uppercase text-text-secondary uppercase"> - {`${t(`${i18nPrefix}.class`, { ns: 'workflow' })} ${index + 1}`} + <div className="text-xs leading-4 font-semibold text-text-secondary"> + {displayLabel} </div> {shouldShowTooltip ? ( diff --git a/web/app/components/workflow/nodes/question-classifier/panel.tsx b/web/app/components/workflow/nodes/question-classifier/panel.tsx index 8d0bd4665f..624952203b 100644 --- a/web/app/components/workflow/nodes/question-classifier/panel.tsx +++ b/web/app/components/workflow/nodes/question-classifier/panel.tsx @@ -127,6 +127,11 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ type="string" description={t(`${i18nPrefix}.outputVars.className`, { ns: 'workflow' })} /> + <VarItem + name="class_label" + type="string" + description={t(`${i18nPrefix}.outputVars.classLabel`, { ns: 'workflow' })} + /> <VarItem name="usage" type="object" diff --git a/web/app/components/workflow/nodes/question-classifier/types.ts b/web/app/components/workflow/nodes/question-classifier/types.ts index ddc16b4501..3f11299aeb 100644 --- a/web/app/components/workflow/nodes/question-classifier/types.ts +++ b/web/app/components/workflow/nodes/question-classifier/types.ts @@ -3,6 +3,7 @@ import type { CommonNodeType, Memory, ModelConfig, ValueSelector, VisionSetting export type Topic = { id: string name: string + label?: string } export type QuestionClassifierNodeType = CommonNodeType & { diff --git a/web/app/components/workflow/nodes/question-classifier/use-config.ts b/web/app/components/workflow/nodes/question-classifier/use-config.ts index 5a46897de5..9200b1bb3f 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-config.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-config.ts @@ -1,7 +1,7 @@ import type { Memory, ValueSelector, Var } from '../../types' import type { QuestionClassifierNodeType, Topic } from './types' import { produce } from 'immer' -import { useCallback, useEffect, useRef, useState } from 'react' +import { startTransition, useCallback, useEffect, useRef } from 'react' import { useUpdateNodeInternals } from 'reactflow' import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -26,13 +26,17 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const { getBeforeNodesInSameBranch } = useWorkflow() const startNode = getBeforeNodesInSameBranch(id).find(node => node.data.type === BlockEnum.Start) const startNodeId = startNode?.id - const { inputs, setInputs } = useNodeCrud<QuestionClassifierNodeType>(id, payload) + const { inputs, setInputs: doSetInputs } = useNodeCrud<QuestionClassifierNodeType>(id, payload) const inputRef = useRef(inputs) + const setInputs = useCallback((newInputs: QuestionClassifierNodeType) => { + doSetInputs(newInputs) + inputRef.current = newInputs + }, [doSetInputs]) useEffect(() => { inputRef.current = inputs }, [inputs]) - const [modelChanged, setModelChanged] = useState(false) + const isHandlingModelChangeRef = useRef(false) const { currentProvider, currentModel, @@ -42,6 +46,13 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const modelMode = inputs.model?.mode const isChatModel = modelMode === AppModeEnum.CHAT + const handleVisionChange = useCallback((newPayload: QuestionClassifierNodeType['vision']) => { + const newInputs = produce(inputRef.current, (draft) => { + draft.vision = newPayload + }) + setInputs(newInputs) + }, [setInputs]) + const { isVisionModel, handleVisionResolutionEnabledChange, @@ -49,12 +60,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { handleModelChanged: handleVisionConfigAfterModelChanged, } = useConfigVision(model, { payload: inputs.vision, - onChange: (newPayload) => { - const newInputs = produce(inputs, (draft) => { - draft.vision = newPayload - }) - setInputs(newInputs) - }, + onChange: handleVisionChange, }) const handleModelChanged = useCallback((model: { provider: string, modelId: string, mode?: string }) => { @@ -63,21 +69,23 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { draft.model.name = model.modelId draft.model.mode = model.mode! }) + isHandlingModelChangeRef.current = true setInputs(newInputs) - setModelChanged(true) }, [setInputs]) useEffect(() => { if (currentProvider?.provider && currentModel?.model && !model.provider) { - handleModelChanged({ - provider: currentProvider?.provider, - modelId: currentModel?.model, - mode: currentModel?.model_properties?.mode as string, + startTransition(() => { + handleModelChanged({ + provider: currentProvider?.provider, + modelId: currentModel?.model, + mode: currentModel?.model_properties?.mode as string | undefined, + }) }) } }, [model.provider, currentProvider, currentModel, handleModelChanged]) - const handleCompletionParamsChange = useCallback((newParams: Record<string, any>) => { + const handleCompletionParamsChange = useCallback((newParams: Record<string, unknown>) => { const newInputs = produce(inputs, (draft) => { draft.model.completion_params = newParams }) @@ -86,11 +94,13 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { // change to vision model to set vision enabled, else disabled useEffect(() => { - if (!modelChanged) + if (!isHandlingModelChangeRef.current) return - setModelChanged(false) - handleVisionConfigAfterModelChanged() - }, [isVisionModel, modelChanged]) + isHandlingModelChangeRef.current = false + startTransition(() => { + handleVisionConfigAfterModelChanged() + }) + }, [handleVisionConfigAfterModelChanged, isVisionModel]) const handleQueryVarChange = useCallback((newVar: ValueSelector | string) => { const newInputs = produce(inputs, (draft) => { @@ -101,22 +111,58 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { useEffect(() => { const isReady = defaultConfig && Object.keys(defaultConfig).length > 0 - if (isReady) { - let query_variable_selector: ValueSelector = [] - if (isChatMode && inputs.query_variable_selector.length === 0 && startNodeId) - query_variable_selector = [startNodeId, 'sys.query'] - setInputs({ - ...inputs, - ...defaultConfig, - query_variable_selector: inputs.query_variable_selector.length > 0 ? inputs.query_variable_selector : query_variable_selector, - }) - } - }, [defaultConfig]) + if (!isReady) + return - const handleClassesChange = useCallback((newClasses: any) => { + const currentInputs = inputRef.current + let shouldUpdate = false + + const nextInputs = produce(currentInputs, (draft) => { + if (!draft.model) + draft.model = defaultConfig.model + + if (!draft.classes) + draft.classes = defaultConfig.classes + + if (!draft._targetBranches) + draft._targetBranches = defaultConfig._targetBranches + + if (!draft.vision) + draft.vision = defaultConfig.vision + + if (draft.query_variable_selector.length === 0 && isChatMode && startNodeId) { + draft.query_variable_selector = [startNodeId, 'sys.query'] + shouldUpdate = true + } + + if (!currentInputs.model && defaultConfig.model) + shouldUpdate = true + + if (!currentInputs.classes && defaultConfig.classes) + shouldUpdate = true + + if (!currentInputs._targetBranches && defaultConfig._targetBranches) + shouldUpdate = true + + if (!currentInputs.vision && defaultConfig.vision) + shouldUpdate = true + }) + + if (!shouldUpdate) + return + + startTransition(() => { + setInputs(nextInputs) + }) + }, [defaultConfig, isChatMode, setInputs, startNodeId]) + + const handleClassesChange = useCallback((newClasses: Topic[]) => { const newInputs = produce(inputs, (draft) => { draft.classes = newClasses - draft._targetBranches = newClasses + draft._targetBranches = newClasses.map((item: Topic) => ({ + id: item.id, + name: item.name, + })) }) setInputs(newInputs) }, [inputs, setInputs]) @@ -170,7 +216,13 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const handleSortTopic = useCallback((newTopics: (Topic & { id: string })[]) => { const newInputs = produce(inputs, (draft) => { - draft.classes = newTopics.filter(Boolean).map(item => ({ + const sortedTopics = newTopics.filter(Boolean) + draft.classes = sortedTopics.map(item => ({ + id: item.id, + name: item.name, + label: item.label, + })) + draft._targetBranches = sortedTopics.map(item => ({ id: item.id, name: item.name, })) diff --git a/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/__tests__/node.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/__tests__/node.spec.tsx index 82cede1d85..463c4dd43d 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/__tests__/node.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/__tests__/node.spec.tsx @@ -28,7 +28,7 @@ describe('workflow preview question classifier node', () => { title: 'Classifier', desc: '', classes: [ - { id: 'class-1', name: 'Billing' }, + { id: 'class-1', name: 'Billing', label: 'Billing label' }, { id: 'class-2', name: 'Support' }, ], } as never, @@ -38,7 +38,8 @@ describe('workflow preview question classifier node', () => { <Node {...props} />, ) - expect(getByText('workflow.nodes.questionClassifiers.class 1')).toBeInTheDocument() + expect(getByText('Billing label')).toBeInTheDocument() + expect(getByText('CLASS 2')).toBeInTheDocument() expect(container.querySelector('[data-handleid="class-1"]')).toBeInTheDocument() expect(container.querySelector('[data-handleid="class-2"]')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/node.tsx b/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/node.tsx index d0e23f7823..9884990309 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/node.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/node.tsx @@ -4,10 +4,9 @@ import type { QuestionClassifierNodeType } from '@/app/components/workflow/nodes import * as React from 'react' import { useTranslation } from 'react-i18next' import InfoPanel from '@/app/components/workflow/nodes/_base/components/info-panel' +import { getDisplayClassLabel } from '@/app/components/workflow/nodes/question-classifier/components/class-label-utils' import { NodeSourceHandle } from '../../node-handle' -const i18nPrefix = 'nodes.questionClassifiers' - const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => { const { t } = useTranslation() const { data } = props @@ -24,7 +23,7 @@ const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => { className="relative" > <InfoPanel - title={`${t(`${i18nPrefix}.class`, { ns: 'workflow' })} ${index + 1}`} + title={getDisplayClassLabel(topic.label, index + 1, t)} content="" /> <NodeSourceHandle diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 3bb285d501..7b1ad1605a 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Advanced Setting", "nodes.questionClassifiers.class": "Class", "nodes.questionClassifiers.classNamePlaceholder": "Write your class name", + "nodes.questionClassifiers.defaultLabel": "CLASS {{index}}", "nodes.questionClassifiers.inputVars": "Input Variables", "nodes.questionClassifiers.instruction": "Instruction", "nodes.questionClassifiers.instructionPlaceholder": "Write your instruction", "nodes.questionClassifiers.instructionTip": "Input additional instructions to help the question classifier better understand how to categorize questions.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Class label editor", "nodes.questionClassifiers.model": "model", + "nodes.questionClassifiers.outputVars.classLabel": "Class Label", "nodes.questionClassifiers.outputVars.className": "Class Name", "nodes.questionClassifiers.outputVars.usage": "Model Usage Information", + "nodes.questionClassifiers.renameHint": "Double-click class title to rename", "nodes.questionClassifiers.topicName": "Topic Name", "nodes.questionClassifiers.topicPlaceholder": "Write your topic name", "nodes.start.builtInVar": "Built-in Variables", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 1154a5baba..f00d49f6bf 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "高度な設定", "nodes.questionClassifiers.class": "クラス", "nodes.questionClassifiers.classNamePlaceholder": "クラス名を入力してください", + "nodes.questionClassifiers.defaultLabel": "クラス {{index}}", "nodes.questionClassifiers.inputVars": "入力変数", "nodes.questionClassifiers.instruction": "指示", "nodes.questionClassifiers.instructionPlaceholder": "指示を入力してください", "nodes.questionClassifiers.instructionTip": "質問分類器が質問をどのように分類するかをよりよく理解するための追加の指示を入力します。", + "nodes.questionClassifiers.labelEditorAriaLabel": "クラスラベルエディター", "nodes.questionClassifiers.model": "モデル", + "nodes.questionClassifiers.outputVars.classLabel": "クラスラベル", "nodes.questionClassifiers.outputVars.className": "クラス名", "nodes.questionClassifiers.outputVars.usage": "モデル使用量", + "nodes.questionClassifiers.renameHint": "クラスタイトルをダブルクリックして名前を変更", "nodes.questionClassifiers.topicName": "トピック名", "nodes.questionClassifiers.topicPlaceholder": "トピック名を入力してください", "nodes.start.builtInVar": "組み込み変数", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index ac3a27af11..6536f5ccdc 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "高级设置", "nodes.questionClassifiers.class": "分类", "nodes.questionClassifiers.classNamePlaceholder": "输入你的分类名称", + "nodes.questionClassifiers.defaultLabel": "分类 {{index}}", "nodes.questionClassifiers.inputVars": "输入变量", "nodes.questionClassifiers.instruction": "指令", "nodes.questionClassifiers.instructionPlaceholder": "在这里输入你的指令", "nodes.questionClassifiers.instructionTip": "你可以输入额外的附加指令,帮助问题分类器更好的理解如何分类", + "nodes.questionClassifiers.labelEditorAriaLabel": "分类标签编辑器", "nodes.questionClassifiers.model": "模型", + "nodes.questionClassifiers.outputVars.classLabel": "分类标签", "nodes.questionClassifiers.outputVars.className": "分类名称", "nodes.questionClassifiers.outputVars.usage": "模型用量信息", + "nodes.questionClassifiers.renameHint": "双击分类标题以重命名", "nodes.questionClassifiers.topicName": "主题内容", "nodes.questionClassifiers.topicPlaceholder": "在这里输入你的主题内容", "nodes.start.builtInVar": "内置变量", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 9d296250db..9544a4ba2e 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "高級設置", "nodes.questionClassifiers.class": "分類", "nodes.questionClassifiers.classNamePlaceholder": "輸入你的分類名稱", + "nodes.questionClassifiers.defaultLabel": "分類 {{index}}", "nodes.questionClassifiers.inputVars": "輸入變數", "nodes.questionClassifiers.instruction": "指令", "nodes.questionClassifiers.instructionPlaceholder": "在這裡輸入你的指令", "nodes.questionClassifiers.instructionTip": "你可以輸入額外的附加指令,幫助問題分類器更好的理解如何分類", + "nodes.questionClassifiers.labelEditorAriaLabel": "分類標籤編輯器", "nodes.questionClassifiers.model": "模型", + "nodes.questionClassifiers.outputVars.classLabel": "分類標籤", "nodes.questionClassifiers.outputVars.className": "分類名稱", "nodes.questionClassifiers.outputVars.usage": "模型用量信息", + "nodes.questionClassifiers.renameHint": "雙擊分類標題以重新命名", "nodes.questionClassifiers.topicName": "主題內容", "nodes.questionClassifiers.topicPlaceholder": "在這裡輸入你的主題內容", "nodes.start.builtInVar": "內置變數", From 9a2bea9287af34e62a78fe9582a96ce7131bf508 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 21:23:31 +0800 Subject: [PATCH 20/53] chore(i18n): sync translations with en-US (#35994) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- web/i18n/ar-TN/workflow.json | 4 ++++ web/i18n/de-DE/workflow.json | 4 ++++ web/i18n/es-ES/workflow.json | 4 ++++ web/i18n/fa-IR/workflow.json | 4 ++++ web/i18n/fr-FR/workflow.json | 4 ++++ web/i18n/hi-IN/workflow.json | 4 ++++ web/i18n/id-ID/workflow.json | 4 ++++ web/i18n/it-IT/workflow.json | 4 ++++ web/i18n/ko-KR/workflow.json | 4 ++++ web/i18n/nl-NL/workflow.json | 4 ++++ web/i18n/pl-PL/workflow.json | 4 ++++ web/i18n/pt-BR/workflow.json | 4 ++++ web/i18n/ro-RO/workflow.json | 4 ++++ web/i18n/ru-RU/workflow.json | 4 ++++ web/i18n/sl-SI/workflow.json | 4 ++++ web/i18n/th-TH/workflow.json | 4 ++++ web/i18n/tr-TR/workflow.json | 4 ++++ web/i18n/uk-UA/workflow.json | 4 ++++ web/i18n/vi-VN/workflow.json | 4 ++++ 19 files changed, 76 insertions(+) diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index cc6c533ca1..4df2de3173 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "إعدادات متقدمة", "nodes.questionClassifiers.class": "فئة", "nodes.questionClassifiers.classNamePlaceholder": "اكتب اسم الفئة الخاصة بك", + "nodes.questionClassifiers.defaultLabel": "الفئة {{index}}", "nodes.questionClassifiers.inputVars": "متغيرات الإدخال", "nodes.questionClassifiers.instruction": "تعليمات", "nodes.questionClassifiers.instructionPlaceholder": "اكتب تعليماتك", "nodes.questionClassifiers.instructionTip": "أدخل تعليمات إضافية لمساعدة مصنف الأسئلة على فهم كيفية تصنيف الأسئلة بشكل أفضل.", + "nodes.questionClassifiers.labelEditorAriaLabel": "محرر تسمية الفئة", "nodes.questionClassifiers.model": "النموذج", + "nodes.questionClassifiers.outputVars.classLabel": "تسمية الفئة", "nodes.questionClassifiers.outputVars.className": "اسم الفئة", "nodes.questionClassifiers.outputVars.usage": "معلومات استخدام النموذج", + "nodes.questionClassifiers.renameHint": "انقر نقراً مزدوجاً على عنوان الفئة لإعادة التسمية", "nodes.questionClassifiers.topicName": "اسم الموضوع", "nodes.questionClassifiers.topicPlaceholder": "اكتب اسم الموضوع الخاص بك", "nodes.start.builtInVar": "المتغيرات المدمجة", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index 426c023259..e00d300f01 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Erweiterte Einstellung", "nodes.questionClassifiers.class": "Klasse", "nodes.questionClassifiers.classNamePlaceholder": "Geben Sie Ihren Klassennamen ein", + "nodes.questionClassifiers.defaultLabel": "KLASSE {{index}}", "nodes.questionClassifiers.inputVars": "Eingabevariablen", "nodes.questionClassifiers.instruction": "Anweisung", "nodes.questionClassifiers.instructionPlaceholder": "Geben Sie Ihre Anweisung ein", "nodes.questionClassifiers.instructionTip": "Geben Sie zusätzliche Anweisungen ein, um dem Fragenklassifizierer zu helfen, besser zu verstehen, wie Fragen kategorisiert werden sollen.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Klassenbeschriftungseditor", "nodes.questionClassifiers.model": "Modell", + "nodes.questionClassifiers.outputVars.classLabel": "Klassenbezeichnung", "nodes.questionClassifiers.outputVars.className": "Klassennamen", "nodes.questionClassifiers.outputVars.usage": "Nutzungsinformationen des Modells", + "nodes.questionClassifiers.renameHint": "Doppelklicken Sie auf den Klassentitel, um ihn umzubenennen", "nodes.questionClassifiers.topicName": "Themenname", "nodes.questionClassifiers.topicPlaceholder": "Geben Sie Ihren Themennamen ein", "nodes.start.builtInVar": "Eingebaute Variablen", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index c55ffdfc1e..c12a6f5cdd 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Configuración avanzada", "nodes.questionClassifiers.class": "Clase", "nodes.questionClassifiers.classNamePlaceholder": "Escribe el nombre de tu clase", + "nodes.questionClassifiers.defaultLabel": "CLASE {{index}}", "nodes.questionClassifiers.inputVars": "Variables de entrada", "nodes.questionClassifiers.instruction": "Instrucción", "nodes.questionClassifiers.instructionPlaceholder": "Write your instruction", "nodes.questionClassifiers.instructionTip": "Input additional instructions to help the question classifier better understand how to categorize questions.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Editor de etiqueta de clase", "nodes.questionClassifiers.model": "modelo", + "nodes.questionClassifiers.outputVars.classLabel": "Etiqueta de clase", "nodes.questionClassifiers.outputVars.className": "Nombre de la clase", "nodes.questionClassifiers.outputVars.usage": "Información de uso del modelo", + "nodes.questionClassifiers.renameHint": "Haz doble clic en el título de la clase para renombrar", "nodes.questionClassifiers.topicName": "Nombre del tema", "nodes.questionClassifiers.topicPlaceholder": "Escribe el nombre de tu tema", "nodes.start.builtInVar": "Variables incorporadas", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index c23c781a04..d9cb4c2c40 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "تنظیمات پیشرفته", "nodes.questionClassifiers.class": "کلاس", "nodes.questionClassifiers.classNamePlaceholder": "نام کلاس را بنویسید", + "nodes.questionClassifiers.defaultLabel": "کلاس {{index}}", "nodes.questionClassifiers.inputVars": "متغیرهای ورودی", "nodes.questionClassifiers.instruction": "دستورالعمل", "nodes.questionClassifiers.instructionPlaceholder": "دستورالعمل خود را بنویسید", "nodes.questionClassifiers.instructionTip": "دستورالعمل اضافی برای کمک به مدل در دسته‌بندی دقیق‌تر سؤالات.", + "nodes.questionClassifiers.labelEditorAriaLabel": "ویرایشگر برچسب کلاس", "nodes.questionClassifiers.model": "مدل", + "nodes.questionClassifiers.outputVars.classLabel": "برچسب کلاس", "nodes.questionClassifiers.outputVars.className": "نام کلاس", "nodes.questionClassifiers.outputVars.usage": "اطلاعات مصرف مدل", + "nodes.questionClassifiers.renameHint": "برای تغییر نام، دوبار روی عنوان کلاس کلیک کنید", "nodes.questionClassifiers.topicName": "نام موضوع", "nodes.questionClassifiers.topicPlaceholder": "نام موضوع را بنویسید", "nodes.start.builtInVar": "متغیرهای داخلی", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index 727c3a91e6..cabed62c22 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Paramètre avancé", "nodes.questionClassifiers.class": "Classe", "nodes.questionClassifiers.classNamePlaceholder": "Écrivez le nom de votre classe", + "nodes.questionClassifiers.defaultLabel": "CLASSE {{index}}", "nodes.questionClassifiers.inputVars": "Variables de saisie", "nodes.questionClassifiers.instruction": "Instruction", "nodes.questionClassifiers.instructionPlaceholder": "Écrivez votre instruction", "nodes.questionClassifiers.instructionTip": "Entrez des instructions supplémentaires pour aider le classificateur de questions à mieux comprendre comment catégoriser les questions.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Éditeur de libellé de classe", "nodes.questionClassifiers.model": "modèle", + "nodes.questionClassifiers.outputVars.classLabel": "Libellé de classe", "nodes.questionClassifiers.outputVars.className": "Nom de la classe", "nodes.questionClassifiers.outputVars.usage": "Informations sur l'utilisation du modèle", + "nodes.questionClassifiers.renameHint": "Double-cliquez sur le titre de la classe pour renommer", "nodes.questionClassifiers.topicName": "Nom du sujet", "nodes.questionClassifiers.topicPlaceholder": "Écrivez le nom de votre sujet", "nodes.start.builtInVar": "Variables intégrées", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index 8b5ea73535..d16b9d0274 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "उन्नत सेटिंग", "nodes.questionClassifiers.class": "क्लास", "nodes.questionClassifiers.classNamePlaceholder": "अपना क्लास नाम लिखें", + "nodes.questionClassifiers.defaultLabel": "क्लास {{index}}", "nodes.questionClassifiers.inputVars": "इनपुट वेरिएबल्स", "nodes.questionClassifiers.instruction": "निर्देश", "nodes.questionClassifiers.instructionPlaceholder": "अपना निर्देश लिखें", "nodes.questionClassifiers.instructionTip": "प्रश्न वर्गीकरणकर्ता को प्रश्नों को वर्गीकृत करने के तरीके को समझने में मदद करने के लिए अतिरिक्त निर्देश दें।", + "nodes.questionClassifiers.labelEditorAriaLabel": "क्लास लेबल संपादक", "nodes.questionClassifiers.model": "मॉडल", + "nodes.questionClassifiers.outputVars.classLabel": "क्लास लेबल", "nodes.questionClassifiers.outputVars.className": "क्लास नाम", "nodes.questionClassifiers.outputVars.usage": "मॉडल उपयोग जानकारी", + "nodes.questionClassifiers.renameHint": "नाम बदलने के लिए क्लास शीर्षक पर डबल-क्लिक करें", "nodes.questionClassifiers.topicName": "विषय नाम", "nodes.questionClassifiers.topicPlaceholder": "अपना विषय नाम लिखें", "nodes.start.builtInVar": "निर्मित वेरिएबल्स", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 058c15334b..dffc4ac5bf 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Pengaturan Lanjutan", "nodes.questionClassifiers.class": "Kelas", "nodes.questionClassifiers.classNamePlaceholder": "Tulis nama kelas Anda", + "nodes.questionClassifiers.defaultLabel": "KELAS {{index}}", "nodes.questionClassifiers.inputVars": "Variabel Masukan", "nodes.questionClassifiers.instruction": "Ajaran", "nodes.questionClassifiers.instructionPlaceholder": "Tulis instruksi Anda", "nodes.questionClassifiers.instructionTip": "Masukkan instruksi tambahan untuk membantu pengklasifikasi pertanyaan lebih memahami cara mengkategorikan pertanyaan.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Editor label kelas", "nodes.questionClassifiers.model": "pola", + "nodes.questionClassifiers.outputVars.classLabel": "Label Kelas", "nodes.questionClassifiers.outputVars.className": "Nama Kelas", "nodes.questionClassifiers.outputVars.usage": "Informasi Penggunaan Model", + "nodes.questionClassifiers.renameHint": "Klik dua kali judul kelas untuk mengganti nama", "nodes.questionClassifiers.topicName": "Nama Topik", "nodes.questionClassifiers.topicPlaceholder": "Tulis nama topik Anda", "nodes.start.builtInVar": "Variabel bawaan", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index fbd3041fb9..fc86bf1b2f 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Impostazione Avanzata", "nodes.questionClassifiers.class": "Classe", "nodes.questionClassifiers.classNamePlaceholder": "Scrivi il nome della tua classe", + "nodes.questionClassifiers.defaultLabel": "CLASSE {{index}}", "nodes.questionClassifiers.inputVars": "Variabili di Input", "nodes.questionClassifiers.instruction": "Istruzione", "nodes.questionClassifiers.instructionPlaceholder": "Scrivi la tua istruzione", "nodes.questionClassifiers.instructionTip": "Inserisci istruzioni aggiuntive per aiutare il classificatore di domande a capire meglio come categorizzare le domande.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Editor etichetta classe", "nodes.questionClassifiers.model": "modello", + "nodes.questionClassifiers.outputVars.classLabel": "Etichetta classe", "nodes.questionClassifiers.outputVars.className": "Nome Classe", "nodes.questionClassifiers.outputVars.usage": "Informazioni sull'utilizzo del modello", + "nodes.questionClassifiers.renameHint": "Fai doppio clic sul titolo della classe per rinominare", "nodes.questionClassifiers.topicName": "Nome Argomento", "nodes.questionClassifiers.topicPlaceholder": "Scrivi il nome del tuo argomento", "nodes.start.builtInVar": "Variabili Integrate", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index a80c34b294..06225bb1a1 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "고급 설정", "nodes.questionClassifiers.class": "클래스", "nodes.questionClassifiers.classNamePlaceholder": "클래스 이름을 작성하세요", + "nodes.questionClassifiers.defaultLabel": "클래스 {{index}}", "nodes.questionClassifiers.inputVars": "입력 변수", "nodes.questionClassifiers.instruction": "지시", "nodes.questionClassifiers.instructionPlaceholder": "지시를 작성하세요", "nodes.questionClassifiers.instructionTip": "질문 분류기가 질문을 더 잘 분류할 수 있도록 추가 지시를 입력하세요.", + "nodes.questionClassifiers.labelEditorAriaLabel": "클래스 레이블 편집기", "nodes.questionClassifiers.model": "모델", + "nodes.questionClassifiers.outputVars.classLabel": "클래스 레이블", "nodes.questionClassifiers.outputVars.className": "클래스 이름", "nodes.questionClassifiers.outputVars.usage": "모델 사용 정보", + "nodes.questionClassifiers.renameHint": "클래스 제목을 더블 클릭하여 이름 변경", "nodes.questionClassifiers.topicName": "주제 이름", "nodes.questionClassifiers.topicPlaceholder": "주제 이름을 작성하세요", "nodes.start.builtInVar": "내장 변수", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index c8e8753eb4..86b6b89ea5 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Advanced Setting", "nodes.questionClassifiers.class": "Class", "nodes.questionClassifiers.classNamePlaceholder": "Write your class name", + "nodes.questionClassifiers.defaultLabel": "KLASSE {{index}}", "nodes.questionClassifiers.inputVars": "Input Variables", "nodes.questionClassifiers.instruction": "Instruction", "nodes.questionClassifiers.instructionPlaceholder": "Write your instruction", "nodes.questionClassifiers.instructionTip": "Input additional instructions to help the question classifier better understand how to categorize questions.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Klasse-labeleditor", "nodes.questionClassifiers.model": "model", + "nodes.questionClassifiers.outputVars.classLabel": "Klassenlabel", "nodes.questionClassifiers.outputVars.className": "Class Name", "nodes.questionClassifiers.outputVars.usage": "Model Usage Information", + "nodes.questionClassifiers.renameHint": "Dubbelklik op de klassentitel om de naam te wijzigen", "nodes.questionClassifiers.topicName": "Topic Name", "nodes.questionClassifiers.topicPlaceholder": "Write your topic name", "nodes.start.builtInVar": "Built-in Variables", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 805960a851..1d4ac0ec8a 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Zaawansowane ustawienia", "nodes.questionClassifiers.class": "Klasa", "nodes.questionClassifiers.classNamePlaceholder": "Napisz nazwę swojej klasy", + "nodes.questionClassifiers.defaultLabel": "KLASA {{index}}", "nodes.questionClassifiers.inputVars": "Zmienne wejściowe", "nodes.questionClassifiers.instruction": "Instrukcja", "nodes.questionClassifiers.instructionPlaceholder": "Napisz swoją instrukcję", "nodes.questionClassifiers.instructionTip": "Wprowadź dodatkowe instrukcje, aby pomóc klasyfikatorowi pytań lepiej zrozumieć, jak kategoryzować pytania.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Edytor etykiety klasy", "nodes.questionClassifiers.model": "model", + "nodes.questionClassifiers.outputVars.classLabel": "Etykieta klasy", "nodes.questionClassifiers.outputVars.className": "Nazwa klasy", "nodes.questionClassifiers.outputVars.usage": "Informacje o użyciu modelu", + "nodes.questionClassifiers.renameHint": "Kliknij dwukrotnie tytuł klasy, aby zmienić nazwę", "nodes.questionClassifiers.topicName": "Nazwa tematu", "nodes.questionClassifiers.topicPlaceholder": "Napisz nazwę swojego tematu", "nodes.start.builtInVar": "Wbudowane zmienne", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index de6c882e0c..77209ffaf1 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Configuração avançada", "nodes.questionClassifiers.class": "Classe", "nodes.questionClassifiers.classNamePlaceholder": "Escreva o nome da sua classe", + "nodes.questionClassifiers.defaultLabel": "CLASSE {{index}}", "nodes.questionClassifiers.inputVars": "Variáveis de entrada", "nodes.questionClassifiers.instruction": "Instrução", "nodes.questionClassifiers.instructionPlaceholder": "Escreva sua instrução", "nodes.questionClassifiers.instructionTip": "Insira instruções adicionais para ajudar o classificador de perguntas a entender melhor como categorizar perguntas.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Editor de rótulo de classe", "nodes.questionClassifiers.model": "modelo", + "nodes.questionClassifiers.outputVars.classLabel": "Rótulo da classe", "nodes.questionClassifiers.outputVars.className": "Nome da classe", "nodes.questionClassifiers.outputVars.usage": "Informações de uso do modelo", + "nodes.questionClassifiers.renameHint": "Clique duas vezes no título da classe para renomear", "nodes.questionClassifiers.topicName": "Nome do tópico", "nodes.questionClassifiers.topicPlaceholder": "Escreva o nome do seu tópico", "nodes.start.builtInVar": "Variáveis integradas", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index 7b551294e8..2151236135 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Setare avansată", "nodes.questionClassifiers.class": "Clasă", "nodes.questionClassifiers.classNamePlaceholder": "Scrieți numele clasei", + "nodes.questionClassifiers.defaultLabel": "CLASĂ {{index}}", "nodes.questionClassifiers.inputVars": "Variabile de intrare", "nodes.questionClassifiers.instruction": "Instrucțiune", "nodes.questionClassifiers.instructionPlaceholder": "Scrieți instrucțiunea", "nodes.questionClassifiers.instructionTip": "Introduceți instrucțiuni suplimentare pentru a ajuta clasificatorul de întrebări să înțeleagă mai bine cum să categorizeze întrebările.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Editor etichete clasă", "nodes.questionClassifiers.model": "model", + "nodes.questionClassifiers.outputVars.classLabel": "Etichetă clasă", "nodes.questionClassifiers.outputVars.className": "Nume clasă", "nodes.questionClassifiers.outputVars.usage": "Informații de utilizare a modelului", + "nodes.questionClassifiers.renameHint": "Faceți dublu clic pe titlul clasei pentru redenumire", "nodes.questionClassifiers.topicName": "Nume subiect", "nodes.questionClassifiers.topicPlaceholder": "Scrieți numele subiectului", "nodes.start.builtInVar": "Variabile integrate", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index aa5292f1cc..195f8ef988 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Расширенные настройки", "nodes.questionClassifiers.class": "Класс", "nodes.questionClassifiers.classNamePlaceholder": "Введите имя вашего класса", + "nodes.questionClassifiers.defaultLabel": "КЛАСС {{index}}", "nodes.questionClassifiers.inputVars": "Входные переменные", "nodes.questionClassifiers.instruction": "Инструкция", "nodes.questionClassifiers.instructionPlaceholder": "Введите вашу инструкцию", "nodes.questionClassifiers.instructionTip": "Введите дополнительные инструкции, чтобы помочь классификатору вопросов лучше понять, как классифицировать вопросы.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Редактор метки класса", "nodes.questionClassifiers.model": "модель", + "nodes.questionClassifiers.outputVars.classLabel": "Метка класса", "nodes.questionClassifiers.outputVars.className": "Имя класса", "nodes.questionClassifiers.outputVars.usage": "Информация об использовании модели", + "nodes.questionClassifiers.renameHint": "Дважды щёлкните по заголовку класса для переименования", "nodes.questionClassifiers.topicName": "Название темы", "nodes.questionClassifiers.topicPlaceholder": "Введите название вашей темы", "nodes.start.builtInVar": "Встроенные переменные", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index a7c2914626..3c3f4667f6 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Napredno nastavitev", "nodes.questionClassifiers.class": "Razred", "nodes.questionClassifiers.classNamePlaceholder": "Napiši ime svoje razredi", + "nodes.questionClassifiers.defaultLabel": "RAZRED {{index}}", "nodes.questionClassifiers.inputVars": "Vhodne spremenljivke", "nodes.questionClassifiers.instruction": "Navodilo", "nodes.questionClassifiers.instructionPlaceholder": "Napišite svoje navodilo", "nodes.questionClassifiers.instructionTip": "Vnesite dodatna navodila, ki bodo pomagala klasifikatorju vprašanj bolje razumeti, kako kategorizirati vprašanja.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Urejevalnik oznak razreda", "nodes.questionClassifiers.model": "model", + "nodes.questionClassifiers.outputVars.classLabel": "Oznaka razreda", "nodes.questionClassifiers.outputVars.className": "Ime razreda", "nodes.questionClassifiers.outputVars.usage": "Informacije o uporabi modela", + "nodes.questionClassifiers.renameHint": "Dvokliknite naslov razreda za preimenovanje", "nodes.questionClassifiers.topicName": "Ime teme", "nodes.questionClassifiers.topicPlaceholder": "Napišite ime svoje teme", "nodes.start.builtInVar": "Vgrajene spremenljivke", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index d8a9b53f2a..ecf8a1277f 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "การตั้งค่าขั้นสูง", "nodes.questionClassifiers.class": "ประเภท", "nodes.questionClassifiers.classNamePlaceholder": "เขียนชื่อชั้นเรียนของคุณ", + "nodes.questionClassifiers.defaultLabel": "ชั้นเรียน {{index}}", "nodes.questionClassifiers.inputVars": "ตัวแปรอินพุต", "nodes.questionClassifiers.instruction": "การสอน", "nodes.questionClassifiers.instructionPlaceholder": "เขียนคําแนะนําของคุณ", "nodes.questionClassifiers.instructionTip": "ป้อนคําแนะนําเพิ่มเติมเพื่อช่วยให้ตัวจําแนกคําถามเข้าใจวิธีจัดหมวดหมู่คําถามได้ดียิ่งขึ้น", + "nodes.questionClassifiers.labelEditorAriaLabel": "โปรแกรมแก้ไขป้ายชั้นเรียน", "nodes.questionClassifiers.model": "แบบ", + "nodes.questionClassifiers.outputVars.classLabel": "ป้ายชั้นเรียน", "nodes.questionClassifiers.outputVars.className": "ชื่อคลาส", "nodes.questionClassifiers.outputVars.usage": "ข้อมูลการใช้งานรุ่น", + "nodes.questionClassifiers.renameHint": "ดับเบิลคลิกที่ชื่อชั้นเรียนเพื่อเปลี่ยนชื่อ", "nodes.questionClassifiers.topicName": "ชื่อหัวข้อ", "nodes.questionClassifiers.topicPlaceholder": "เขียนชื่อหัวข้อของคุณ", "nodes.start.builtInVar": "ตัวแปรในตัว", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index af30ebf4ac..018c5a6077 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Gelişmiş Ayarlar", "nodes.questionClassifiers.class": "Sınıf", "nodes.questionClassifiers.classNamePlaceholder": "Sınıf adınızı yazın", + "nodes.questionClassifiers.defaultLabel": "SINIF {{index}}", "nodes.questionClassifiers.inputVars": "Giriş Değişkenleri", "nodes.questionClassifiers.instruction": "Talimat", "nodes.questionClassifiers.instructionPlaceholder": "Talimatınızı yazın", "nodes.questionClassifiers.instructionTip": "Soru sınıflandırıcının soruları nasıl kategorize edeceğini daha iyi anlamasına yardımcı olmak için ek talimatlar girin.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Sınıf etiketi düzenleyicisi", "nodes.questionClassifiers.model": "model", + "nodes.questionClassifiers.outputVars.classLabel": "Sınıf Etiketi", "nodes.questionClassifiers.outputVars.className": "Sınıf Adı", "nodes.questionClassifiers.outputVars.usage": "Model Kullanım Bilgileri", + "nodes.questionClassifiers.renameHint": "Yeniden adlandırmak için sınıf başlığına çift tıklayın", "nodes.questionClassifiers.topicName": "Konu Adı", "nodes.questionClassifiers.topicPlaceholder": "Konu adınızı yazın", "nodes.start.builtInVar": "Yerleşik Değişkenler", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 44d527618e..a7687edcc8 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Розширене налаштування", "nodes.questionClassifiers.class": "Клас", "nodes.questionClassifiers.classNamePlaceholder": "Напишіть назву вашого класу", + "nodes.questionClassifiers.defaultLabel": "КЛАС {{index}}", "nodes.questionClassifiers.inputVars": "Вхідні змінні", "nodes.questionClassifiers.instruction": "Інструкція", "nodes.questionClassifiers.instructionPlaceholder": "Напишіть вашу інструкцію", "nodes.questionClassifiers.instructionTip": "Введіть додаткові інструкції, щоб допомогти класифікатору запитань краще зрозуміти, як категоризувати запитання.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Редактор мітки класу", "nodes.questionClassifiers.model": "модель", + "nodes.questionClassifiers.outputVars.classLabel": "Мітка класу", "nodes.questionClassifiers.outputVars.className": "Назва класу", "nodes.questionClassifiers.outputVars.usage": "Інформація про використання моделі", + "nodes.questionClassifiers.renameHint": "Двічі клацніть заголовок класу, щоб перейменувати", "nodes.questionClassifiers.topicName": "Назва теми", "nodes.questionClassifiers.topicPlaceholder": "Напишіть назву вашої теми", "nodes.start.builtInVar": "Вбудовані змінні", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index 231c01bc82..db9d484b23 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -897,13 +897,17 @@ "nodes.questionClassifiers.advancedSetting": "Cài đặt nâng cao", "nodes.questionClassifiers.class": "Lớp", "nodes.questionClassifiers.classNamePlaceholder": "Viết tên lớp của bạn", + "nodes.questionClassifiers.defaultLabel": "LỚP {{index}}", "nodes.questionClassifiers.inputVars": "Biến đầu vào", "nodes.questionClassifiers.instruction": "Hướng dẫn", "nodes.questionClassifiers.instructionPlaceholder": "Viết hướng dẫn của bạn", "nodes.questionClassifiers.instructionTip": "Nhập hướng dẫn bổ sung để giúp trình phân loại câu hỏi hiểu rõ hơn về cách phân loại câu hỏi.", + "nodes.questionClassifiers.labelEditorAriaLabel": "Trình chỉnh sửa nhãn lớp", "nodes.questionClassifiers.model": "mô hình", + "nodes.questionClassifiers.outputVars.classLabel": "Nhãn lớp", "nodes.questionClassifiers.outputVars.className": "Tên lớp", "nodes.questionClassifiers.outputVars.usage": "Thông tin sử dụng mô hình", + "nodes.questionClassifiers.renameHint": "Nhấp đúp vào tiêu đề lớp để đổi tên", "nodes.questionClassifiers.topicName": "Tên chủ đề", "nodes.questionClassifiers.topicPlaceholder": "Viết tên chủ đề của bạn", "nodes.start.builtInVar": "Biến tích hợp sẵn", From e8dc7064144ec3bade3127776319d8e560fed520 Mon Sep 17 00:00:00 2001 From: L1nSn0w <l1nsn0w@qq.com> Date: Mon, 11 May 2026 09:57:56 +0800 Subject: [PATCH 21/53] fix(api): "File validation failed" on Chatflow follow-up with custom file type + memory (#35891) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/agent/base_agent_runner.py | 1 - api/core/memory/token_buffer_memory.py | 2 - api/factories/file_factory/message_files.py | 15 +- api/factories/file_factory/validation.py | 43 ++++- .../core/memory/test_token_buffer_memory.py | 42 +++++ .../factories/test_file_validation.py | 159 ++++++++++++++++++ 6 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 api/tests/unit_tests/factories/test_file_validation.py diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index c22102c2ba..cba4659483 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -532,7 +532,6 @@ class BaseAgentRunner(AppRunner): file_objs = file_factory.build_from_message_files( message_files=files, tenant_id=self.tenant_id, - config=file_extra_config, access_controller=_file_access_controller, ) if not file_objs: diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py index d840ee213c..c41c175cca 100644 --- a/api/core/memory/token_buffer_memory.py +++ b/api/core/memory/token_buffer_memory.py @@ -86,12 +86,10 @@ class TokenBufferMemory: detail = ImagePromptMessageContent.DETAIL.HIGH if file_extra_config and app_record: - # Build files directly without filtering by belongs_to file_objs = [ file_factory.build_from_message_file( message_file=message_file, tenant_id=app_record.tenant_id, - config=file_extra_config, access_controller=_file_access_controller, ) for message_file in message_files diff --git a/api/factories/file_factory/message_files.py b/api/factories/file_factory/message_files.py index 4b3d514238..27441bdcc1 100644 --- a/api/factories/file_factory/message_files.py +++ b/api/factories/file_factory/message_files.py @@ -1,11 +1,18 @@ -"""Adapters from persisted message files to graph-layer file values.""" +"""Adapters from persisted message files to graph-layer file values. + +Replay paths only: files in conversation history were validated at upload time, +so these helpers deliberately do not accept (or forward) a ``FileUploadConfig`` — +re-validation here would break replays whenever workflow ``file_upload`` config +drifts between rounds. Mirrors ``build_file_from_stored_mapping`` in +``models/utils/file_input_compat.py``. +""" from __future__ import annotations from collections.abc import Sequence from core.app.file_access import FileAccessControllerProtocol -from graphon.file import File, FileBelongsTo, FileTransferMethod, FileUploadConfig +from graphon.file import File, FileBelongsTo, FileTransferMethod from models import MessageFile from .builders import build_from_mapping @@ -15,14 +22,12 @@ def build_from_message_files( *, message_files: Sequence[MessageFile], tenant_id: str, - config: FileUploadConfig | None = None, access_controller: FileAccessControllerProtocol, ) -> Sequence[File]: return [ build_from_message_file( message_file=message_file, tenant_id=tenant_id, - config=config, access_controller=access_controller, ) for message_file in message_files @@ -34,7 +39,6 @@ def build_from_message_file( *, message_file: MessageFile, tenant_id: str, - config: FileUploadConfig | None, access_controller: FileAccessControllerProtocol, ) -> File: mapping = { @@ -54,6 +58,5 @@ def build_from_message_file( return build_from_mapping( mapping=mapping, tenant_id=tenant_id, - config=config, access_controller=access_controller, ) diff --git a/api/factories/file_factory/validation.py b/api/factories/file_factory/validation.py index 4c4f6150e4..8c4e7ef1d4 100644 --- a/api/factories/file_factory/validation.py +++ b/api/factories/file_factory/validation.py @@ -2,9 +2,25 @@ from __future__ import annotations +from collections.abc import Iterable + from graphon.file import FileTransferMethod, FileType, FileUploadConfig +def _normalize_extension(extension: str) -> str: + s = extension.strip().lower() + if not s: + return "" + return s if s.startswith(".") else "." + s + + +def _extension_matches(extension: str, whitelist: Iterable[str]) -> bool: + normalized = _normalize_extension(extension) + if not normalized: + return False + return normalized in {_normalize_extension(e) for e in whitelist} + + def is_file_valid_with_config( *, input_file_type: str, @@ -12,22 +28,31 @@ def is_file_valid_with_config( file_transfer_method: FileTransferMethod, config: FileUploadConfig, ) -> bool: - # FIXME(QIN2DIM): Always allow tool files (files generated by the assistant/model) - # These are internally generated and should bypass user upload restrictions + """Return whether the file is allowed by the upload config. + + ``allowed_file_types`` lists the buckets a file may fall into; ``CUSTOM`` is + a fallback bucket gated by ``allowed_file_extensions`` (case- and + dot-insensitive). Tool-generated files bypass user-facing config. + """ if file_transfer_method == FileTransferMethod.TOOL_FILE: return True - if ( - config.allowed_file_types - and input_file_type not in config.allowed_file_types - and input_file_type != FileType.CUSTOM - ): + allowed_types = config.allowed_file_types or [] + custom_allowed = FileType.CUSTOM in allowed_types + type_allowed = not allowed_types or input_file_type in allowed_types + + if not type_allowed and not custom_allowed: return False + # When the file is in the CUSTOM bucket, the extension whitelist is authoritative. + # An explicitly set whitelist (including the empty list) is enforced; empty == deny — + # the UI never submits an empty list, so this guards against DSL/API paths that + # bypass the UI from accidentally widening the allowlist. + in_custom_bucket = input_file_type == FileType.CUSTOM or not type_allowed if ( - input_file_type == FileType.CUSTOM + in_custom_bucket and config.allowed_file_extensions is not None - and file_extension not in config.allowed_file_extensions + and not _extension_matches(file_extension, config.allowed_file_extensions) ): return False diff --git a/api/tests/unit_tests/core/memory/test_token_buffer_memory.py b/api/tests/unit_tests/core/memory/test_token_buffer_memory.py index f459250b8e..72c24bda96 100644 --- a/api/tests/unit_tests/core/memory/test_token_buffer_memory.py +++ b/api/tests/unit_tests/core/memory/test_token_buffer_memory.py @@ -198,6 +198,48 @@ class TestBuildPromptMessageWithFiles: assert isinstance(result.content[-1], TextPromptMessageContent) assert result.content[-1].data == "user text" + def test_replay_does_not_pass_config_to_file_factory(self): + """Replay contract: history files were validated on upload, so this + path must not forward a FileUploadConfig. The factory's signature + no longer accepts ``config``; this test guards against a future + regression that re-introduces it.""" + conv = _make_conversation(AppMode.CHAT) + mem = TokenBufferMemory(conversation=conv, model_instance=_make_model_instance()) + + mock_file_extra_config = MagicMock() + mock_file_extra_config.image_config = None + + real_image_content = ImagePromptMessageContent( + url="http://example.com/img.png", format="png", mime_type="image/png" + ) + mock_app_record = MagicMock() + mock_app_record.tenant_id = "tenant-1" + + with ( + patch( + "core.memory.token_buffer_memory.FileUploadConfigManager.convert", + return_value=mock_file_extra_config, + ), + patch( + "core.memory.token_buffer_memory.file_factory.build_from_message_file", + return_value=MagicMock(), + ) as mock_build, + patch( + "core.memory.token_buffer_memory.file_manager.to_prompt_message_content", + return_value=real_image_content, + ), + ): + mem._build_prompt_message_with_files( + message_files=[MagicMock()], + text_content="user text", + message=_make_message(), + app_record=mock_app_record, + is_user_message=True, + ) + + mock_build.assert_called_once() + assert "config" not in mock_build.call_args.kwargs + @pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION]) def test_chat_mode_with_files_assistant_message(self, mode): """When files are present, returns AssistantPromptMessage with list content.""" diff --git a/api/tests/unit_tests/factories/test_file_validation.py b/api/tests/unit_tests/factories/test_file_validation.py new file mode 100644 index 0000000000..61337fcf10 --- /dev/null +++ b/api/tests/unit_tests/factories/test_file_validation.py @@ -0,0 +1,159 @@ +"""Unit tests for is_file_valid_with_config.""" + +from __future__ import annotations + +import pytest + +from factories.file_factory.validation import is_file_valid_with_config +from graphon.file import FileTransferMethod, FileType, FileUploadConfig + + +def _validate( + *, + input_file_type: str, + file_extension: str = ".png", + file_transfer_method: FileTransferMethod = FileTransferMethod.LOCAL_FILE, + config: FileUploadConfig, +) -> bool: + return is_file_valid_with_config( + input_file_type=input_file_type, + file_extension=file_extension, + file_transfer_method=file_transfer_method, + config=config, + ) + + +@pytest.mark.parametrize( + ("input_file_type", "file_extension", "allowed_file_types", "allowed_file_extensions", "expected"), + [ + # round-1 happy path: literal "custom" mapping, ext whitelisted + ("custom", ".png", [FileType.CUSTOM], [".png"], True), + # round-2 replay: MessageFile.type is the resolved type, but config still allows CUSTOM + ("image", ".png", [FileType.CUSTOM], [".png"], True), + ("document", ".pdf", [FileType.CUSTOM], [".pdf"], True), + # mixed bucket [IMAGE, CUSTOM]: document falls into CUSTOM bucket via extension + ("document", ".pdf", [FileType.IMAGE, FileType.CUSTOM], [".pdf"], True), + ("document", ".exe", [FileType.IMAGE, FileType.CUSTOM], [".pdf"], False), + ("image", ".jpg", [FileType.IMAGE], [], True), + ("video", ".mp4", [FileType.IMAGE, FileType.DOCUMENT], [], False), + ("custom", ".exe", [FileType.CUSTOM], [".png"], False), + # empty allowed_file_types == no type restriction + ("video", ".mp4", [], [], True), + ], +) +def test_bucket_semantics(input_file_type, file_extension, allowed_file_types, allowed_file_extensions, expected): + config = FileUploadConfig( + allowed_file_types=allowed_file_types, + allowed_file_extensions=allowed_file_extensions, + ) + assert _validate(input_file_type=input_file_type, file_extension=file_extension, config=config) is expected + + +@pytest.mark.parametrize("whitelist_entry", [".png", ".PNG", "png", "PNG", " .Png ", "PnG"]) +def test_extension_match_is_case_and_dot_insensitive(whitelist_entry): + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[whitelist_entry], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is True + + +def test_extension_mismatch_still_rejected_after_normalization(): + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[".png", ".jpg"], + ) + assert _validate(input_file_type="custom", file_extension=".pdf", config=config) is False + + +def test_mixed_case_whitelist_replicating_real_user_config(): + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[".PNG", "png", "JPG", ".WEBP", "SVG", "GIF"], + ) + for ext in (".png", ".jpg", ".webp", ".svg", ".gif"): + assert _validate(input_file_type="custom", file_extension=ext, config=config) is True + + +def test_tool_file_always_passes(): + config = FileUploadConfig(allowed_file_types=[FileType.CUSTOM], allowed_file_extensions=[".pdf"]) + assert ( + _validate( + input_file_type="image", + file_extension=".png", + file_transfer_method=FileTransferMethod.TOOL_FILE, + config=config, + ) + is True + ) + + +def test_transfer_method_gate_for_non_image(): + config = FileUploadConfig( + allowed_file_types=[FileType.DOCUMENT], + allowed_file_upload_methods=[FileTransferMethod.LOCAL_FILE], + ) + assert ( + _validate( + input_file_type="document", + file_extension=".pdf", + file_transfer_method=FileTransferMethod.LOCAL_FILE, + config=config, + ) + is True + ) + assert ( + _validate( + input_file_type="document", + file_extension=".pdf", + file_transfer_method=FileTransferMethod.REMOTE_URL, + config=config, + ) + is False + ) + + +def test_history_replay_matches_round_1_outcome_under_unchanged_config(): + """A file that passes round 1 must pass history replay when config is unchanged.""" + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[".png"], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is True + assert _validate(input_file_type="image", file_extension=".png", config=config) is True + + +def test_empty_whitelist_in_custom_bucket_denies_by_default(): + """Defensive: when a file lands in the CUSTOM bucket, an empty + allowed_file_extensions list rejects. The UI never submits empty; + this guards DSL / API paths that bypass the UI from accidentally + widening what's accepted.""" + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is False + assert _validate(input_file_type="image", file_extension=".png", config=config) is False + + +def test_normalize_handles_whitespace_and_empty_consistently(): + """Whitespace-only or empty entries in the whitelist must not match real + extensions (regression guard for _normalize_extension edge cases).""" + for noisy_entry in ("", " ", "\t"): + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[noisy_entry], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is False + + +def test_empty_extension_does_not_spuriously_match_empty_whitelist_entry(): + """Defensive: even if the whitelist contains an empty / whitespace entry + (e.g., a stray comma in DSL), an extensionless file must not pass via + a both-sides-empty match. Real entries in the same whitelist still match.""" + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=["", ".png"], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is True + assert _validate(input_file_type="custom", file_extension="", config=config) is False From 0b70eec69512dd6bd2eaeedb3a6e62d30a2ca422 Mon Sep 17 00:00:00 2001 From: Blackoutta <37723456+Blackoutta@users.noreply.github.com> Date: Mon, 11 May 2026 10:16:29 +0800 Subject: [PATCH 22/53] feat(human-input): expose selected action value (#35451) --- api/services/workflow_service.py | 5 +++++ api/tests/unit_tests/services/test_workflow_service.py | 8 ++++++++ web/app/components/workflow/constants.ts | 4 ++++ .../nodes/human-input/__tests__/human-input.spec.tsx | 2 +- .../workflow/nodes/human-input/__tests__/panel.spec.tsx | 1 + .../components/__tests__/user-action.spec.tsx | 9 +++------ .../nodes/human-input/components/user-action.tsx | 8 ++++---- web/app/components/workflow/nodes/human-input/panel.tsx | 5 +++++ web/i18n/en-US/workflow.json | 4 ++-- 9 files changed, 33 insertions(+), 13 deletions(-) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index b8c2ed5e6f..eb78e0a68b 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -1066,8 +1066,13 @@ class WorkflowService: ) rendered_content = node.render_form_content_before_submission() + selected_action = next( + (user_action for user_action in node_data.user_actions if user_action.id == action), + None, + ) outputs: dict[str, Any] = dict(form_inputs) outputs["__action_id"] = action + outputs["__action_value"] = selected_action.title if selected_action else "" outputs["__rendered_content"] = node.render_form_content_with_outputs( rendered_content, outputs, node_data.outputs_field_names() ) diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 1711e66b23..e152ab923c 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -11,6 +11,7 @@ This test suite covers: import json import uuid +from types import SimpleNamespace from typing import Any, cast from unittest.mock import ANY, MagicMock, Mock, patch, sentinel @@ -2649,7 +2650,12 @@ class TestWorkflowServiceHumanInputOperations: mock_node = MagicMock() mock_node.node_data = MagicMock() + mock_node.node_data.user_actions = [ + SimpleNamespace(id="submit", title="card_visa_enterprise_001"), + ] mock_node.node_data.outputs_field_names.return_value = ["field1"] + mock_node.render_form_content_before_submission.return_value = "Ticket: {{#$output.field1#}}" + mock_node.render_form_content_with_outputs.return_value = "Ticket: val1" with ( patch("services.workflow_service.db"), @@ -2665,6 +2671,8 @@ class TestWorkflowServiceHumanInputOperations: app_model=app_model, account=account, node_id="node-1", form_inputs={"field1": "val1"}, action="submit" ) assert result["__action_id"] == "submit" + assert result["__action_value"] == "card_visa_enterprise_001" + assert result["__rendered_content"] == "Ticket: val1" mock_saver_cls.return_value.save.assert_called_once() def test_test_human_input_delivery_success(self, service: WorkflowService) -> None: diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 101d15a140..32c7c82e33 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -221,6 +221,10 @@ export const HUMAN_INPUT_OUTPUT_STRUCT: Var[] = [ variable: '__action_id', type: VarType.string, }, + { + variable: '__action_value', + type: VarType.string, + }, { variable: '__rendered_content', type: VarType.string, diff --git a/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx index cbc9cddd4a..8d41a45468 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx @@ -516,7 +516,7 @@ describe('DSL Import with Human Input Node', () => { ]) }) - it('should return empty output variables when no form inputs exist', () => { + it('should return no output variables when no form inputs exist', () => { const payload = { ...humanInputDefault.defaultValue, inputs: [], diff --git a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx index 6ff61dce3f..0f0a6839eb 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx @@ -313,6 +313,7 @@ describe('human-input/panel', () => { expect(screen.getByText('approve:editable')).toBeInTheDocument() expect(screen.getByText('review_result:string:Form input value')).toBeInTheDocument() expect(screen.getByText('__action_id:string:Action ID user triggered')).toBeInTheDocument() + expect(screen.getByText('__action_value:string:Selected action value')).toBeInTheDocument() expect(screen.getByText('__rendered_content:string:Rendered content')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'delivery-method:editable' })) diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx index a47a012f49..5e9074a42b 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx @@ -87,7 +87,7 @@ describe('UserActionItem', () => { fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'Approve action' } }) fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: '1invalid' } }) fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'averyveryveryverylongidentifier' } }) - fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'), { target: { value: 'A very very very long button title' } }) + fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'), { target: { value: 'card_visa_enterprise_001' } }) expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'Approve_action', @@ -96,7 +96,7 @@ describe('UserActionItem', () => { id: 'averyveryveryverylon', })) expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({ - title: 'A very very very lon', + title: 'card_visa_enterprise_001', })) expect(mockNotify).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: 'error', @@ -106,10 +106,7 @@ describe('UserActionItem', () => { type: 'error', message: 'nodes.humanInput.userActions.actionIdTooLong', })) - expect(mockNotify).toHaveBeenNthCalledWith(3, expect.objectContaining({ - type: 'error', - message: 'nodes.humanInput.userActions.buttonTextTooLong', - })) + expect(mockNotify).toHaveBeenCalledTimes(2) }) it('should support clearing ids, updating button style, deleting, and readonly mode', () => { diff --git a/web/app/components/workflow/nodes/human-input/components/user-action.tsx b/web/app/components/workflow/nodes/human-input/components/user-action.tsx index a83ea4f8f2..94581281e8 100644 --- a/web/app/components/workflow/nodes/human-input/components/user-action.tsx +++ b/web/app/components/workflow/nodes/human-input/components/user-action.tsx @@ -12,7 +12,7 @@ import ButtonStyleDropdown from './button-style-dropdown' const i18nPrefix = 'nodes.humanInput' const ACTION_ID_MAX_LENGTH = 20 -const BUTTON_TEXT_MAX_LENGTH = 20 +const ACTION_VALUE_MAX_LENGTH = 100 type UserActionItemProps = { data: UserAction @@ -63,9 +63,9 @@ const UserActionItem: FC<UserActionItemProps> = ({ const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => { let value = e.target.value - if (value.length > BUTTON_TEXT_MAX_LENGTH) { - value = value.slice(0, BUTTON_TEXT_MAX_LENGTH) - toast.error(t(`${i18nPrefix}.userActions.buttonTextTooLong`, { ns: 'workflow', maxLength: BUTTON_TEXT_MAX_LENGTH })) + if (value.length > ACTION_VALUE_MAX_LENGTH) { + value = value.slice(0, ACTION_VALUE_MAX_LENGTH) + toast.error(t(`${i18nPrefix}.userActions.buttonTextTooLong`, { ns: 'workflow', maxLength: ACTION_VALUE_MAX_LENGTH })) } onChange({ ...data, title: value }) } diff --git a/web/app/components/workflow/nodes/human-input/panel.tsx b/web/app/components/workflow/nodes/human-input/panel.tsx index fa0914c098..99b5da42eb 100644 --- a/web/app/components/workflow/nodes/human-input/panel.tsx +++ b/web/app/components/workflow/nodes/human-input/panel.tsx @@ -229,6 +229,11 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ type="string" description="Action ID user triggered" /> + <VarItem + name="__action_value" + type="string" + description="Selected action value" + /> <VarItem name="__rendered_content" type="string" diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 7b1ad1605a..774713476b 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "Action ID must start with a letter or underscores, followed by letters, numbers, or underscores", "nodes.humanInput.userActions.actionIdTooLong": "Action ID must be {{maxLength}} characters or less", "nodes.humanInput.userActions.actionNamePlaceholder": "Action Name", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Button display Text", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Action Value", "nodes.humanInput.userActions.buttonTextTooLong": "Button text must be {{maxLength}} characters or less", "nodes.humanInput.userActions.chooseStyle": "Choose a button style", "nodes.humanInput.userActions.emptyTip": "Click the '+' button to add user actions", "nodes.humanInput.userActions.title": "User Actions", - "nodes.humanInput.userActions.tooltip": "Define buttons that users can click to respond to this form. Each button can trigger different workflow paths. Action ID must start with a letter or underscores, followed by letters, numbers, or underscores.", + "nodes.humanInput.userActions.tooltip": "Define buttons that users can click to respond to this form. Action ID controls branching. Action Value is exposed downstream as the selected built-in output. Action ID must start with a letter or underscores, followed by letters, numbers, or underscores.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> has been triggered", "nodes.ifElse.addCondition": "Add Condition", "nodes.ifElse.addSubVariable": "Sub Variable", From 1e6dc6247002b2a5c988a0a291efc5126e6fdd2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= <hjlarry@163.com> Date: Mon, 11 May 2026 10:22:40 +0800 Subject: [PATCH 23/53] chore: separate websocket service (#35981) --- api/.env.example | 2 +- api/configs/feature/__init__.py | 2 +- api/services/feature_service.py | 2 +- docker/.env.example | 9 +++++-- docker/docker-compose-template.yaml | 26 ++++++++++++++++++++ docker/docker-compose.yaml | 26 ++++++++++++++++++++ docker/envs/core-services/shared.env.example | 8 ++++-- docker/envs/infrastructure/nginx.env.example | 1 + docker/nginx/conf.d/default.conf.template | 4 ++- 9 files changed, 72 insertions(+), 8 deletions(-) diff --git a/api/.env.example b/api/.env.example index 56ba8a6c5d..ba153e4c9c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -34,7 +34,7 @@ TRIGGER_URL=http://localhost:5001 FILES_ACCESS_TIMEOUT=300 # Collaboration mode toggle -ENABLE_COLLABORATION_MODE=false +ENABLE_COLLABORATION_MODE=true # Access token expiration time in minutes ACCESS_TOKEN_EXPIRE_MINUTES=60 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 52e33c1789..e9bb34fa75 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1298,7 +1298,7 @@ class PositionConfig(BaseSettings): class CollaborationConfig(BaseSettings): ENABLE_COLLABORATION_MODE: bool = Field( description="Whether to enable collaboration mode features across the workspace", - default=False, + default=True, ) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 9477c28bf3..257c4bea9a 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -166,7 +166,7 @@ class SystemFeatureModel(BaseModel): enable_email_code_login: bool = False enable_email_password_login: bool = True enable_social_oauth_login: bool = False - enable_collaboration_mode: bool = False + enable_collaboration_mode: bool = True is_allow_register: bool = False is_allow_create_workspace: bool = False is_email_setup: bool = False diff --git a/docker/.env.example b/docker/.env.example index 82bd837ffb..d9891d842a 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -34,7 +34,8 @@ CHECK_UPDATE_URL=https://updates.dify.ai OPENAI_API_BASE=https://api.openai.com/v1 MIGRATION_ENABLED=true FILES_ACCESS_TIMEOUT=300 -ENABLE_COLLABORATION_MODE=false +# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. +ENABLE_COLLABORATION_MODE=true # Logging and server workers LOG_LEVEL=INFO @@ -52,6 +53,9 @@ DIFY_PORT=5001 SERVER_WORKER_AMOUNT=1 SERVER_WORKER_CLASS=gevent SERVER_WORKER_CONNECTIONS=10 +API_WEBSOCKET_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker +API_WEBSOCKET_WORKER_CONNECTIONS=1000 +API_WEBSOCKET_GUNICORN_TIMEOUT=360 GUNICORN_TIMEOUT=360 CELERY_WORKER_CLASS= CELERY_WORKER_AMOUNT=4 @@ -246,6 +250,7 @@ NGINX_KEEPALIVE_TIMEOUT=65 NGINX_PROXY_READ_TIMEOUT=3600s NGINX_PROXY_SEND_TIMEOUT=3600s NGINX_ENABLE_CERTBOT_CHALLENGE=false +NGINX_SOCKET_IO_UPSTREAM=api_websocket:5001 EXPOSE_NGINX_PORT=80 EXPOSE_NGINX_SSL_PORT=443 -COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} +COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql},collaboration diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 0f65c38098..72c9d4fd90 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -261,6 +261,31 @@ services: - ssrf_proxy_network - default + # WebSocket service for workflow collaboration. + api_websocket: + <<: *shared-api-worker-config + image: langgenius/dify-api:1.14.0 + profiles: + - collaboration + environment: + MODE: api + SERVER_WORKER_AMOUNT: 1 + SERVER_WORKER_CLASS: ${API_WEBSOCKET_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} + SERVER_WORKER_CONNECTIONS: ${API_WEBSOCKET_WORKER_CONNECTIONS:-1000} + GUNICORN_TIMEOUT: ${API_WEBSOCKET_GUNICORN_TIMEOUT:-360} + depends_on: + db_postgres: + condition: service_healthy + required: false + db_mysql: + condition: service_healthy + required: false + redis: + condition: service_started + networks: + - ssrf_proxy_network + - default + # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: @@ -661,6 +686,7 @@ services: NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false} + NGINX_SOCKET_IO_UPSTREAM: ${NGINX_SOCKET_IO_UPSTREAM:-api_websocket:5001} CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-} depends_on: - api diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0f8458a58f..c1d75e01f4 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -267,6 +267,31 @@ services: - ssrf_proxy_network - default + # WebSocket service for workflow collaboration. + api_websocket: + <<: *shared-api-worker-config + image: langgenius/dify-api:1.14.0 + profiles: + - collaboration + environment: + MODE: api + SERVER_WORKER_AMOUNT: 1 + SERVER_WORKER_CLASS: ${API_WEBSOCKET_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} + SERVER_WORKER_CONNECTIONS: ${API_WEBSOCKET_WORKER_CONNECTIONS:-1000} + GUNICORN_TIMEOUT: ${API_WEBSOCKET_GUNICORN_TIMEOUT:-360} + depends_on: + db_postgres: + condition: service_healthy + required: false + db_mysql: + condition: service_healthy + required: false + redis: + condition: service_started + networks: + - ssrf_proxy_network + - default + # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: @@ -667,6 +692,7 @@ services: NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false} + NGINX_SOCKET_IO_UPSTREAM: ${NGINX_SOCKET_IO_UPSTREAM:-api_websocket:5001} CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-} depends_on: - api diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 2a57f6954a..af1c3ce74e 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -16,7 +16,8 @@ CHECK_UPDATE_URL=https://updates.dify.ai OPENAI_API_BASE=https://api.openai.com/v1 MIGRATION_ENABLED=true FILES_ACCESS_TIMEOUT=300 -ENABLE_COLLABORATION_MODE=false +# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. +ENABLE_COLLABORATION_MODE=true CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 CELERY_TASK_ANNOTATIONS=null AZURE_BLOB_ACCOUNT_URL=https://<your_account_name>.blob.core.windows.net @@ -87,6 +88,9 @@ DIFY_PORT=5001 SERVER_WORKER_AMOUNT=1 SERVER_WORKER_CLASS=gevent SERVER_WORKER_CONNECTIONS=10 +API_WEBSOCKET_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker +API_WEBSOCKET_WORKER_CONNECTIONS=1000 +API_WEBSOCKET_GUNICORN_TIMEOUT=360 CELERY_SENTINEL_PASSWORD= S3_ACCESS_KEY= S3_SECRET_KEY= @@ -399,7 +403,7 @@ TABLESTORE_ENDPOINT=https://instance-name.cn-hangzhou.ots.aliyuncs.com TABLESTORE_INSTANCE_NAME=instance-name CLICKZETTA_USERNAME= CLICKZETTA_VECTOR_DISTANCE_FUNCTION=cosine_distance -COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} +COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql},collaboration EXPOSE_NGINX_PORT=80 EXPOSE_NGINX_SSL_PORT=443 POSITION_TOOL_PINS= diff --git a/docker/envs/infrastructure/nginx.env.example b/docker/envs/infrastructure/nginx.env.example index fbe86680ba..fcb369a47d 100644 --- a/docker/envs/infrastructure/nginx.env.example +++ b/docker/envs/infrastructure/nginx.env.example @@ -15,3 +15,4 @@ NGINX_KEEPALIVE_TIMEOUT=65 NGINX_PROXY_READ_TIMEOUT=3600s NGINX_PROXY_SEND_TIMEOUT=3600s NGINX_ENABLE_CERTBOT_CHALLENGE=false +NGINX_SOCKET_IO_UPSTREAM=api_websocket:5001 diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template index 94a748290f..64c720ca2b 100644 --- a/docker/nginx/conf.d/default.conf.template +++ b/docker/nginx/conf.d/default.conf.template @@ -15,7 +15,9 @@ server { } location /socket.io/ { - proxy_pass http://api:5001; + resolver 127.0.0.11 valid=30s ipv6=off; + set $socket_io_upstream ${NGINX_SOCKET_IO_UPSTREAM}; + proxy_pass http://$socket_io_upstream; include proxy.conf; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; From bf117dd0c8b140f8390d6f447d84a9d2fddba10d Mon Sep 17 00:00:00 2001 From: kien duong <trungkienty2001@gmail.com> Date: Mon, 11 May 2026 09:52:29 +0700 Subject: [PATCH 24/53] fix(trace): LangSmith trace_id mismatch in chatflow workflow traces (#35979) --- .../dify_trace_langsmith/langsmith_trace.py | 6 +- .../langsmith_trace/test_langsmith_trace.py | 84 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py index 145bd70dbc..045ec44e4e 100644 --- a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py @@ -64,7 +64,9 @@ class LangSmithDataTrace(BaseTraceInstance): self.generate_name_trace(trace_info) def workflow_trace(self, trace_info: WorkflowTraceInfo): - trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id + # trace_id must equal the root run's run_id (LangSmith protocol); external trace_id + # cannot be used here as it would cause HTTP 400. + trace_id = trace_info.message_id or trace_info.workflow_run_id if trace_info.start_time is None: trace_info.start_time = datetime.now() message_dotted_order = ( @@ -77,6 +79,8 @@ class LangSmithDataTrace(BaseTraceInstance): ) metadata = trace_info.metadata metadata["workflow_app_log_id"] = trace_info.workflow_app_log_id + if trace_info.trace_id: + metadata["external_trace_id"] = trace_info.trace_id if trace_info.message_id: message_run = LangSmithRunModel( diff --git a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py index ee59acb17e..edc4aafd87 100644 --- a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py @@ -208,13 +208,17 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): assert call_args[0].id == "msg-1" assert call_args[0].name == TraceTaskName.MESSAGE_TRACE + # trace_id must equal root run's id (message_id), not the external trace_id "trace-1" + assert call_args[0].trace_id == "msg-1" assert call_args[1].id == "run-1" assert call_args[1].name == TraceTaskName.WORKFLOW_TRACE assert call_args[1].parent_run_id == "msg-1" + assert call_args[1].trace_id == "msg-1" assert call_args[2].id == "node-llm" assert call_args[2].run_type == LangSmithRunType.llm + assert call_args[2].trace_id == "msg-1" assert call_args[3].id == "node-other" assert call_args[3].run_type == LangSmithRunType.tool @@ -604,3 +608,83 @@ def test_get_project_url_error(trace_instance): trace_instance.langsmith_client.get_run_url.side_effect = Exception("error") with pytest.raises(ValueError, match="LangSmith get run url failed: error"): trace_instance.get_project_url() + + +def _make_workflow_trace_info( + *, message_id: str | None, workflow_run_id: str, trace_id: str | None +) -> WorkflowTraceInfo: + workflow_data = MagicMock() + workflow_data.created_at = _dt() + workflow_data.finished_at = _dt() + timedelta(seconds=1) + return WorkflowTraceInfo( + tenant_id="tenant-1", + workflow_id="wf-1", + workflow_run_id=workflow_run_id, + workflow_run_inputs={}, + workflow_run_outputs={}, + workflow_run_status="succeeded", + workflow_run_version="1.0", + workflow_run_elapsed_time=1.0, + total_tokens=0, + file_list=[], + query="q", + message_id=message_id, + conversation_id="conv-1" if message_id else None, + start_time=_dt(), + end_time=_dt() + timedelta(seconds=1), + trace_id=trace_id, + metadata={"app_id": "app-1"}, + workflow_app_log_id=None, + error=None, + workflow_data=workflow_data, + ) + + +def _patch_workflow_trace_deps(monkeypatch, trace_instance): + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + factory = MagicMock() + factory.create_workflow_node_execution_repository.return_value = repo + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", factory) + monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) + trace_instance.add_run = MagicMock() + + +def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch): + """Chatflow with external trace_id: LangSmith trace_id must be message_id, not external.""" + trace_info = _make_workflow_trace_info( + message_id="msg-abc", + workflow_run_id="run-xyz", + trace_id="external-999", + ) + _patch_workflow_trace_deps(monkeypatch, trace_instance) + + trace_instance.workflow_trace(trace_info) + + calls = [c[0][0] for c in trace_instance.add_run.call_args_list] + # message run (root) and workflow run (child) must both use message_id as trace_id + assert calls[0].id == "msg-abc" + assert calls[0].trace_id == "msg-abc" + assert calls[1].id == "run-xyz" + assert calls[1].trace_id == "msg-abc" + # external_trace_id preserved in metadata + assert trace_info.metadata.get("external_trace_id") == "external-999" + + +def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch): + """Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id.""" + trace_info = _make_workflow_trace_info( + message_id=None, + workflow_run_id="run-xyz", + trace_id="external-999", + ) + _patch_workflow_trace_deps(monkeypatch, trace_instance) + + trace_instance.workflow_trace(trace_info) + + calls = [c[0][0] for c in trace_instance.add_run.call_args_list] + # workflow run is the root; trace_id must equal its run_id + assert calls[0].id == "run-xyz" + assert calls[0].trace_id == "run-xyz" From 1a011dc14af6fcfecfbc2c9524cce48e24309902 Mon Sep 17 00:00:00 2001 From: Asuka Minato <i@asukaminato.eu.org> Date: Mon, 11 May 2026 12:37:26 +0900 Subject: [PATCH 25/53] refactor: port DatasetProcessRule (#31004) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/indexing_runner.py | 9 +++-- .../processor/parent_child_index_processor.py | 3 +- api/models/dataset.py | 22 ++++++----- api/services/dataset_service.py | 12 +++--- .../test_dataset_service_retrieval.py | 15 ++++---- .../test_disable_segments_from_index_task.py | 37 ++++++++++--------- .../unit_tests/models/test_dataset_models.py | 4 +- 7 files changed, 54 insertions(+), 48 deletions(-) diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index b6e33396d1..537b14388e 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -324,9 +324,10 @@ class IndexingRunner: # one extract_setting is one source document for extract_setting in extract_settings: # extract - processing_rule = DatasetProcessRule( - mode=tmp_processing_rule["mode"], rules=json.dumps(tmp_processing_rule["rules"]) - ) + processing_rule = { + "mode": tmp_processing_rule["mode"], + "rules": tmp_processing_rule.get("rules"), + } # Extract document content text_docs = index_processor.extract(extract_setting, process_rule_mode=tmp_processing_rule["mode"]) # Cleaning and segmentation @@ -334,7 +335,7 @@ class IndexingRunner: text_docs, current_user=None, embedding_model_instance=embedding_model_instance, - process_rule=processing_rule.to_dict(), + process_rule=processing_rule, tenant_id=tenant_id, doc_language=doc_language, preview=True, diff --git a/api/core/rag/index_processor/processor/parent_child_index_processor.py b/api/core/rag/index_processor/processor/parent_child_index_processor.py index ba277d5018..a26a900512 100644 --- a/api/core/rag/index_processor/processor/parent_child_index_processor.py +++ b/api/core/rag/index_processor/processor/parent_child_index_processor.py @@ -29,6 +29,7 @@ from libs import helper from models import Account from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment from models.dataset import Document as DatasetDocument +from models.enums import ProcessRuleMode from services.account_service import AccountService from services.summary_index_service import SummaryIndexService @@ -325,7 +326,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor): # update document parent mode dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode="hierarchical", + mode=ProcessRuleMode.HIERARCHICAL, rules=json.dumps( { "parent_mode": parent_childs.parent_mode, diff --git a/api/models/dataset.py b/api/models/dataset.py index ed7727e0f1..f823e0aa10 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -11,7 +11,7 @@ import time from collections.abc import Sequence from datetime import datetime from json import JSONDecodeError -from typing import Any, TypedDict, cast +from typing import Any, ClassVar, TypedDict, cast from uuid import uuid4 import sqlalchemy as sa @@ -441,23 +441,27 @@ class Dataset(Base): return f"{dify_config.VECTOR_INDEX_NAME_PREFIX}_{normalized_dataset_id}_Node" -class DatasetProcessRule(Base): # bug +class DatasetProcessRule(TypeBase): __tablename__ = "dataset_process_rules" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="dataset_process_rule_pkey"), sa.Index("dataset_process_rule_dataset_id_idx", "dataset_id"), ) - id = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4())) - dataset_id = mapped_column(StringUUID, nullable=False) - mode = mapped_column(EnumText(ProcessRuleMode, length=255), nullable=False, server_default=sa.text("'automatic'")) - rules = mapped_column(LongText, nullable=True) - created_by = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + id: Mapped[str] = mapped_column(StringUUID, nullable=False, default_factory=lambda: str(uuid4()), init=False) + dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + mode: Mapped[ProcessRuleMode] = mapped_column( + EnumText(ProcessRuleMode, length=255), nullable=False, server_default=sa.text("'automatic'") + ) + rules: Mapped[str | None] = mapped_column(LongText, nullable=True) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) MODES = ["automatic", "custom", "hierarchical"] PRE_PROCESSING_RULES = ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"] - AUTOMATIC_RULES: AutomaticRulesConfig = { + AUTOMATIC_RULES: ClassVar[AutomaticRulesConfig] = { "pre_processing_rules": [ {"id": "remove_extra_spaces", "enabled": True}, {"id": "remove_urls_emails", "enabled": False}, diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index eef38f1ce2..383474f4f6 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -108,7 +108,7 @@ logger = logging.getLogger(__name__) class ProcessRulesDict(TypedDict): - mode: str + mode: ProcessRuleMode rules: dict[str, Any] @@ -204,7 +204,7 @@ class DatasetService: mode = dataset_process_rule.mode rules = dataset_process_rule.rules_dict or {} else: - mode = str(DocumentService.DEFAULT_RULES["mode"]) + mode = ProcessRuleMode(DocumentService.DEFAULT_RULES["mode"]) rules = dict(DocumentService.DEFAULT_RULES.get("rules") or {}) return {"mode": mode, "rules": rules} @@ -1984,7 +1984,7 @@ class DocumentService: if process_rule.rules: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode(process_rule.mode), rules=process_rule.rules.model_dump_json() if process_rule.rules else None, created_by=account.id, ) @@ -1995,7 +1995,7 @@ class DocumentService: elif process_rule.mode == ProcessRuleMode.AUTOMATIC: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode.AUTOMATIC, rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES), created_by=account.id, ) @@ -2572,14 +2572,14 @@ class DocumentService: if process_rule.mode in {ProcessRuleMode.CUSTOM, ProcessRuleMode.HIERARCHICAL}: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode(process_rule.mode), rules=process_rule.rules.model_dump_json() if process_rule.rules else None, created_by=account.id, ) elif process_rule.mode == ProcessRuleMode.AUTOMATIC: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode.AUTOMATIC, rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES), created_by=account.id, ) diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py index 2f90d16176..0c610311bb 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py @@ -16,6 +16,7 @@ from uuid import uuid4 from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexTechniqueType +from models import AccountStatus, CreatorUserRole, TenantStatus from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import ( AppDatasetJoin, @@ -25,7 +26,7 @@ from models.dataset import ( DatasetProcessRule, DatasetQuery, ) -from models.enums import DatasetQuerySource, DataSourceType, ProcessRuleMode +from models.enums import DatasetQuerySource, DataSourceType, ProcessRuleMode, TagType from models.model import Tag, TagBinding from services.dataset_service import DatasetService, DocumentService @@ -42,11 +43,11 @@ class DatasetRetrievalTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) tenant = Tenant( name=f"tenant-{uuid4()}", - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add_all([account, tenant]) db_session_with_containers.flush() @@ -72,7 +73,7 @@ class DatasetRetrievalTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.flush() @@ -130,7 +131,7 @@ class DatasetRetrievalTestDataFactory: @staticmethod def create_process_rule( - db_session_with_containers: Session, dataset_id: str, created_by: str, mode: str, rules: dict + db_session_with_containers: Session, dataset_id: str, created_by: str, mode: ProcessRuleMode, rules: dict ) -> DatasetProcessRule: """Create a dataset process rule.""" process_rule = DatasetProcessRule( @@ -153,7 +154,7 @@ class DatasetRetrievalTestDataFactory: content=content, source=DatasetQuerySource.APP, source_app_id=None, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=created_by, ) db_session_with_containers.add(dataset_query) @@ -176,7 +177,7 @@ class DatasetRetrievalTestDataFactory: """Create a knowledge tag and bind it to the target dataset.""" tag = Tag( tenant_id=tenant_id, - type="knowledge", + type=TagType.KNOWLEDGE, name=f"tag-{uuid4()}", created_by=created_by, ) diff --git a/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py index 6bfb1e1f1e..6a95bfc425 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py @@ -7,6 +7,7 @@ The task is responsible for removing document segments from the search index whe """ from unittest.mock import MagicMock, patch +from uuid import uuid4 from faker import Faker from sqlalchemy import select @@ -82,7 +83,7 @@ class TestDisableSegmentsFromIndexTask: return account - def _create_test_dataset(self, db_session_with_containers: Session, account, fake: Faker | None = None): + def _create_test_dataset(self, db_session_with_containers: Session, account: Account, fake: Faker | None = None): """ Helper method to create a test dataset with realistic data. @@ -117,7 +118,7 @@ class TestDisableSegmentsFromIndexTask: return dataset def _create_test_document( - self, db_session_with_containers: Session, dataset, account: Account, fake: Faker | None = None + self, db_session_with_containers: Session, dataset: Dataset, account: Account, fake: Faker | None = None ): """ Helper method to create a test document with realistic data. @@ -164,7 +165,7 @@ class TestDisableSegmentsFromIndexTask: return document def _create_test_segments( - self, db_session_with_containers: Session, document, dataset, account, count=3, fake=None + self, db_session_with_containers: Session, document, dataset: Dataset, account: Account, count=3, fake=None ): """ Helper method to create test document segments with realistic data. @@ -217,7 +218,9 @@ class TestDisableSegmentsFromIndexTask: return segments - def _create_dataset_process_rule(self, db_session_with_containers: Session, dataset, fake: Faker | None = None): + def _create_dataset_process_rule( + self, db_session_with_containers: Session, dataset: Dataset, fake: Faker | None = None + ): """ Helper method to create a dataset process rule. @@ -230,21 +233,19 @@ class TestDisableSegmentsFromIndexTask: DatasetProcessRule: Created process rule instance """ fake = fake or Faker() - process_rule = DatasetProcessRule() - process_rule.id = fake.uuid4() - process_rule.tenant_id = dataset.tenant_id - process_rule.dataset_id = dataset.id - process_rule.mode = ProcessRuleMode.AUTOMATIC - process_rule.rules = ( - "{" - '"mode": "automatic", ' - '"rules": {' - '"pre_processing_rules": [], "segmentation": ' - '{"separator": "\\n\\n", "max_tokens": 1000, "chunk_overlap": 50}}' - "}" + process_rule = DatasetProcessRule( + dataset_id=dataset.id, + mode=ProcessRuleMode.AUTOMATIC, + rules=( + "{" + '"mode": "automatic", ' + '"rules": {' + '"pre_processing_rules": [], "segmentation": ' + '{"separator": "\\n\\n", "max_tokens": 1000, "chunk_overlap": 50}}' + "}" + ), + created_by=str(uuid4()), ) - process_rule.created_by = dataset.created_by - process_rule.updated_by = dataset.updated_by db_session_with_containers.add(process_rule) db_session_with_containers.commit() diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 3f14ebe8bf..f4ccfb4191 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -847,9 +847,7 @@ class TestDatasetProcessRule: # Act process_rule = DatasetProcessRule( - dataset_id=dataset_id, - mode=ProcessRuleMode.AUTOMATIC, - created_by=created_by, + dataset_id=dataset_id, mode=ProcessRuleMode.AUTOMATIC, created_by=created_by, rules=None ) # Assert From 837b5cad862e3cb8d62b2d1ca2385e1552154387 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 12:51:51 +0900 Subject: [PATCH 26/53] chore(deps): bump opentelemetry-exporter-otlp-proto-grpc from 1.41.0 to 1.41.1 in /api in the opentelemetry group (#36013) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index ad9ce2c4a4..23cdf669bb 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4265,32 +4265,32 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/b7/845565a2ab5d22c1486bc7729a06b05cd0964c61539d766e1f107c9eea0c/opentelemetry_exporter_otlp-1.41.0.tar.gz", hash = "sha256:97ff847321f8d4c919032a67d20d3137fb7b34eac0c47f13f71112858927fc5b", size = 6152, upload-time = "2026-04-09T14:38:35.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/84/d55baf8e1a222f40282956083e67de9fa92d5fa451108df4839505fa2a24/opentelemetry_exporter_otlp-1.41.1.tar.gz", hash = "sha256:299a2f0541ca175df186f5ac58fd5db177ba1e9b72b0826049062f750d55b47f", size = 6152, upload-time = "2026-04-24T13:15:40.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f2/f1076fff152858773f22cda146713f9ae3661795af6bacd411a76f2151ac/opentelemetry_exporter_otlp-1.41.0-py3-none-any.whl", hash = "sha256:443b6a45c990ae4c55e147f97049a86c5f5b704f3d78b48b44a073a886ec4d6e", size = 7022, upload-time = "2026-04-09T14:38:13.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/ea4aa7dfc458fd537bd9519ea0e7226eef2a6212dfe952694984167daaba/opentelemetry_exporter_otlp-1.41.1-py3-none-any.whl", hash = "sha256:db276c5a80c02b063994e80950d00ca1bfddcf6520f608335b7dc2db0c0eb9c6", size = 7025, upload-time = "2026-04-24T13:15:17.839Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -4301,14 +4301,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/46/d75a3f8c91915f2e58f61d0a2e4ada63891e7c7a37a20ff7949ba184a6b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0.tar.gz", hash = "sha256:f704201251c6f65772b11bddea1c948000554459101bdbb0116e0a01b70592f6", size = 25754, upload-time = "2026-04-09T14:38:37.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/f6/b09e2e0c9f0b5750cebc6eaf31527b910821453cef40a5a0fe93550422b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0-py3-none-any.whl", hash = "sha256:3a1a86bd24806ccf136ec9737dbfa4c09b069f9130ff66b0acb014f9c5255fd1", size = 20299, upload-time = "2026-04-09T14:38:17.01Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -4319,9 +4319,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, ] [[package]] @@ -4479,14 +4479,14 @@ wheels = [ [[package]] name = "opentelemetry-proto" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, ] [[package]] From 9e3e616391a22f263ec76d0503cdc1121a36c04a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 12:52:13 +0900 Subject: [PATCH 27/53] chore(deps): bump the storage group in /api with 2 updates (#36017) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 4 ++-- api/uv.lock | 52 +++++++++++++++++++++++----------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 6c30779f9d..a88ad174fd 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -6,7 +6,7 @@ requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed "bleach>=6.3.0", - "boto3>=1.43.3", + "boto3>=1.43.6", "celery>=5.6.3", "croniter>=6.2.2", "flask>=3.1.3,<4.0.0", @@ -191,7 +191,7 @@ storage = [ "google-cloud-storage>=3.10.1", "opendal>=0.46.0", "oss2>=2.19.1", - "supabase>=2.29.0", + "supabase>=2.30.0", "tos>=2.9.0", ] diff --git a/api/uv.lock b/api/uv.lock index 23cdf669bb..cbb6440533 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -607,16 +607,16 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.3" +version = "1.43.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/50/ea184e159c4ac64fef816a72094fb8656eb071361a39ed22c0e3b15a35b4/boto3-1.43.3.tar.gz", hash = "sha256:7c7777862ffc898f05efa566032bbabfe226dbb810e35ec11125817f128bc5c5", size = 113111, upload-time = "2026-05-04T19:34:09.731Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/ad/8a6946a329f0127322108e537dc1c0d9f8eea4f1d1231702c073d2e85f46/boto3-1.43.3-py3-none-any.whl", hash = "sha256:fb9fe51849ef2a78198d582756fc06f14f7de27f73e0fa90275d6aa4171eb4d0", size = 140501, upload-time = "2026-05-04T19:34:07.991Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" }, ] [[package]] @@ -639,16 +639,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.43.3" +version = "1.43.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/ac/cd55f886e17b6b952dbc95b792d3645a73d58586a1400ababe54406073bd/botocore-1.43.3.tar.gz", hash = "sha256:eac6da0fffccf87888ebf4d89f0b2378218a707efa748cd955b838995e944695", size = 15308705, upload-time = "2026-05-04T19:33:56.28Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/99/1d9e296edf244f47e0508032f20999f8fd40704dd3c5b601fed099424eb6/botocore-1.43.3-py3-none-any.whl", hash = "sha256:ec0769eb0f7c5034856bb406a92698dbc02a3d4be0f78a384747106b161d8ea3", size = 14989027, upload-time = "2026-05-04T19:33:50.81Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, ] [[package]] @@ -1581,7 +1581,7 @@ requires-dist = [ { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.3.0" }, - { name = "boto3", specifier = ">=1.43.3" }, + { name = "boto3", specifier = ">=1.43.6" }, { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, @@ -1692,7 +1692,7 @@ storage = [ { name = "google-cloud-storage", specifier = ">=3.10.1" }, { name = "opendal", specifier = ">=0.46.0" }, { name = "oss2", specifier = ">=2.19.1" }, - { name = "supabase", specifier = ">=2.29.0" }, + { name = "supabase", specifier = ">=2.30.0" }, { name = "tos", specifier = ">=2.9.0" }, ] tools = [ @@ -4813,7 +4813,7 @@ wheels = [ [[package]] name = "postgrest" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -4821,9 +4821,9 @@ dependencies = [ { name = "pydantic" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/98/f216b8b5c4d116ab6a2fb21339b5821da279ee773e163612418e1c56c012/postgrest-2.29.0.tar.gz", hash = "sha256:a87081858f627fcd57e8e7137004a1ef0adbdf0dbdfed1384e9ea1d7a9c525ec", size = 14217, upload-time = "2026-04-24T13:13:00.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/7c/54e7be05adc9fd6fd98dc572ddfc8982d45bec314a55711e37277d440698/postgrest-2.30.0.tar.gz", hash = "sha256:4f89eec56ce605ab6fbddd9b96d526a9bb44962796d44a5d85cb77640eb766c3", size = 14430, upload-time = "2026-05-06T17:35:21.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/0b/08b670a93a90d625c557b9e64b8a5fdeec80c3542d2d0265f0b4d6b16646/postgrest-2.29.0-py3-none-any.whl", hash = "sha256:3ee48e146f726272733d20e2b12de354cdb6cb9dd9cc3a61ed97ce69047aeb96", size = 22735, upload-time = "2026-04-24T13:12:58.405Z" }, + { url = "https://files.pythonhosted.org/packages/22/aa/ff2e09f99f95ea96fddeb373646bf907dd89a24fc00b5d38e5674ca7c9ca/postgrest-2.30.0-py3-none-any.whl", hash = "sha256:30631e7993da542419f4217cf3b60aa641084731ea15e66a18526a3a52e40a7d", size = 23108, upload-time = "2026-05-06T17:35:20.531Z" }, ] [[package]] @@ -5726,16 +5726,16 @@ wheels = [ [[package]] name = "realtime" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/f1/08c42a42653942fadfbef495d5b0239356140e7186cc528704956c5f06d4/realtime-2.29.0.tar.gz", hash = "sha256:8efe4a1b3a548a5fda09de701bd041fa0970c5a2fe7d13db0b9861ce11828be2", size = 18715, upload-time = "2026-04-24T13:13:02.315Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/a2/0328d49d3b5fb427068e9200e7de5b0d708d021a1ad98d004bc685d2529e/realtime-2.30.0.tar.gz", hash = "sha256:7aa593da52ed5f92c34ec4e50e32043afa62f219c94f717ad64a66ab0ef9f1ba", size = 18718, upload-time = "2026-05-06T17:35:23.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/48/f6375c0a24923beb988f0c71c052604c96641cf43c2d22b91ec1df86afa0/realtime-2.29.0-py3-none-any.whl", hash = "sha256:1a4891e6c82e88ac9d96ac715e435e086f6f8c7665212a8717346de829cbb509", size = 22374, upload-time = "2026-04-24T13:13:01.103Z" }, + { url = "https://files.pythonhosted.org/packages/b4/75/1b2cfc949595e22d8c05a2aa2cfc222921f7f94177d7e8a90542f3f73b33/realtime-2.30.0-py3-none-any.whl", hash = "sha256:7c93b63d2cf99aa1da4fa8826b03b00cd32f7b38abb27ff47b19eb5dcb5707c6", size = 22376, upload-time = "2026-05-06T17:35:22.568Z" }, ] [[package]] @@ -6217,7 +6217,7 @@ wheels = [ [[package]] name = "storage3" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -6226,9 +6226,9 @@ dependencies = [ { name = "pyiceberg" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/be/771246434b5caf3c6187bfdc932eaede00bf5f2937b47475ab25209ede3e/storage3-2.29.0.tar.gz", hash = "sha256:b0cc2f6714655d725c998d2c5ae8c6fb4f56a513bd31e4f85770df557fe021e3", size = 20160, upload-time = "2026-04-24T13:13:04.626Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/b2/6df208d64630744704d00f2c07197170390d6b4d0098617740f6a7a4fa98/storage3-2.30.0.tar.gz", hash = "sha256:b74e3cac149f2c0553dcb5f4d55d8c35d420d88183a1a2df77727d482665972b", size = 20162, upload-time = "2026-05-06T17:35:25.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/c3/790c31866f52c13b26f108b45759bf50dafae3a0bafb4511fadc98ba7c33/storage3-2.29.0-py3-none-any.whl", hash = "sha256:043ef7ff27cc8b9da12be403cf78ee4586180edfcf62b227ff61e1bd79594b06", size = 28284, upload-time = "2026-04-24T13:13:03.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/5c/bb8c8cc448cfae671c4ffee67f3651892ea59b341f27bed54666190eb8ef/storage3-2.30.0-py3-none-any.whl", hash = "sha256:2bd23a34011c018bd9c130d8a70a09ebd060ae80d946c6204a6fc08161ad728d", size = 28284, upload-time = "2026-05-06T17:35:24.659Z" }, ] [[package]] @@ -6254,7 +6254,7 @@ wheels = [ [[package]] name = "supabase" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -6265,37 +6265,37 @@ dependencies = [ { name = "supabase-functions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/a0/2407d616fdf68e8632bbbfb063d1685c38377ac0199e8ca11deaea1f3bf0/supabase-2.29.0.tar.gz", hash = "sha256:a88c4a4eb50fbb903e2e962fbc7c27733b00589140139f9e837bc9fe30dd3615", size = 9689, upload-time = "2026-04-24T13:13:06.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/a6/d2b17021c2db1a9d219c383e0762ac03a62b25468e61ab126b6b561c2f21/supabase-2.30.0.tar.gz", hash = "sha256:efdba41d474038ed220736ba4e64946df56043057ad785c4c3499d27e459975c", size = 9689, upload-time = "2026-05-06T17:35:27.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/52/232f6bbf5326e04ae12e2ef04a24f011a0d7cab379a8b9698652bc8ff78f/supabase-2.29.0-py3-none-any.whl", hash = "sha256:16c3ec4b7094f6b92efc5cd3bb3f96826d3b6dd5d24fe15c89c81166efce88fe", size = 16633, upload-time = "2026-04-24T13:13:05.722Z" }, + { url = "https://files.pythonhosted.org/packages/f0/82/d213be7d0ce0bb18018744c0ee38ba0d6648d41dbc46ac8558cffe80541f/supabase-2.30.0-py3-none-any.whl", hash = "sha256:f9b259194554f7bfd2dca6c23261f2df588016ca18b18e774f4d85bc941edb03", size = 16634, upload-time = "2026-05-06T17:35:26.696Z" }, ] [[package]] name = "supabase-auth" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/7f/7ceeb4c7a2caa188062e934897f0e08e1af0a0e47e376c7645c26b4c39d8/supabase_auth-2.29.0.tar.gz", hash = "sha256:46efc6a3455a23957b846dc974303a844ba0413718cfa899425477ac977f95b3", size = 39154, upload-time = "2026-04-24T13:13:08.509Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/8a/48bbbe0b6703d0670b67e45b90d6a791fd01aace67443d286f760bf48895/supabase_auth-2.30.0.tar.gz", hash = "sha256:6138a53a306a95ed59c03d4e4975469dfc3343a0ade33cc4b37e4ef967ad83f8", size = 39135, upload-time = "2026-05-06T17:35:30.371Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/ac/3c35cf52281f940b9497cf17abfc5c2050ca49f342d60cfafe22dac3482b/supabase_auth-2.29.0-py3-none-any.whl", hash = "sha256:64de6ef8cae80f97d3aa8d5ca507d5427dda5c89885c0bcfe9f8b0263b6fb9a4", size = 48379, upload-time = "2026-04-24T13:13:07.417Z" }, + { url = "https://files.pythonhosted.org/packages/db/40/a99cb4373353bcbf302d962e51da9eac78b3b0f257eb0362c0852b1667f4/supabase_auth-2.30.0-py3-none-any.whl", hash = "sha256:e85e1f51ec0de2172c3a2a8514205f71731a9914f9a770ed199ac0cf054bc82c", size = 48352, upload-time = "2026-05-06T17:35:28.936Z" }, ] [[package]] name = "supabase-functions" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "strenum" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/19/1a1d22749f38f2a6cbca93a6f5a35c9f816c2c3c06bfaa077fa336e90537/supabase_functions-2.29.0.tar.gz", hash = "sha256:0f8a14a2ea9f12b1c208f61dc6f55e2f4b1121f81bf01c08f9b487d22888744d", size = 4683, upload-time = "2026-04-24T13:13:10.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/e6/5cd8559ec2bb332e6027840c1be292f9989c2fc7b47bf40800aec5586791/supabase_functions-2.30.0.tar.gz", hash = "sha256:025acfd25f1c000ba43d0f7b8e366b0d2e9dfc784b842528e21973eb33006113", size = 4683, upload-time = "2026-05-06T17:35:32.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/10/6f8ef0b408ade76b5a439afab588ce5849e9604a23040ca73cfe0b90cb9e/supabase_functions-2.29.0-py3-none-any.whl", hash = "sha256:6f08de52eec5820eae53616868b85e849e181beffaa5d05b8ea1708ceae5e48e", size = 8799, upload-time = "2026-04-24T13:13:09.214Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/9dedab32775df04cc22ca72f194b78e895d940f195bed3e02882a65daa9b/supabase_functions-2.30.0-py3-none-any.whl", hash = "sha256:92419459f102767b954cd034856e4ded8e34c78660b32442d66c8b2899c68011", size = 8803, upload-time = "2026-05-06T17:35:31.342Z" }, ] [[package]] From a2ee151e48633e2ed52d13905938c5e6d0c273c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 12:52:38 +0900 Subject: [PATCH 28/53] chore(deps): bump the github-actions-dependencies group with 2 updates (#36009) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- .github/workflows/translate-i18n-claude.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index f59cc6be48..aefcf1b5ac 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -9,6 +9,6 @@ jobs: pull-requests: write runs-on: depot-ubuntu-24.04 steps: - - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: sync-labels: true diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 7bb6fc1bbd..4e738df684 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -158,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 + uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} From 9127209dd55830e56ce1a6cd74ae64a42f12c472 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <hi@hyoban.cc> Date: Mon, 11 May 2026 11:52:47 +0800 Subject: [PATCH 29/53] chore(web): refresh agent skills (#36015) --- .agents/skills/component-refactoring/SKILL.md | 6 +- .../references/complexity-patterns.md | 4 +- .../references/component-splitting.md | 16 +- .../references/hook-extraction.md | 6 +- .../skills/e2e-cucumber-playwright/SKILL.md | 2 +- .../references/performance.md | 10 +- .../skills/frontend-query-mutation/SKILL.md | 46 - .../agents/openai.yaml | 4 - .../references/contract-patterns.md | 129 -- .../references/runtime-rules.md | 172 --- .agents/skills/frontend-testing/SKILL.md | 16 +- .../frontend-testing/references/mocking.md | 38 +- .../frontend-testing/references/workflow.md | 8 +- .../skills/how-to-write-component/SKILL.md | 63 + .agents/skills/tailwind-css-rules/SKILL.md | 367 ++++++ e2e/AGENTS.md | 18 +- .../generated/enterprise/orpc.gen.ts | 396 ------ .../generated/enterprise/types.gen.ts | 1097 ++--------------- .../contracts/generated/enterprise/zod.gen.ts | 1019 ++------------- web/AGENTS.md | 4 - web/contract/router.ts | 3 + web/docs/test.md | 18 +- 22 files changed, 733 insertions(+), 2709 deletions(-) delete mode 100644 .agents/skills/frontend-query-mutation/SKILL.md delete mode 100644 .agents/skills/frontend-query-mutation/agents/openai.yaml delete mode 100644 .agents/skills/frontend-query-mutation/references/contract-patterns.md delete mode 100644 .agents/skills/frontend-query-mutation/references/runtime-rules.md create mode 100644 .agents/skills/how-to-write-component/SKILL.md create mode 100644 .agents/skills/tailwind-css-rules/SKILL.md diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md index 98a94592ab..a7cae67e8f 100644 --- a/.agents/skills/component-refactoring/SKILL.md +++ b/.agents/skills/component-refactoring/SKILL.md @@ -63,7 +63,7 @@ pnpm analyze-component <path> --json ```typescript // ❌ Before: Complex state logic in component -const Configuration: FC = () => { +function Configuration() { const [modelConfig, setModelConfig] = useState<ModelConfig>(...) const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...) const [completionParams, setCompletionParams] = useState<FormValue>({}) @@ -85,7 +85,7 @@ export const useModelConfig = (appId: string) => { } // Component becomes cleaner -const Configuration: FC = () => { +function Configuration() { const { modelConfig, setModelConfig } = useModelConfig(appId) return <div>...</div> } @@ -189,8 +189,6 @@ const Template = useMemo(() => { **Dify Convention**: - This skill is for component decomposition, not query/mutation design. -- When refactoring data fetching, follow `web/AGENTS.md`. -- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling. - Do not introduce deprecated `useInvalid` / `useReset`. - Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state. diff --git a/.agents/skills/component-refactoring/references/complexity-patterns.md b/.agents/skills/component-refactoring/references/complexity-patterns.md index 5a0a268f38..2873630d4b 100644 --- a/.agents/skills/component-refactoring/references/complexity-patterns.md +++ b/.agents/skills/component-refactoring/references/complexity-patterns.md @@ -60,8 +60,10 @@ const Template = useMemo(() => { **After** (complexity: ~3): ```typescript +import type { ComponentType } from 'react' + // Define lookup table outside component -const TEMPLATE_MAP: Record<AppModeEnum, Record<string, FC<TemplateProps>>> = { +const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplateProps>>> = { [AppModeEnum.CHAT]: { [LanguagesSupported[1]]: TemplateChatZh, [LanguagesSupported[7]]: TemplateChatJa, diff --git a/.agents/skills/component-refactoring/references/component-splitting.md b/.agents/skills/component-refactoring/references/component-splitting.md index 78a3389100..81c007e005 100644 --- a/.agents/skills/component-refactoring/references/component-splitting.md +++ b/.agents/skills/component-refactoring/references/component-splitting.md @@ -65,10 +65,10 @@ interface ConfigurationHeaderProps { onPublish: () => void } -const ConfigurationHeader: FC<ConfigurationHeaderProps> = ({ +function ConfigurationHeader({ isAdvancedMode, onPublish, -}) => { +}: ConfigurationHeaderProps) { const { t } = useTranslation() return ( @@ -136,7 +136,7 @@ const AppInfo = () => { } // ✅ After: Separate view components -const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => { +function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) { return ( <div className="expanded"> {/* Clean, focused expanded view */} @@ -144,7 +144,7 @@ const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => { ) } -const AppInfoCollapsed: FC<AppInfoViewProps> = ({ appDetail, onAction }) => { +function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) { return ( <div className="collapsed"> {/* Clean, focused collapsed view */} @@ -203,12 +203,12 @@ interface AppInfoModalsProps { onSuccess: () => void } -const AppInfoModals: FC<AppInfoModalsProps> = ({ +function AppInfoModals({ appDetail, activeModal, onClose, onSuccess, -}) => { +}: AppInfoModalsProps) { const handleEdit = async (data) => { /* logic */ } const handleDuplicate = async (data) => { /* logic */ } const handleDelete = async () => { /* logic */ } @@ -296,7 +296,7 @@ interface OperationItemProps { onAction: (id: string) => void } -const OperationItem: FC<OperationItemProps> = ({ operation, onAction }) => { +function OperationItem({ operation, onAction }: OperationItemProps) { return ( <div className="operation-item"> <span className="icon">{operation.icon}</span> @@ -435,7 +435,7 @@ interface ChildProps { onSubmit: () => void } -const Child: FC<ChildProps> = ({ value, onChange, onSubmit }) => { +function Child({ value, onChange, onSubmit }: ChildProps) { return ( <div> <input value={value} onChange={e => onChange(e.target.value)} /> diff --git a/.agents/skills/component-refactoring/references/hook-extraction.md b/.agents/skills/component-refactoring/references/hook-extraction.md index 0d567eb2a6..6fad2c8885 100644 --- a/.agents/skills/component-refactoring/references/hook-extraction.md +++ b/.agents/skills/component-refactoring/references/hook-extraction.md @@ -112,13 +112,13 @@ export const useModelConfig = ({ ```typescript // Before: 50+ lines of state management -const Configuration: FC = () => { +function Configuration() { const [modelConfig, setModelConfig] = useState<ModelConfig>(...) // ... lots of related state and effects } // After: Clean component -const Configuration: FC = () => { +function Configuration() { const { modelConfig, setModelConfig, @@ -159,8 +159,6 @@ const Configuration: FC = () => { When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns. -- Follow `web/AGENTS.md` first. -- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling. - Do not introduce deprecated `useInvalid` / `useReset`. - Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks. diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md index de6b58f26d..dd7d204678 100644 --- a/.agents/skills/e2e-cucumber-playwright/SKILL.md +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -23,7 +23,7 @@ Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS - `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter 3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved. 4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved. -5. Re-check official docs with Context7 before introducing a new Playwright or Cucumber pattern. +5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern. ## Local Rules diff --git a/.agents/skills/frontend-code-review/references/performance.md b/.agents/skills/frontend-code-review/references/performance.md index 2d60072f5c..0c33db46d0 100644 --- a/.agents/skills/frontend-code-review/references/performance.md +++ b/.agents/skills/frontend-code-review/references/performance.md @@ -9,18 +9,18 @@ Category: Performance When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks. -## Complex prop memoization +## Complex prop stability -IsUrgent: True +IsUrgent: False Category: Performance ### Description -Wrap complex prop values (objects, arrays, maps) in `useMemo` prior to passing them into child components to guarantee stable references and prevent unnecessary renders. +Only require stable object, array, or map props when there is a clear reason: the child is memoized, the value participates in effect/query dependencies, the value is part of a stable-reference API contract, or profiling/local behavior shows avoidable re-renders. Do not request `useMemo` for every inline object by default; `how-to-write-component` treats memoization as a targeted optimization. Update this file when adding, editing, or removing Performance rules so the catalog remains accurate. -Wrong: +Risky: ```tsx <HeavyComp @@ -31,7 +31,7 @@ Wrong: /> ``` -Right: +Better when stable identity matters: ```tsx const config = useMemo(() => ({ diff --git a/.agents/skills/frontend-query-mutation/SKILL.md b/.agents/skills/frontend-query-mutation/SKILL.md deleted file mode 100644 index 10c49d222e..0000000000 --- a/.agents/skills/frontend-query-mutation/SKILL.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: frontend-query-mutation -description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions()/mutationOptions() directly or extract a helper or use-* hook, configuring oRPC experimental_defaults/default options, handling conditional queries, cache updates/invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers. ---- - -# Frontend Query & Mutation - -## Intent - -- Keep contract as the single source of truth in `web/contract/*`. -- Prefer contract-shaped `queryOptions()` and `mutationOptions()`. -- Keep default cache behavior with `consoleQuery`/`marketplaceQuery` setup, and keep business orchestration in feature vertical hooks when direct contract calls are not enough. -- Treat `web/service/use-*` query or mutation wrappers as legacy migration targets, not the preferred destination. -- Keep abstractions minimal to preserve TypeScript inference. - -## Workflow - -1. Identify the change surface. - - Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape. - - Read `references/runtime-rules.md` for conditional queries, default options, cache updates/invalidation, error handling, and legacy migrations. - - Read both references when a task spans contract shape and runtime behavior. -2. Implement the smallest abstraction that fits the task. - - Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site. - - Extract a small shared query helper only when multiple call sites share the same extra options. - - Create or keep feature hooks only for real orchestration or shared domain behavior. - - When touching thin `web/service/use-*` wrappers, migrate them away when feasible. -3. Preserve Dify conventions. - - Keep contract inputs in `{ params, query?, body? }` shape. - - Bind default cache updates/invalidation in `createTanstackQueryUtils(...experimental_defaults...)`; use feature hooks only for workflows that cannot be expressed as default operation behavior. - - Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required. - -## Files Commonly Touched - -- `web/contract/console/*.ts` -- `web/contract/marketplace.ts` -- `web/contract/router.ts` -- `web/service/client.ts` -- legacy `web/service/use-*.ts` files when migrating wrappers away -- component and hook call sites using `consoleQuery` or `marketplaceQuery` - -## References - -- Use `references/contract-patterns.md` for contract shape, router registration, query and mutation helpers, and anti-patterns that degrade inference. -- Use `references/runtime-rules.md` for conditional queries, invalidation, `mutate` versus `mutateAsync`, and legacy migration rules. - -Treat this skill as the single query and mutation entry point for Dify frontend work. Keep detailed rules in the reference files instead of duplicating them in project docs. diff --git a/.agents/skills/frontend-query-mutation/agents/openai.yaml b/.agents/skills/frontend-query-mutation/agents/openai.yaml deleted file mode 100644 index 79e7e7d214..0000000000 --- a/.agents/skills/frontend-query-mutation/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Frontend Query & Mutation" - short_description: "Dify TanStack Query, oRPC, and default option patterns" - default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, oRPC default options, conditional queries, cache updates/invalidation, or legacy query/mutation migrations." diff --git a/.agents/skills/frontend-query-mutation/references/contract-patterns.md b/.agents/skills/frontend-query-mutation/references/contract-patterns.md deleted file mode 100644 index 25ccfc81d7..0000000000 --- a/.agents/skills/frontend-query-mutation/references/contract-patterns.md +++ /dev/null @@ -1,129 +0,0 @@ -# Contract Patterns - -## Table of Contents - -- Intent -- Minimal structure -- Core workflow -- Query usage decision rule -- Mutation usage decision rule -- Thin hook decision rule -- Anti-patterns -- Contract rules -- Type export - -## Intent - -- Keep contract as the single source of truth in `web/contract/*`. -- Default query usage to call-site `useQuery(consoleQuery|marketplaceQuery.xxx.queryOptions(...))` when endpoint behavior maps 1:1 to the contract. -- Keep abstractions minimal and preserve TypeScript inference. - -## Minimal Structure - -```text -web/contract/ -├── base.ts -├── router.ts -├── marketplace.ts -└── console/ - ├── billing.ts - └── ...other domains -web/service/client.ts -``` - -## Core Workflow - -1. Define contract in `web/contract/console/{domain}.ts` or `web/contract/marketplace.ts`. - - Use `base.route({...}).output(type<...>())` as the baseline. - - Add `.input(type<...>())` only when the request has `params`, `query`, or `body`. - - For `GET` without input, omit `.input(...)`; do not use `.input(type<unknown>())`. -2. Register contract in `web/contract/router.ts`. - - Import directly from domain files and nest by API prefix. -3. Consume from UI call sites via oRPC query utilities. - -```typescript -import { useQuery } from '@tanstack/react-query' -import { consoleQuery } from '@/service/client' - -const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({ - staleTime: 5 * 60 * 1000, - throwOnError: true, - select: invoice => invoice.url, -})) -``` - -## Query Usage Decision Rule - -1. Default to direct `*.queryOptions(...)` usage at the call site. -2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook. -3. Create or keep feature hooks only for orchestration. - - Combine multiple queries or mutations. - - Share domain-level derived state or invalidation helpers. - - Prefer `web/features/{domain}/hooks/*` for feature-owned workflows. -4. Treat `web/service/use-{domain}.ts` as legacy. - - Do not create new thin service wrappers for oRPC contracts. - - When touching existing wrappers, inline direct `consoleQuery` or `marketplaceQuery` consumption when the wrapper is only a passthrough. - -```typescript -const invoicesBaseQueryOptions = () => - consoleQuery.billing.invoices.queryOptions({ retry: false }) - -const invoiceQuery = useQuery({ - ...invoicesBaseQueryOptions(), - throwOnError: true, -}) -``` - -## Mutation Usage Decision Rule - -1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`. -2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic. - -```typescript -const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions()) -``` - -## Thin Hook Decision Rule - -Remove thin hooks when they only rename a single oRPC query or mutation helper. -Keep hooks when they orchestrate business behavior across multiple operations, own local workflow state, or normalize a feature-specific API. -Prefer feature vertical hooks for kept orchestration. Do not move new contract-first wrappers into `web/service/use-*`. - -Use: - -```typescript -const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions()) -``` - -Keep: - -```typescript -const applyTagBindingsMutation = useApplyTagBindingsMutation() -``` - -`useApplyTagBindingsMutation` is acceptable because it coordinates bind and unbind requests, computes deltas, and exposes a feature-level workflow rather than a single endpoint passthrough. - -## Anti-Patterns - -- Do not wrap `useQuery` with `options?: Partial<UseQueryOptions>`. -- Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case. -- Do not create thin `use-*` passthrough hooks for a single endpoint. -- Do not create business-layer helpers whose only purpose is to call `consoleQuery.xxx.mutationOptions()` or `queryOptions()`. -- Do not introduce new `web/service/use-*` files for oRPC contract passthroughs. -- These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection. - -## Contract Rules - -- Input structure: always use `{ params, query?, body? }`. -- No-input `GET`: omit `.input(...)`; do not use `.input(type<unknown>())`. -- Path params: use `{paramName}` in the path and match it in the `params` object. -- Router nesting: group by API prefix, for example `/billing/*` becomes `billing: {}`. -- No barrel files: import directly from specific files. -- Types: import from `@/types/` and use the `type<T>()` helper. -- Mutations: prefer `mutationOptions`; use explicit `mutationKey` mainly for defaults, filtering, and devtools. - -## Type Export - -```typescript -export type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract> -``` diff --git a/.agents/skills/frontend-query-mutation/references/runtime-rules.md b/.agents/skills/frontend-query-mutation/references/runtime-rules.md deleted file mode 100644 index 91b484d438..0000000000 --- a/.agents/skills/frontend-query-mutation/references/runtime-rules.md +++ /dev/null @@ -1,172 +0,0 @@ -# Runtime Rules - -## Table of Contents - -- Conditional queries -- oRPC default options -- Cache invalidation -- Key API guide -- `mutate` vs `mutateAsync` -- Legacy migration - -## Conditional Queries - -Prefer contract-shaped `queryOptions(...)`. -When required input is missing, prefer `input: skipToken` instead of placeholder params or non-null assertions. -Use `enabled` only for extra business gating after the input itself is already valid. - -```typescript -import { skipToken, useQuery } from '@tanstack/react-query' - -// Disable the query by skipping input construction. -function useAccessMode(appId: string | undefined) { - return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({ - input: appId - ? { params: { appId } } - : skipToken, - })) -} - -// Avoid runtime-only guards that bypass type checking. -function useBadAccessMode(appId: string | undefined) { - return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({ - input: { params: { appId: appId! } }, - enabled: !!appId, - })) -} -``` - -## oRPC Default Options - -Use `experimental_defaults` in `createTanstackQueryUtils` when a contract operation should always carry shared TanStack Query behavior, such as default stale time, mutation cache writes, or invalidation. - -Place defaults at the query utility creation point in `web/service/client.ts`: - -```typescript -export const consoleQuery = createTanstackQueryUtils(consoleClient, { - path: ['console'], - experimental_defaults: { - tags: { - create: { - mutationOptions: { - onSuccess: (tag, _variables, _result, context) => { - context.client.setQueryData( - consoleQuery.tags.list.queryKey({ - input: { - query: { - type: tag.type, - }, - }, - }), - (oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags, - ) - }, - }, - }, - }, - }, -}) -``` - -Rules: - -- Keep defaults inline in the `consoleQuery` or `marketplaceQuery` initialization when they need sibling oRPC key builders. -- Do not create a wrapper function solely to host `createTanstackQueryUtils`. -- Do not split defaults into a vertical feature file if that forces handwritten operation paths such as `generateOperationKey(['console', ...])`. -- Keep feature-level orchestration in the feature vertical; keep query utility lifecycle defaults with the query utility. -- Prefer call-site callbacks for UI feedback only; shared cache behavior belongs in oRPC defaults when it is tied to a contract operation. - -## Cache Invalidation - -Bind shared invalidation in oRPC defaults when it is tied to a contract operation. -Use feature vertical hooks only for multi-operation workflows or domain orchestration that cannot live in a single operation default. -Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate. - -Use: - -- `.key()` for namespace or prefix invalidation -- `.queryKey(...)` only for exact cache reads or writes such as `getQueryData` and `setQueryData` -- `queryClient.invalidateQueries(...)` in mutation `onSuccess` - -Do not use deprecated `useInvalid` from `use-base.ts`. - -```typescript -// Feature orchestration owns cache invalidation only when defaults are not enough. -export const useUpdateAccessMode = () => { - const queryClient = useQueryClient() - - return useMutation(consoleQuery.accessControl.updateAccessMode.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(), - }) - }, - })) -} - -// Component only adds UI behavior. -updateAccessMode({ appId, mode }, { - onSuccess: () => toast.success('...'), -}) - -// Avoid putting invalidation knowledge in the component. -mutate({ appId, mode }, { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(), - }) - }, -}) -``` - -## Key API Guide - -- `.key(...)` - - Use for partial matching operations. - - Prefer it for invalidation, refetch, and cancel patterns. - - Example: `queryClient.invalidateQueries({ queryKey: consoleQuery.billing.key() })` -- `.queryKey(...)` - - Use for a specific query's full key. - - Prefer it for exact cache addressing and direct reads or writes. -- `.mutationKey(...)` - - Use for a specific mutation's full key. - - Prefer it for mutation defaults registration, mutation-status filtering, and devtools grouping. - -## `mutate` vs `mutateAsync` - -Prefer `mutate` by default. -Use `mutateAsync` only when Promise semantics are truly required, such as parallel mutations or sequential steps with result dependencies. - -Rules: - -- Event handlers should usually call `mutate(...)` with `onSuccess` or `onError`. -- Every `await mutateAsync(...)` must be wrapped in `try/catch`. -- Do not use `mutateAsync` when callbacks already express the flow clearly. - -```typescript -// Default case. -mutation.mutate(data, { - onSuccess: result => router.push(result.url), -}) - -// Promise semantics are required. -try { - const order = await createOrder.mutateAsync(orderData) - await confirmPayment.mutateAsync({ orderId: order.id, token }) - router.push(`/orders/${order.id}`) -} -catch (error) { - toast.error(error instanceof Error ? error.message : 'Unknown error') -} -``` - -## Legacy Migration - -When touching old code, migrate it toward these rules: - -| Old pattern | New pattern | -|---|---| -| `useInvalid(key)` in service wrappers | oRPC defaults, or a feature vertical hook for real orchestration | -| component-triggered invalidation after mutation | move invalidation into oRPC defaults or a feature vertical hook | -| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` | -| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` | diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index 105c979c58..86675dfeba 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -5,7 +5,7 @@ description: Generate Vitest + React Testing Library tests for Dify frontend com # Dify Frontend Testing Skill -This skill enables Claude to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices. +This skill enables Codex to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices. > **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`). @@ -24,23 +24,15 @@ Apply this skill when the user: **Do NOT apply** when: - User is asking about backend/API tests (Python/pytest) -- User is asking about E2E tests (Playwright/Cypress) +- User is asking about E2E tests (Cucumber + Playwright under `e2e/`) - User is only asking conceptual questions without code context ## Quick Reference -### Tech Stack - -| Tool | Version | Purpose | -|------|---------|---------| -| Vitest | 4.0.16 | Test runner | -| React Testing Library | 16.0 | Component testing | -| jsdom | - | Test environment | -| nock | 14.0 | HTTP mocking | -| TypeScript | 5.x | Type safety | - ### Key Commands +Run these commands from `web/`. From the repository root, prefix them with `pnpm -C web`. + ```bash # Run all tests pnpm test diff --git a/.agents/skills/frontend-testing/references/mocking.md b/.agents/skills/frontend-testing/references/mocking.md index 8c2f1c0c58..7723e4df21 100644 --- a/.agents/skills/frontend-testing/references/mocking.md +++ b/.agents/skills/frontend-testing/references/mocking.md @@ -56,7 +56,7 @@ See [Zustand Store Testing](#zustand-store-testing) section for full details. | Location | Purpose | |----------|---------| -| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `next/image`, `zustand`) | +| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) | | `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) | | `web/__mocks__/` | Reusable mock factories shared across multiple test files | | Test file | Test-specific mocks, inline with `vi.mock()` | @@ -216,28 +216,21 @@ describe('Component', () => { }) ``` -### 5. HTTP Mocking with Nock +### 5. HTTP and `fetch` Mocking ```typescript -import nock from 'nock' - -const GITHUB_HOST = 'https://api.github.com' -const GITHUB_PATH = '/repos/owner/repo' - -const mockGithubApi = (status: number, body: Record<string, unknown>, delayMs = 0) => { - return nock(GITHUB_HOST) - .get(GITHUB_PATH) - .delay(delayMs) - .reply(status, body) -} - describe('GithubComponent', () => { - afterEach(() => { - nock.cleanAll() + beforeEach(() => { + vi.clearAllMocks() }) it('should display repo info', async () => { - mockGithubApi(200, { name: 'dify', stars: 1000 }) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'dify', stars: 1000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) render(<GithubComponent />) @@ -247,7 +240,12 @@ describe('GithubComponent', () => { }) it('should handle API error', async () => { - mockGithubApi(500, { message: 'Server error' }) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ) render(<GithubComponent />) @@ -258,6 +256,8 @@ describe('GithubComponent', () => { }) ``` +Prefer mocking `@/service/*` modules or spying on `global.fetch` / `ky` clients with deterministic responses. Do not introduce an HTTP interception dependency such as `nock` or MSW unless it is already declared in the workspace or adding it is part of the task. + ### 6. Context Providers ```typescript @@ -332,7 +332,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => { 1. **Don't mock Zustand store modules** - Use real stores with `setState()` 1. Don't mock components you can import directly 1. Don't create overly simplified mocks that miss conditional logic -1. Don't forget to clean up nock after each test +1. Don't leave HTTP mocks or service mock state leaking between tests 1. Don't use `any` types in mocks without necessity ### Mock Decision Tree diff --git a/.agents/skills/frontend-testing/references/workflow.md b/.agents/skills/frontend-testing/references/workflow.md index bc4ed8285a..27755d42a7 100644 --- a/.agents/skills/frontend-testing/references/workflow.md +++ b/.agents/skills/frontend-testing/references/workflow.md @@ -227,12 +227,12 @@ Failing tests compound: **Fix failures immediately before proceeding.** -## Integration with Claude's Todo Feature +## Integration with Codex's Todo Feature -When using Claude for multi-file testing: +When using Codex for multi-file testing: -1. **Ask Claude to create a todo list** before starting -1. **Request one file at a time** or ensure Claude processes incrementally +1. **Create a todo list** before starting +1. **Process one file at a time** 1. **Verify each test passes** before asking for the next 1. **Mark todos complete** as you progress diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md new file mode 100644 index 0000000000..f33a9dd75e --- /dev/null +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -0,0 +1,63 @@ +--- +name: how-to-write-component +description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. +--- + +# How To Write A Component + +Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation. + +## Core Defaults + +- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit. +- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them. +- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature. +- Use Tailwind CSS v4.1+ rules via the `tailwind-css-rules` skill. Prefer v4 utilities, `gap`, `text-size/line-height`, `min-h-dvh`, and avoid deprecated utilities and `@apply`. + +## Ownership + +- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home. +- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing. +- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children. +- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. +- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. +- Prefer uncontrolled DOM state and CSS variables before adding controlled props. + +## Components, Props, And Types + +- Type component signatures directly; do not use `FC` or `React.FC`. +- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs. +- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files. +- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer. +- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them. +- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary. +- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks. + +## Queries And Mutations + +- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape. +- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. +- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it. +- Keep feature hooks for real orchestration, workflow state, or shared domain behavior. +- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid. +- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows. +- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules. +- Do not use deprecated `useInvalid` or `useReset`. +- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`. + +## Component Boundaries + +- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner. +- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer. +- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary. +- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow. +- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment. +- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. +- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. + +## Navigation, Effects, And Performance + +- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission. +- Treat `useEffect` as a last resort. First try deriving values during render, moving event-driven work into handlers, or using existing hooks/APIs for persistence, subscriptions, media queries, timers, and DOM sync. +- Do not use `useEffect` directly in components. If unavoidable, encapsulate it in a purpose-built hook so the component consumes a declarative API. +- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. diff --git a/.agents/skills/tailwind-css-rules/SKILL.md b/.agents/skills/tailwind-css-rules/SKILL.md new file mode 100644 index 0000000000..3528548036 --- /dev/null +++ b/.agents/skills/tailwind-css-rules/SKILL.md @@ -0,0 +1,367 @@ +--- +name: tailwind-css-rules +description: Tailwind CSS v4.1+ rules and best practices. Use when writing, reviewing, refactoring, or upgrading Tailwind CSS classes and styles, especially v4 utility migrations, layout spacing, typography, responsive variants, dark mode, gradients, CSS variables, and component styling. +--- + +# Tailwind CSS Rules and Best Practices + +## Core Principles + +- **Always use Tailwind CSS v4.1+** - Ensure the codebase is using the latest version +- **Do not use deprecated or removed utilities** - ALWAYS use the replacement +- **Never use `@apply`** - Use CSS variables, the `--spacing()` function, or framework components instead +- **Check for redundant classes** - Remove any classes that aren't necessary +- **Group elements logically** to simplify responsive tweaks later + +## Upgrading to Tailwind CSS v4 + +### Before Upgrading + +- **Always read the upgrade documentation first** - Read https://tailwindcss.com/docs/upgrade-guide and https://tailwindcss.com/blog/tailwindcss-v4 before starting an upgrade. +- Ensure the git repository is in a clean state before starting + +### Upgrade Process + +1. Run the upgrade command: `npx @tailwindcss/upgrade@latest` for both major and minor updates +2. The tool will convert JavaScript config files to the new CSS format +3. Review all changes extensively to clean up any false positives +4. Test thoroughly across your application + +## Breaking Changes Reference + +### Removed Utilities (NEVER use these in v4) + +| ❌ Deprecated | ✅ Replacement | +| ----------------------- | ------------------------------------------------- | +| `bg-opacity-*` | Use opacity modifiers like `bg-black/50` | +| `text-opacity-*` | Use opacity modifiers like `text-black/50` | +| `border-opacity-*` | Use opacity modifiers like `border-black/50` | +| `divide-opacity-*` | Use opacity modifiers like `divide-black/50` | +| `ring-opacity-*` | Use opacity modifiers like `ring-black/50` | +| `placeholder-opacity-*` | Use opacity modifiers like `placeholder-black/50` | +| `flex-shrink-*` | `shrink-*` | +| `flex-grow-*` | `grow-*` | +| `overflow-ellipsis` | `text-ellipsis` | +| `decoration-slice` | `box-decoration-slice` | +| `decoration-clone` | `box-decoration-clone` | + +### Renamed Utilities + +Use the v4 name when migrating code that still carries Tailwind v3 semantics. Do not blanket-replace existing v4 classes: classes such as `rounded-sm`, `shadow-sm`, `ring-1`, and `ring-2` are valid in this codebase when they intentionally represent the current design scale. + +| ❌ v3 pattern | ✅ v4 pattern | +| ------------------- | -------------------------------------------------- | +| `bg-gradient-*` | `bg-linear-*` | +| old shadow scale | verify against the current Tailwind/design scale | +| old blur scale | verify against the current Tailwind/design scale | +| old radius scale | use the Dify radius token mapping when applicable | +| `outline-none` | `outline-hidden` | +| bare `ring` utility | use an explicit ring width such as `ring-1`/`ring-2`/`ring-3` | + +For Figma radius tokens, follow `packages/dify-ui/AGENTS.md`. For example, `--radius/xs` maps to `rounded-sm`; do not rewrite it to `rounded-xs`. + +## Layout and Spacing Rules + +### Flexbox and Grid Spacing + +#### Always use gap utilities for internal spacing + +Gap provides consistent spacing without edge cases (no extra space on last items). It's cleaner and more maintainable than margins on children. + +```html +<!-- ❌ Don't do this --> +<div class="flex"> + <div class="mr-4">Item 1</div> + <div class="mr-4">Item 2</div> + <div>Item 3</div> + <!-- No margin on last --> +</div> + +<!-- ✅ Do this instead --> +<div class="flex gap-4"> + <div>Item 1</div> + <div>Item 2</div> + <div>Item 3</div> +</div> +``` + +#### Gap vs Space utilities + +- **Never use `space-x-*` or `space-y-*` in flex/grid layouts** - always use gap +- Space utilities add margins to children and have issues with wrapped items +- Gap works correctly with flex-wrap and all flex directions + +```html +<!-- ❌ Avoid space utilities in flex containers --> +<div class="flex flex-wrap space-x-4"> + <!-- Space utilities break with wrapped items --> +</div> + +<!-- ✅ Use gap for consistent spacing --> +<div class="flex flex-wrap gap-4"> + <!-- Gap works perfectly with wrapping --> +</div> +``` + +### General Spacing Guidelines + +- **Prefer top and left margins** over bottom and right margins (unless conditionally rendered) +- **Use padding on parent containers** instead of bottom margins on the last child +- **Always use `min-h-dvh` instead of `min-h-screen`** - `min-h-screen` is buggy on mobile Safari +- **Prefer `size-*` utilities** over separate `w-*` and `h-*` when setting equal dimensions +- For max-widths, prefer the container scale (e.g., `max-w-2xs` over `max-w-72`) + +## Typography Rules + +### Line Heights + +- **Never use `leading-*` classes** - Always use line height modifiers with text size +- **Always use fixed line heights from the spacing scale** - Don't use named values + +```html +<!-- ❌ Don't do this --> +<p class="text-base leading-7">Text with separate line height</p> +<p class="text-lg leading-relaxed">Text with named line height</p> + +<!-- ✅ Do this instead --> +<p class="text-base/7">Text with line height modifier</p> +<p class="text-lg/8">Text with specific line height</p> +``` + +### Font Size Reference + +Be precise with font sizes - know the actual pixel values: + +- `text-xs` = 12px +- `text-sm` = 14px +- `text-base` = 16px +- `text-lg` = 18px +- `text-xl` = 20px + +## Color and Opacity + +### Opacity Modifiers + +**Never use `bg-opacity-*`, `text-opacity-*`, etc.** - use the opacity modifier syntax: + +```html +<!-- ❌ Don't do this --> +<div class="bg-red-500 bg-opacity-60">Old opacity syntax</div> + +<!-- ✅ Do this instead --> +<div class="bg-red-500/60">Modern opacity syntax</div> +``` + +## Responsive Design + +### Breakpoint Optimization + +- **Check for redundant classes across breakpoints** +- **Only add breakpoint variants when values change** + +```html +<!-- ❌ Redundant breakpoint classes --> +<div class="px-4 md:px-4 lg:px-4"> + <!-- md:px-4 and lg:px-4 are redundant --> +</div> + +<!-- ✅ Efficient breakpoint usage --> +<div class="px-4 lg:px-8"> + <!-- Only specify when value changes --> +</div> +``` + +## Dark Mode + +### Dark Mode Best Practices + +- Use the plain `dark:` variant pattern +- Put light mode styles first, then dark mode styles +- Ensure `dark:` variant comes before other variants + +```html +<!-- ✅ Correct dark mode pattern --> +<div class="bg-white text-black dark:bg-black dark:text-white"> + <button class="hover:bg-gray-100 dark:hover:bg-gray-800">Click me</button> +</div> +``` + +## Gradient Utilities + +- **ALWAYS Use `bg-linear-*` instead of `bg-gradient-*` utilities** - The gradient utilities were renamed in v4 +- Use the new `bg-radial` or `bg-radial-[<position>]` to create radial gradients +- Use the new `bg-conic` or `bg-conic-*` to create conic gradients + +```html +<!-- ✅ Use the new gradient utilities --> +<div class="h-14 bg-linear-to-br from-violet-500 to-fuchsia-500"></div> +<div + class="size-18 bg-radial-[at_50%_75%] from-sky-200 via-blue-400 to-indigo-900 to-90%" +></div> +<div + class="size-24 bg-conic-180 from-indigo-600 via-indigo-50 to-indigo-600" +></div> + +<!-- ❌ Do not use bg-gradient-* utilities --> +<div class="h-14 bg-gradient-to-br from-violet-500 to-fuchsia-500"></div> +``` + +## Working with CSS Variables + +### Accessing Theme Values + +Tailwind CSS v4 exposes all theme values as CSS variables: + +```css +/* Access colors, and other theme values */ +.custom-element { + background: var(--color-red-500); + border-radius: var(--radius-lg); +} +``` + +### The `--spacing()` Function + +Use the dedicated `--spacing()` function for spacing calculations: + +```css +.custom-class { + margin-top: calc(100vh - --spacing(16)); +} +``` + +### Extending theme values + +Use CSS to extend theme values: + +```css +@import "tailwindcss"; + +@theme { + --color-mint-500: oklch(0.72 0.11 178); +} +``` + +```html +<div class="bg-mint-500"> + <!-- ... --> +</div> +``` + +## New v4 Features + +### Container Queries + +Use the `@container` class and size variants: + +```html +<article class="@container"> + <div class="flex flex-col @md:flex-row @lg:gap-8"> + <img class="w-full @md:w-48" /> + <div class="mt-4 @md:mt-0"> + <!-- Content adapts to container size --> + </div> + </div> +</article> +``` + +### Container Query Units + +Use container-based units like `cqw` for responsive sizing: + +```html +<div class="@container"> + <h1 class="text-[50cqw]">Responsive to container width</h1> +</div> +``` + +### Text Shadows (v4.1) + +Use text-shadow-\* utilities from text-shadow-2xs to text-shadow-lg: + +```html +<!-- ✅ Text shadow examples --> +<h1 class="text-shadow-lg">Large shadow</h1> +<p class="text-shadow-sm/50">Small shadow with opacity</p> +``` + +### Masking (v4.1) + +Use the new composable mask utilities for image and gradient masks: + +```html +<!-- ✅ Linear gradient masks on specific sides --> +<div class="mask-t-from-50%">Top fade</div> +<div class="mask-b-from-20% mask-b-to-80%">Bottom gradient</div> +<div class="mask-linear-from-white mask-linear-to-black/60"> + Fade from white to black +</div> + +<!-- ✅ Radial gradient masks --> +<div class="mask-radial-[100%_100%] mask-radial-from-75% mask-radial-at-left"> + Radial mask +</div> +``` + +## Component Patterns + +### Avoiding Utility Inheritance + +Don't add utilities to parents that you override in children: + +```html +<!-- ❌ Avoid this pattern --> +<div class="text-center"> + <h1>Centered Heading</h1> + <div class="text-left">Left-aligned content</div> +</div> + +<!-- ✅ Better approach --> +<div> + <h1 class="text-center">Centered Heading</h1> + <div>Left-aligned content</div> +</div> +``` + +### Component Extraction + +- Extract repeated patterns into framework components, not CSS classes +- Keep utility classes in templates/JSX +- Use data attributes for complex state-based styling + +## CSS Best Practices + +### Nesting Guidelines + +- Use nesting when styling both parent and children +- Avoid empty parent selectors + +```css +/* ✅ Good nesting - parent has styles */ +.card { + padding: --spacing(4); + + > .card-title { + font-weight: bold; + } +} + +/* ❌ Avoid empty parents */ +ul { + > li { + /* Parent has no styles */ + } +} +``` + +## Common Pitfalls to Avoid + +1. **Using old opacity utilities** - Always use `/opacity` syntax like `bg-red-500/60` +2. **Redundant breakpoint classes** - Only specify changes +3. **Space utilities in flex/grid** - Always use gap +4. **Leading utilities** - Use line-height modifiers like `text-sm/6` +5. **Arbitrary values** - Use the design scale +6. **@apply directive** - Use components or CSS variables +7. **min-h-screen on mobile** - Use min-h-dvh +8. **Separate width/height** - Use size utilities when equal +9. **Arbitrary values** - Always use Tailwind's predefined scale whenever possible (e.g., use `ml-4` over `ml-[16px]`) diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index e56aab20a7..c05b5105be 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -31,7 +31,7 @@ pnpm -C e2e check `pnpm install` is resolved through the repository workspace and uses the shared root lockfile plus `pnpm-workspace.yaml`. -Use `pnpm check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package. +Use `pnpm -C e2e check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package. Common commands: @@ -68,8 +68,8 @@ flowchart TD C --> D["Cucumber loads config, steps, and support modules"] D --> E["BeforeAll bootstraps shared auth state via /install"] E --> F{"Which command is running?"} - F -->|`pnpm e2e`| G["Run config default tags: not @fresh and not @skip"] - F -->|`pnpm e2e:full*`| H["Override tags to not @skip"] + F -->|`pnpm -C e2e e2e`| G["Run config default tags: not @fresh and not @skip"] + F -->|`pnpm -C e2e e2e:full*`| H["Override tags to not @skip"] G --> I["Per-scenario BrowserContext from shared browser"] H --> I I --> J["Failure artifacts written to cucumber-report/artifacts"] @@ -99,7 +99,7 @@ Behavior depends on instance state: - uninitialized instance: completes install and stores authenticated state - initialized instance: signs in and reuses authenticated state -Because of that, the `@fresh` install scenario only runs in the `pnpm e2e:full*` flows. The default `pnpm e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance. +Because of that, the `@fresh` install scenario only runs in the `pnpm -C e2e e2e:full*` flows. The default `pnpm -C e2e e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance. Reset all persisted E2E state: @@ -126,7 +126,7 @@ pnpm -C e2e e2e:middleware:up Stop the full middleware stack: ```bash -pnpm e2e:middleware:down +pnpm -C e2e e2e:middleware:down ``` The middleware stack includes: @@ -141,15 +141,15 @@ The middleware stack includes: Fresh install verification: ```bash -pnpm e2e:full +pnpm -C e2e e2e:full ``` Run the Cucumber suite against an already running middleware stack: ```bash -pnpm e2e:middleware:up -pnpm e2e -pnpm e2e:middleware:down +pnpm -C e2e e2e:middleware:up +pnpm -C e2e e2e +pnpm -C e2e e2e:middleware:down ``` Artifacts and diagnostics: diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 73eb850001..6b9b76470a 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -7,63 +7,6 @@ import { zConsoleSsoOAuth2LoginResponse, zConsoleSsoOidcLoginResponse, zConsoleSsoSamlLoginResponse, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse, - zEnterpriseAppDeployConsoleCreateAppInstanceBody, - zEnterpriseAppDeployConsoleCreateAppInstanceResponse, - zEnterpriseAppDeployConsoleCreateDeploymentBody, - zEnterpriseAppDeployConsoleCreateDeploymentPath, - zEnterpriseAppDeployConsoleCreateDeploymentResponse, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse, - zEnterpriseAppDeployConsoleCreateReleaseBody, - zEnterpriseAppDeployConsoleCreateReleasePath, - zEnterpriseAppDeployConsoleCreateReleaseResponse, - zEnterpriseAppDeployConsoleDeleteAppInstancePath, - zEnterpriseAppDeployConsoleDeleteAppInstanceResponse, - zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath, - zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse, - zEnterpriseAppDeployConsoleGetAppInstanceAccessPath, - zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse, - zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath, - zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse, - zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath, - zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse, - zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath, - zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse, - zEnterpriseAppDeployConsoleListAppInstancesQuery, - zEnterpriseAppDeployConsoleListAppInstancesResponse, - zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath, - zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse, - zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse, - zEnterpriseAppDeployConsoleListReleasesPath, - zEnterpriseAppDeployConsoleListReleasesQuery, - zEnterpriseAppDeployConsoleListReleasesResponse, - zEnterpriseAppDeployConsoleListRuntimeInstancesPath, - zEnterpriseAppDeployConsoleListRuntimeInstancesResponse, - zEnterpriseAppDeployConsolePreviewReleaseBody, - zEnterpriseAppDeployConsolePreviewReleasePath, - zEnterpriseAppDeployConsolePreviewReleaseResponse, - zEnterpriseAppDeployConsoleSearchAccessSubjectsPath, - zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery, - zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse, - zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody, - zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath, - zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse, - zEnterpriseAppDeployConsoleUpdateAccessChannelsBody, - zEnterpriseAppDeployConsoleUpdateAccessChannelsPath, - zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse, - zEnterpriseAppDeployConsoleUpdateAppInstanceBody, - zEnterpriseAppDeployConsoleUpdateAppInstancePath, - zEnterpriseAppDeployConsoleUpdateAppInstanceResponse, - zEnterpriseAppDeployConsoleUpdateDeveloperApiBody, - zEnterpriseAppDeployConsoleUpdateDeveloperApiPath, - zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse, zWebAppAuthGetGroupSubjectsQuery, zWebAppAuthGetGroupSubjectsResponse, zWebAppAuthGetWebAppAccessModeQuery, @@ -78,344 +21,6 @@ import { zWebAppAuthUpdateWebAppWhitelistSubjectsResponse, } from './zod.gen' -export const listAppInstances = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListAppInstances', - path: '/enterprise/app-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ query: zEnterpriseAppDeployConsoleListAppInstancesQuery.optional() })) - .output(zEnterpriseAppDeployConsoleListAppInstancesResponse) - -export const createAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateAppInstance', - path: '/enterprise/app-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ body: zEnterpriseAppDeployConsoleCreateAppInstanceBody })) - .output(zEnterpriseAppDeployConsoleCreateAppInstanceResponse) - -export const deleteAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'EnterpriseAppDeployConsole_DeleteAppInstance', - path: '/enterprise/app-instances/{appInstanceId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleDeleteAppInstancePath })) - .output(zEnterpriseAppDeployConsoleDeleteAppInstanceResponse) - -export const updateAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateAppInstance', - path: '/enterprise/app-instances/{appInstanceId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateAppInstanceBody, - params: zEnterpriseAppDeployConsoleUpdateAppInstancePath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateAppInstanceResponse) - -export const getAppInstanceAccess = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceAccess', - path: '/enterprise/app-instances/{appInstanceId}/access', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceAccessPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse) - -export const updateAccessChannels = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateAccessChannels', - path: '/enterprise/app-instances/{appInstanceId}/access-channels', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateAccessChannelsBody, - params: zEnterpriseAppDeployConsoleUpdateAccessChannelsPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse) - -export const searchAccessSubjects = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_SearchAccessSubjects', - path: '/enterprise/app-instances/{appInstanceId}/access-subjects:search', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - params: zEnterpriseAppDeployConsoleSearchAccessSubjectsPath, - query: zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery.optional(), - }), - ) - .output(zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse) - -export const createDeveloperApiKey = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateDeveloperApiKey', - path: '/enterprise/app-instances/{appInstanceId}/api-keys', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody, - params: zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse) - -export const deleteDeveloperApiKey = oc - .route({ - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'EnterpriseAppDeployConsole_DeleteDeveloperApiKey', - path: '/enterprise/app-instances/{appInstanceId}/api-keys/{apiKeyId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath })) - .output(zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse) - -export const listDeploymentBindingOptions = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListDeploymentBindingOptions', - path: '/enterprise/app-instances/{appInstanceId}/deployment-binding-options', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath })) - .output(zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse) - -export const createDeployment = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateDeployment', - path: '/enterprise/app-instances/{appInstanceId}/deployments', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateDeploymentBody, - params: zEnterpriseAppDeployConsoleCreateDeploymentPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateDeploymentResponse) - -export const updateDeveloperApi = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateDeveloperApi', - path: '/enterprise/app-instances/{appInstanceId}/developer-api', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateDeveloperApiBody, - params: zEnterpriseAppDeployConsoleUpdateDeveloperApiPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse) - -export const getEnvironmentAccessPolicy = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetEnvironmentAccessPolicy', - path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath })) - .output(zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse) - -export const updateEnvironmentAccessPolicy = oc - .route({ - inputStructure: 'detailed', - method: 'PUT', - operationId: 'EnterpriseAppDeployConsole_UpdateEnvironmentAccessPolicy', - path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody, - params: zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse) - -export const getAppInstanceOverview = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceOverview', - path: '/enterprise/app-instances/{appInstanceId}/overview', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse) - -export const listReleases = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListReleases', - path: '/enterprise/app-instances/{appInstanceId}/releases', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - params: zEnterpriseAppDeployConsoleListReleasesPath, - query: zEnterpriseAppDeployConsoleListReleasesQuery.optional(), - }), - ) - .output(zEnterpriseAppDeployConsoleListReleasesResponse) - -export const createRelease = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateRelease', - path: '/enterprise/app-instances/{appInstanceId}/releases', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateReleaseBody, - params: zEnterpriseAppDeployConsoleCreateReleasePath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateReleaseResponse) - -export const previewRelease = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_PreviewRelease', - path: '/enterprise/app-instances/{appInstanceId}/releases:preview', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsolePreviewReleaseBody, - params: zEnterpriseAppDeployConsolePreviewReleasePath, - }), - ) - .output(zEnterpriseAppDeployConsolePreviewReleaseResponse) - -export const listRuntimeInstances = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListRuntimeInstances', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleListRuntimeInstancesPath })) - .output(zEnterpriseAppDeployConsoleListRuntimeInstancesResponse) - -export const cancelRuntimeDeployment = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CancelRuntimeDeployment', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}/deployment:cancel', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody, - params: zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse) - -export const undeployRuntimeInstance = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_UndeployRuntimeInstance', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}:undeploy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody, - params: zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath, - }), - ) - .output(zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse) - -export const getAppInstanceSettings = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceSettings', - path: '/enterprise/app-instances/{appInstanceId}/settings', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse) - -export const listDeploymentEnvironmentOptions = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListDeploymentEnvironmentOptions', - path: '/enterprise/deployment-environment-options', - tags: ['EnterpriseAppDeployConsole'], - }) - .output(zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse) - -export const enterpriseAppDeployConsole = { - listAppInstances, - createAppInstance, - deleteAppInstance, - updateAppInstance, - getAppInstanceAccess, - updateAccessChannels, - searchAccessSubjects, - createDeveloperApiKey, - deleteDeveloperApiKey, - listDeploymentBindingOptions, - createDeployment, - updateDeveloperApi, - getEnvironmentAccessPolicy, - updateEnvironmentAccessPolicy, - getAppInstanceOverview, - listReleases, - createRelease, - previewRelease, - listRuntimeInstances, - cancelRuntimeDeployment, - undeployRuntimeInstance, - getAppInstanceSettings, - listDeploymentEnvironmentOptions, -} - export const oAuth2Login = oc .route({ inputStructure: 'detailed', @@ -528,7 +133,6 @@ export const webAppAuth = { } export const contract = { - enterpriseAppDeployConsole, consoleSso, webAppAuth, } diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index 56228f2738..b747c4baa8 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -4,46 +4,6 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type AccessChannels = { - enabled?: boolean - webappRows?: Array<WebAppAccessRow> - cli?: CliAccess -} - -export type AccessModeOption = { - mode?: string - label?: string - disabled?: boolean - selected?: boolean -} - -export type AccessPolicyDetail = { - accessMode?: string - subjects?: Array<AccessSubjectDisplay> - options?: Array<AccessModeOption> -} - -export type AccessStatus = { - accessChannelsEnabled?: boolean - webappUrl?: string - cliUrl?: string - developerApiEnabled?: boolean - apiKeyCount?: number -} - -export type AccessSubject = { - subjectId?: string - subjectType?: string -} - -export type AccessSubjectDisplay = { - id?: string - subjectType?: string - name?: string - avatarUrl?: string - memberCount?: string -} - export type Account = { id?: string email?: string @@ -70,104 +30,9 @@ export type AccountInWorkspace = { role?: string } -export type AckDeploymentReply = { - accepted?: boolean - newVersion?: string -} - -export type AckDeploymentReq = { - deploymentId?: string - instanceId?: string - expectedVersion?: string - status?: string - observedReleaseId?: string - lastError?: LastError -} - -export type AppInstanceBasicInfo = { +export type AddGroupAppsRequest = { id?: string - name?: string - description?: string - sourceAppId?: string - sourceAppName?: string - mode?: string - createdAt?: string -} - -export type AppInstanceCard = { - id?: string - name?: string - icon?: string - mode?: string - sourceAppName?: string - statuses?: Array<StatusCount> - lastDeployedAt?: string -} - -export type AppRunnerBatchRuntimeArtifactReply = { - results?: Array<AppRunnerRuntimeArtifactResult> -} - -export type AppRunnerBatchRuntimeArtifactRequest = { - artifacts?: Array<AppRunnerRuntimeArtifactRequest> -} - -export type AppRunnerBootstrapAssignment = { - appId?: string - environmentId?: string - workflowId?: string - instanceId?: string - workspaceId?: string - instanceVersion?: string - bindingSnapshotVersion?: string - executionTokenVersion?: string - executionToken?: string - releaseId?: string -} - -export type AppRunnerBootstrapReply = { - runnerId?: string - assignmentRevision?: string - assignments?: Array<AppRunnerBootstrapAssignment> -} - -export type AppRunnerBootstrapRequest = { - runner?: AppRunnerRunnerInfo -} - -export type AppRunnerRunnerInfo = { - hostname?: string -} - -export type AppRunnerRuntimeArtifactReply = { - dslYaml?: string - bindingSnapshotVersion?: string - bindingSnapshot?: { - [key: string]: unknown - } -} - -export type AppRunnerRuntimeArtifactRequest = { - instanceId?: string - releaseId?: string - bindingSnapshotVersion?: string -} - -export type AppRunnerRuntimeArtifactResult = { - instanceId?: string - releaseId?: string - artifact?: AppRunnerRuntimeArtifactReply - errorCode?: string - errorMessage?: string -} - -export type AppRunnerTokenExchangeReply = { - accessToken?: string - expiresAt?: string -} - -export type AppRunnerTokenExchangeRequest = { - joinToken?: string + app_ids?: Array<string> } export type AuthSettingsReply = { @@ -193,15 +58,6 @@ export type AuthSettingsReq = { ssoSettings?: SsoSettings } -export type BootstrapProgress = { - currentStep?: string - completedSteps?: Array<string> - attemptCount?: number - lastAttemptAt?: string - lastErrorCode?: string - lastErrorMessage?: string -} - export type BrandingInfo = { enabled?: boolean applicationTitle?: string @@ -210,15 +66,6 @@ export type BrandingInfo = { favicon?: string } -export type CancelRuntimeDeploymentReply = { - status?: string -} - -export type CancelRuntimeDeploymentReq = { - appInstanceId?: string - runtimeInstanceId?: string -} - export type CheckPasswordStatusReply = { requirePasswordChange?: boolean changeReason?: number @@ -230,82 +77,10 @@ export type ClearDefaultWorkspaceReply = { [key: string]: unknown } -export type CliAccess = { - url?: string -} - -export type ConsoleEnvironment = { - id?: string - name?: string - runtime?: string - type?: string - status?: string -} - -export type ConsoleRelease = { - id?: string - name?: string - shortCommitId?: string - createdAt?: string -} - -export type ConsoleUser = { - id?: string - name?: string -} - -export type CreateAppInstanceReply = { - appInstanceId?: string - initialRelease?: ConsoleRelease -} - -export type CreateAppInstanceReq = { - sourceAppId?: string - name?: string - description?: string -} - export type CreateBearerTokenResponse = { token?: string } -export type CreateDeploymentReply = { - runtimeInstanceId?: string - deploymentId?: string - status?: string -} - -export type CreateDeploymentReq = { - appInstanceId?: string - environmentId?: string - releaseId?: string - bindings?: Array<DeploymentRuntimeBinding> -} - -export type CreateDeveloperApiKeyReply = { - apiKey?: DeveloperApiKeyRow - token?: string -} - -export type CreateDeveloperApiKeyReq = { - appInstanceId?: string - environmentId?: string - name?: string -} - -export type CreateEnvironmentReply = { - environment?: Environment -} - -export type CreateEnvironmentReq = { - name?: string - description?: string - mode?: number - backend?: number - k8s?: K8sEnvironmentConfig - host?: HostEnvironmentConfig -} - export type CreateMemberReply = { id?: string password?: string @@ -329,12 +104,7 @@ export type CreateNewGroupsRes = { groups?: Array<SubjectGroupData> } -export type CreateReleaseReply = { - release?: ConsoleRelease -} - -export type CreateReleaseReq = { - appInstanceId?: string +export type CreateResourceGroupRequest = { name?: string description?: string } @@ -394,27 +164,10 @@ export type DashboardSsosamlLoginReply = { url?: string } -export type DeleteAppInstanceReply = { - [key: string]: unknown -} - -export type DeleteDeveloperApiKeyReply = { - [key: string]: unknown -} - -export type DeleteEnvironmentReply = { - [key: string]: unknown -} - export type DeleteGroupsRes = { message?: string } -export type DeleteGuard = { - canDelete?: boolean - disabledReason?: string -} - export type DeleteMemberReply = { account?: Account } @@ -431,70 +184,6 @@ export type DeleteWorkspaceReply = { [key: string]: unknown } -export type DeployedEnvironment = { - environmentId?: string - environmentName?: string -} - -export type DeploymentBindingOptionSlot = { - slot?: string - kind?: string - label?: string - required?: boolean - candidates?: Array<DeploymentCredentialOption> - envVarCandidates?: Array<DeploymentEnvVarOption> -} - -export type DeploymentCredentialOption = { - credentialId?: string - displayName?: string - pluginId?: string - pluginName?: string - pluginVersion?: string -} - -export type DeploymentEnvVarOption = { - envVarId?: string - name?: string - valueType?: string - displayValue?: string -} - -export type DeploymentEnvironmentOption = { - id?: string - name?: string - type?: string - backend?: string - status?: string - managedBy?: string - deployable?: boolean - disabledReason?: string -} - -export type DeploymentRuntimeBinding = { - slot?: string - credentialId?: string - envVarId?: string -} - -export type DeploymentStatusRow = { - environment?: ConsoleEnvironment - release?: ConsoleRelease - status?: string -} - -export type DeveloperApiAccess = { - enabled?: boolean - apiKeys?: Array<DeveloperApiKeyRow> -} - -export type DeveloperApiKeyRow = { - id?: string - name?: string - environment?: ConsoleEnvironment - maskedKey?: string -} - export type EndpointReply = { mode?: number metricsEndpoint?: OtelExporterEndpoint @@ -507,55 +196,6 @@ export type EnterpriseSystemUserSettingReply = { enableEmailPasswordLogin?: boolean } -export type Environment = { - id?: string - name?: string - description?: string - mode?: number - namespace?: string - apiServer?: string - status?: number - statusMessage?: string - bootstrapProgress?: BootstrapProgress - managedBy?: string - createdAt?: string - updatedAt?: string - backend?: number - host?: string -} - -export type EnvironmentAccessRow = { - environment?: ConsoleEnvironment - currentRelease?: ConsoleRelease - accessMode?: string - accessModeLabel?: string - hint?: string -} - -export type EnvironmentFilter = { - id?: string - name?: string - kind?: string -} - -export type GetAppInstanceAccessReply = { - permissions?: Array<EnvironmentAccessRow> - accessChannels?: AccessChannels - developerApi?: DeveloperApiAccess -} - -export type GetAppInstanceOverviewReply = { - instance?: AppInstanceBasicInfo - deployments?: Array<DeploymentStatusRow> - access?: AccessStatus -} - -export type GetAppInstanceSettingsReply = { - name?: string - description?: string - deleteGuard?: DeleteGuard -} - export type GetBearerTokenResponse = { maskedToken?: string } @@ -571,14 +211,6 @@ export type GetDefaultWorkspaceReply = { workspace?: Workspace } -export type GetEnvironmentAccessPolicyReply = { - policy?: AccessPolicyDetail -} - -export type GetEnvironmentReply = { - environment?: Environment -} - export type GetGroupSubjectsRes = { subjects?: Array<Subject> } @@ -587,15 +219,6 @@ export type GetGroupsRes = { groups?: Array<SubjectGroupData> } -export type GetInstanceReply = { - instanceId?: string - status?: string - desiredReleaseId?: string - observedReleaseId?: string - currentDeploymentId?: string - version?: string -} - export type GetJoinedGroupsRes = { groups?: Array<SubjectGroupData> } @@ -652,16 +275,22 @@ export type GetWorkspaceReply = { workspace?: Workspace } +export type GroupAppItem = { + app_id?: string + app_name?: string + workspace_id?: string + workspace_name?: string + app_status?: number + token_usage?: string + rpm?: string + concurrency?: string +} + export type HealthzReply = { message?: string status?: string } -export type HostEnvironmentConfig = { - machineId?: string - joinTokenHash?: string -} - export type InfoConfigReply = { SSOEnforcedForSignin?: boolean SSOEnforcedForSigninProtocol?: string @@ -677,6 +306,11 @@ export type InfoConfigReply = { PluginInstallationPermission?: PluginInstallationPermissionInfo } +export type InnerAdmission = { + marker?: string + concurrencyGroupIds?: Array<string> +} + export type InnerBatchGetWebAppAccessModesByIdReq = { appIds?: Array<string> } @@ -698,42 +332,10 @@ export type InnerBatchIsUserAllowedToAccessWebAppRes = { } } -export type InnerCheckAppDeployAccessReply = { - allowed?: boolean - matchedPolicyId?: string - matchedScopeType?: string - reason?: string - cacheTtlSeconds?: number -} - -export type InnerCheckAppDeployAccessReq = { - appInstanceId?: string - environmentId?: string - principalType?: string - principalId?: string -} - export type InnerCleanAppRes = { message?: string } -export type InnerGetTokenRouteReply = { - environmentId?: string - namespace?: string - serviceName?: string - servicePort?: number - environmentStatus?: string - appId?: string - tenantId?: string - instanceId?: string - observedReleaseId?: string - instanceStatus?: string -} - -export type InnerGetTokenRouteReq = { - token?: string -} - export type InnerGetWebAppAccessModeByCodeRes = { accessMode?: string } @@ -742,10 +344,34 @@ export type InnerGetWebAppAccessModeByIdRes = { accessMode?: string } +export type InnerGroupConfig = { + id?: string + enabled?: boolean + membershipId?: string + limits?: Array<LimitConfig> +} + export type InnerIsUserAllowedToAccessWebAppRes = { result?: boolean } +export type InnerReleaseAdmissionRequest = { + admission?: InnerAdmission +} + +export type InnerReleaseAdmissionResponse = { + [key: string]: unknown +} + +export type InnerResolveResponse = { + appId?: string + groups?: Array<InnerGroupConfig> + blocked?: boolean + blockGroupId?: string + blockReason?: string + admission?: InnerAdmission +} + export type InnerTryAddAccountToDefaultWorkspaceReply = { workspaceId?: string joined?: boolean @@ -770,20 +396,6 @@ export type JoinWorkspaceReq = { role?: string } -export type K8sEnvironmentConfig = { - namespace?: string - apiServer?: string - caBundle?: string - bearerToken?: string -} - -export type LastError = { - phase?: string - code?: string - message?: string - releaseId?: string -} - export type LicenseInfo = { uuid?: string expiredAt?: string @@ -798,28 +410,21 @@ export type LicenseStatus = { workspaces?: ResourceQuota } +export type LimitConfig = { + type?: number + threshold?: string + action?: number + reached?: boolean +} + export type LimitFields = { workspaceMembers?: number workspaces?: ResourceQuota } -export type ListAppInstancesReply = { - filters?: Array<EnvironmentFilter> - data?: Array<AppInstanceCard> - pagination?: Pagination -} - -export type ListDeploymentBindingOptionsReply = { - slots?: Array<DeploymentBindingOptionSlot> -} - -export type ListDeploymentEnvironmentOptionsReply = { - environments?: Array<DeploymentEnvironmentOption> -} - -export type ListEnvironmentsReply = { - data?: Array<Environment> - pagination?: Pagination +export type ListGroupAppsResponse = { + items?: Array<GroupAppItem> + total?: string } export type ListMembersReply = { @@ -827,13 +432,9 @@ export type ListMembersReply = { pagination?: Pagination } -export type ListReleasesReply = { - data?: Array<ReleaseRow> - pagination?: Pagination -} - -export type ListRuntimeInstancesReply = { - data?: Array<RuntimeInstanceRow> +export type ListResourceGroupsResponse = { + items?: Array<ResourceGroupItem> + total?: string } export type ListSecretKeysReply = { @@ -981,31 +582,6 @@ export type PluginInstallationSettingsReply = { restrictToMarketplaceOnly?: boolean } -export type PreviewReleaseReply = { - release?: ConsoleRelease - bindings?: Array<ReleaseRuntimeBinding> -} - -export type PreviewReleaseReq = { - appInstanceId?: string - releaseId?: string -} - -export type ReleaseRow = { - id?: string - name?: string - createdAt?: string - createdBy?: ConsoleUser - deployedTo?: Array<DeployedEnvironment> -} - -export type ReleaseRuntimeBinding = { - kind?: string - label?: string - displayValue?: string - valueType?: string -} - export type ResetMemberPasswordReply = { id?: string password?: string @@ -1034,21 +610,35 @@ export type ResetUserPasswordReq = { id?: string } -export type ResolveCredentialsReply = { - resolved?: Array<ResolvedCredential> +export type ResourceGroupDetail = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + rpm_action?: number + concurrency_limit?: number + concurrency_action?: number + token_quota?: string + token_action?: number + created_at?: string + updated_at?: string } -export type ResolveCredentialsReq = { - instanceId?: string - deploymentId?: string - slots?: Array<string> -} - -export type ResolvedCredential = { - slot?: string - credentialId?: string - envVarId?: string - value?: string +export type ResourceGroupItem = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + concurrency_limit?: number + token_quota?: string + token_usage?: string + app_count?: string + rpm_status?: number + conc_status?: number + created_at?: string + updated_at?: string } export type ResourceQuota = { @@ -1057,36 +647,6 @@ export type ResourceQuota = { enabled?: boolean } -export type RetryEnvironmentReply = { - environment?: Environment -} - -export type RetryEnvironmentReq = { - id?: string -} - -export type RuntimeEndpoints = { - run?: string - health?: string -} - -export type RuntimeInstanceDetail = { - deploymentName?: string - replicas?: number - runtimeMode?: string - runtimeNote?: string - endpoints?: RuntimeEndpoints - bindings?: Array<ReleaseRuntimeBinding> -} - -export type RuntimeInstanceRow = { - id?: string - environment?: ConsoleEnvironment - status?: string - currentRelease?: ConsoleRelease - detail?: RuntimeInstanceDetail -} - export type SamlConfig = { idpSsoUrl?: string certificate?: string @@ -1119,8 +679,21 @@ export type ScimSettings = { lastSyncTime?: string } -export type SearchAccessSubjectsReply = { - data?: Array<AccessSubjectDisplay> +export type SearchAppItem = { + app_id?: string + app_name?: string + workspace_id?: string + workspace_name?: string + app_status?: number + icon?: string + icon_type?: string + icon_background?: string + created_by_name?: string +} + +export type SearchAppsResponse = { + items?: Array<SearchAppItem> + total?: string } export type SearchForWhilteListCandidatesRes = { @@ -1145,11 +718,6 @@ export type SetDefaultWorkspaceReq = { id?: string } -export type StatusCount = { - status?: string - count?: number -} - export type Subject = { subjectId?: string subjectType?: string @@ -1185,42 +753,10 @@ export type TestConnectionReply = { error?: string } -export type TestEnvironmentConnectionReply = { - ok?: boolean - reachableServerVersion?: string - namespaceExists?: boolean - missingPermissions?: Array<string> - error?: string - probedAt?: string -} - -export type TestEnvironmentConnectionReq = { - id?: string -} - export type ToggleEndpointRequest = { enabled?: boolean } -export type UndeployRuntimeInstanceReply = { - deploymentId?: string - status?: string -} - -export type UndeployRuntimeInstanceReq = { - appInstanceId?: string - runtimeInstanceId?: string -} - -export type UpdateAccessChannelsReply = { - accessChannels?: AccessChannels -} - -export type UpdateAccessChannelsReq = { - appInstanceId?: string - enabled?: boolean -} - export type UpdateAccessModeReq = { appId?: string accessMode?: string @@ -1230,16 +766,6 @@ export type UpdateAccessModeRes = { message?: string } -export type UpdateAppInstanceReply = { - appInstanceId?: string -} - -export type UpdateAppInstanceReq = { - appInstanceId?: string - name?: string - description?: string -} - export type UpdateBrandingInfoReq = { enabled?: boolean applicationTitle?: string @@ -1248,36 +774,6 @@ export type UpdateBrandingInfoReq = { favicon?: string } -export type UpdateDeveloperApiReply = { - developerApi?: DeveloperApiAccess -} - -export type UpdateDeveloperApiReq = { - appInstanceId?: string - enabled?: boolean -} - -export type UpdateEnvironmentAccessPolicyReply = { - permission?: EnvironmentAccessRow -} - -export type UpdateEnvironmentAccessPolicyReq = { - appInstanceId?: string - environmentId?: string - accessMode?: string - subjects?: Array<AccessSubject> -} - -export type UpdateEnvironmentReply = { - environment?: Environment -} - -export type UpdateEnvironmentReq = { - id?: string - name?: string - description?: string -} - export type UpdateGroupSubjectsReq = { groupId?: string subjects?: Array<Subject> @@ -1358,6 +854,19 @@ export type UpdatePluginInstallationSettingsRequest = { restrictToMarketplaceOnly?: boolean } +export type UpdateResourceGroupRequest = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + rpm_action?: number + concurrency_limit?: number + concurrency_action?: number + token_quota?: string + token_action?: number +} + export type UpdateUserReply = { account?: AccountDetail } @@ -1410,11 +919,6 @@ export type UpdateWorkspaceReq = { status?: string } -export type WebAppAccessRow = { - environment?: ConsoleEnvironment - url?: string -} - export type WebAppAuthInfo = { allowSso?: boolean allowEmailCodeLogin?: boolean @@ -1459,385 +963,6 @@ export type Pagination = { totalPages?: number } -export type EnterpriseAppDeployConsoleListAppInstancesData = { - body?: never - path?: never - query?: { - environmentId?: string - notDeployed?: boolean - query?: string - pageNumber?: number - resultsPerPage?: number - } - url: '/enterprise/app-instances' -} - -export type EnterpriseAppDeployConsoleListAppInstancesResponses = { - 200: ListAppInstancesReply -} - -export type EnterpriseAppDeployConsoleListAppInstancesResponse - = EnterpriseAppDeployConsoleListAppInstancesResponses[keyof EnterpriseAppDeployConsoleListAppInstancesResponses] - -export type EnterpriseAppDeployConsoleCreateAppInstanceData = { - body: CreateAppInstanceReq - path?: never - query?: never - url: '/enterprise/app-instances' -} - -export type EnterpriseAppDeployConsoleCreateAppInstanceResponses = { - 200: CreateAppInstanceReply -} - -export type EnterpriseAppDeployConsoleCreateAppInstanceResponse - = EnterpriseAppDeployConsoleCreateAppInstanceResponses[keyof EnterpriseAppDeployConsoleCreateAppInstanceResponses] - -export type EnterpriseAppDeployConsoleDeleteAppInstanceData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}' -} - -export type EnterpriseAppDeployConsoleDeleteAppInstanceResponses = { - 200: DeleteAppInstanceReply -} - -export type EnterpriseAppDeployConsoleDeleteAppInstanceResponse - = EnterpriseAppDeployConsoleDeleteAppInstanceResponses[keyof EnterpriseAppDeployConsoleDeleteAppInstanceResponses] - -export type EnterpriseAppDeployConsoleUpdateAppInstanceData = { - body: UpdateAppInstanceReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}' -} - -export type EnterpriseAppDeployConsoleUpdateAppInstanceResponses = { - 200: UpdateAppInstanceReply -} - -export type EnterpriseAppDeployConsoleUpdateAppInstanceResponse - = EnterpriseAppDeployConsoleUpdateAppInstanceResponses[keyof EnterpriseAppDeployConsoleUpdateAppInstanceResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/access' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessResponses = { - 200: GetAppInstanceAccessReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessResponse - = EnterpriseAppDeployConsoleGetAppInstanceAccessResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceAccessResponses] - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsData = { - body: UpdateAccessChannelsReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/access-channels' -} - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsResponses = { - 200: UpdateAccessChannelsReply -} - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsResponse - = EnterpriseAppDeployConsoleUpdateAccessChannelsResponses[keyof EnterpriseAppDeployConsoleUpdateAccessChannelsResponses] - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsData = { - body?: never - path: { - appInstanceId: string - } - query?: { - keyword?: string - subjectTypes?: Array<string> - } - url: '/enterprise/app-instances/{appInstanceId}/access-subjects:search' -} - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsResponses = { - 200: SearchAccessSubjectsReply -} - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsResponse - = EnterpriseAppDeployConsoleSearchAccessSubjectsResponses[keyof EnterpriseAppDeployConsoleSearchAccessSubjectsResponses] - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyData = { - body: CreateDeveloperApiKeyReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/api-keys' -} - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses = { - 200: CreateDeveloperApiKeyReply -} - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse - = EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses[keyof EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses] - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyData = { - body?: never - path: { - appInstanceId: string - apiKeyId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/api-keys/{apiKeyId}' -} - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses = { - 200: DeleteDeveloperApiKeyReply -} - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse - = EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses[keyof EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses] - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/deployment-binding-options' -} - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses = { - 200: ListDeploymentBindingOptionsReply -} - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse - = EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses[keyof EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses] - -export type EnterpriseAppDeployConsoleCreateDeploymentData = { - body: CreateDeploymentReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/deployments' -} - -export type EnterpriseAppDeployConsoleCreateDeploymentResponses = { - 200: CreateDeploymentReply -} - -export type EnterpriseAppDeployConsoleCreateDeploymentResponse - = EnterpriseAppDeployConsoleCreateDeploymentResponses[keyof EnterpriseAppDeployConsoleCreateDeploymentResponses] - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiData = { - body: UpdateDeveloperApiReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/developer-api' -} - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiResponses = { - 200: UpdateDeveloperApiReply -} - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiResponse - = EnterpriseAppDeployConsoleUpdateDeveloperApiResponses[keyof EnterpriseAppDeployConsoleUpdateDeveloperApiResponses] - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyData = { - body?: never - path: { - appInstanceId: string - environmentId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy' -} - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses = { - 200: GetEnvironmentAccessPolicyReply -} - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse - = EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses[keyof EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses] - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyData = { - body: UpdateEnvironmentAccessPolicyReq - path: { - appInstanceId: string - environmentId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy' -} - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses = { - 200: UpdateEnvironmentAccessPolicyReply -} - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse - = EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses[keyof EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/overview' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses = { - 200: GetAppInstanceOverviewReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewResponse - = EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses] - -export type EnterpriseAppDeployConsoleListReleasesData = { - body?: never - path: { - appInstanceId: string - } - query?: { - pageNumber?: number - resultsPerPage?: number - } - url: '/enterprise/app-instances/{appInstanceId}/releases' -} - -export type EnterpriseAppDeployConsoleListReleasesResponses = { - 200: ListReleasesReply -} - -export type EnterpriseAppDeployConsoleListReleasesResponse - = EnterpriseAppDeployConsoleListReleasesResponses[keyof EnterpriseAppDeployConsoleListReleasesResponses] - -export type EnterpriseAppDeployConsoleCreateReleaseData = { - body: CreateReleaseReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/releases' -} - -export type EnterpriseAppDeployConsoleCreateReleaseResponses = { - 200: CreateReleaseReply -} - -export type EnterpriseAppDeployConsoleCreateReleaseResponse - = EnterpriseAppDeployConsoleCreateReleaseResponses[keyof EnterpriseAppDeployConsoleCreateReleaseResponses] - -export type EnterpriseAppDeployConsolePreviewReleaseData = { - body: PreviewReleaseReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/releases:preview' -} - -export type EnterpriseAppDeployConsolePreviewReleaseResponses = { - 200: PreviewReleaseReply -} - -export type EnterpriseAppDeployConsolePreviewReleaseResponse - = EnterpriseAppDeployConsolePreviewReleaseResponses[keyof EnterpriseAppDeployConsolePreviewReleaseResponses] - -export type EnterpriseAppDeployConsoleListRuntimeInstancesData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances' -} - -export type EnterpriseAppDeployConsoleListRuntimeInstancesResponses = { - 200: ListRuntimeInstancesReply -} - -export type EnterpriseAppDeployConsoleListRuntimeInstancesResponse - = EnterpriseAppDeployConsoleListRuntimeInstancesResponses[keyof EnterpriseAppDeployConsoleListRuntimeInstancesResponses] - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentData = { - body: CancelRuntimeDeploymentReq - path: { - appInstanceId: string - runtimeInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}/deployment:cancel' -} - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses = { - 200: CancelRuntimeDeploymentReply -} - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse - = EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses[keyof EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses] - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceData = { - body: UndeployRuntimeInstanceReq - path: { - appInstanceId: string - runtimeInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}:undeploy' -} - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses = { - 200: UndeployRuntimeInstanceReply -} - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse - = EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses[keyof EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/settings' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses = { - 200: GetAppInstanceSettingsReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsResponse - = EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses] - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsData = { - body?: never - path?: never - query?: never - url: '/enterprise/deployment-environment-options' -} - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses = { - 200: ListDeploymentEnvironmentOptionsReply -} - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse - = EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses[keyof EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses] - export type ConsoleSsoOAuth2LoginData = { body?: never path?: never diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index 1e7e3d44ae..cef500a906 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -2,44 +2,6 @@ import * as z from 'zod' -export const zAccessModeOption = z.object({ - mode: z.string().optional(), - label: z.string().optional(), - disabled: z.boolean().optional(), - selected: z.boolean().optional(), -}) - -export const zAccessStatus = z.object({ - accessChannelsEnabled: z.boolean().optional(), - webappUrl: z.string().optional(), - cliUrl: z.string().optional(), - developerApiEnabled: z.boolean().optional(), - apiKeyCount: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zAccessSubject = z.object({ - subjectId: z.string().optional(), - subjectType: z.string().optional(), -}) - -export const zAccessSubjectDisplay = z.object({ - id: z.string().optional(), - subjectType: z.string().optional(), - name: z.string().optional(), - avatarUrl: z.string().optional(), - memberCount: z.string().optional(), -}) - -export const zAccessPolicyDetail = z.object({ - accessMode: z.string().optional(), - subjects: z.array(zAccessSubjectDisplay).optional(), - options: z.array(zAccessModeOption).optional(), -}) - /** * Account represents a basic user account */ @@ -75,101 +37,9 @@ export const zAccountDetail = z.object({ groups: z.array(zAccountDetailGroup).optional(), }) -export const zAckDeploymentReply = z.object({ - accepted: z.boolean().optional(), - newVersion: z.string().optional(), -}) - -export const zAppInstanceBasicInfo = z.object({ +export const zAddGroupAppsRequest = z.object({ id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - sourceAppId: z.string().optional(), - sourceAppName: z.string().optional(), - mode: z.string().optional(), - createdAt: z.iso.datetime().optional(), -}) - -export const zAppRunnerBootstrapAssignment = z.object({ - appId: z.string().optional(), - environmentId: z.string().optional(), - workflowId: z.string().optional(), - instanceId: z.string().optional(), - workspaceId: z.string().optional(), - instanceVersion: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), - executionTokenVersion: z.string().optional(), - executionToken: z.string().optional(), - releaseId: z.string().optional(), -}) - -export const zAppRunnerBootstrapReply = z.object({ - runnerId: z.string().optional(), - assignmentRevision: z.string().optional(), - assignments: z.array(zAppRunnerBootstrapAssignment).optional(), -}) - -export const zAppRunnerRunnerInfo = z.object({ - hostname: z.string().optional(), -}) - -export const zAppRunnerBootstrapRequest = z.object({ - runner: zAppRunnerRunnerInfo.optional(), -}) - -export const zAppRunnerRuntimeArtifactReply = z.object({ - dslYaml: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), - bindingSnapshot: z.record(z.string(), z.unknown()).optional(), -}) - -export const zAppRunnerRuntimeArtifactRequest = z.object({ - instanceId: z.string().optional(), - releaseId: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), -}) - -export const zAppRunnerBatchRuntimeArtifactRequest = z.object({ - artifacts: z.array(zAppRunnerRuntimeArtifactRequest).optional(), -}) - -export const zAppRunnerRuntimeArtifactResult = z.object({ - instanceId: z.string().optional(), - releaseId: z.string().optional(), - artifact: zAppRunnerRuntimeArtifactReply.optional(), - errorCode: z.string().optional(), - errorMessage: z.string().optional(), -}) - -export const zAppRunnerBatchRuntimeArtifactReply = z.object({ - results: z.array(zAppRunnerRuntimeArtifactResult).optional(), -}) - -export const zAppRunnerTokenExchangeReply = z.object({ - accessToken: z.string().optional(), - expiresAt: z.iso.datetime().optional(), -}) - -export const zAppRunnerTokenExchangeRequest = z.object({ - joinToken: z.string().optional(), -}) - -/** - * BootstrapProgress is step-list-agnostic. Reconcilers emit step names as - * strings owned by each executor (e.g. "connectivity", "namespace"), so adding - * or removing steps does not break the API. - */ -export const zBootstrapProgress = z.object({ - currentStep: z.string().optional(), - completedSteps: z.array(z.string()).optional(), - attemptCount: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - lastAttemptAt: z.iso.datetime().optional(), - lastErrorCode: z.string().optional(), - lastErrorMessage: z.string().optional(), + app_ids: z.array(z.string()).optional(), }) export const zBrandingInfo = z.object({ @@ -180,15 +50,6 @@ export const zBrandingInfo = z.object({ favicon: z.string().optional(), }) -export const zCancelRuntimeDeploymentReply = z.object({ - status: z.string().optional(), -}) - -export const zCancelRuntimeDeploymentReq = z.object({ - appInstanceId: z.string().optional(), - runtimeInstanceId: z.string().optional(), -}) - export const zCheckPasswordStatusReply = z.object({ requirePasswordChange: z.boolean().optional(), changeReason: z.int().optional(), @@ -202,57 +63,10 @@ export const zCheckPasswordStatusReply = z.object({ export const zClearDefaultWorkspaceReply = z.record(z.string(), z.unknown()) -export const zCliAccess = z.object({ - url: z.string().optional(), -}) - -export const zConsoleEnvironment = z.object({ - id: z.string().optional(), - name: z.string().optional(), - runtime: z.string().optional(), - type: z.string().optional(), - status: z.string().optional(), -}) - -export const zConsoleRelease = z.object({ - id: z.string().optional(), - name: z.string().optional(), - shortCommitId: z.string().optional(), - createdAt: z.iso.datetime().optional(), -}) - -export const zConsoleUser = z.object({ - id: z.string().optional(), - name: z.string().optional(), -}) - -export const zCreateAppInstanceReply = z.object({ - appInstanceId: z.string().optional(), - initialRelease: zConsoleRelease.optional(), -}) - -export const zCreateAppInstanceReq = z.object({ - sourceAppId: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zCreateBearerTokenResponse = z.object({ token: z.string().optional(), }) -export const zCreateDeploymentReply = z.object({ - runtimeInstanceId: z.string().optional(), - deploymentId: z.string().optional(), - status: z.string().optional(), -}) - -export const zCreateDeveloperApiKeyReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - name: z.string().optional(), -}) - export const zCreateMemberReply = z.object({ id: z.string().optional(), password: z.string().optional(), @@ -275,12 +89,7 @@ export const zCreateNewGroupsReq = z.object({ groups: z.array(zCreateNewGroupsReqGroup).optional(), }) -export const zCreateReleaseReply = z.object({ - release: zConsoleRelease.optional(), -}) - -export const zCreateReleaseReq = z.object({ - appInstanceId: z.string().optional(), +export const zCreateResourceGroupRequest = z.object({ name: z.string().optional(), description: z.string().optional(), }) @@ -342,21 +151,10 @@ export const zDashboardSsosamlLoginReply = z.object({ url: z.string().optional(), }) -export const zDeleteAppInstanceReply = z.record(z.string(), z.unknown()) - -export const zDeleteDeveloperApiKeyReply = z.record(z.string(), z.unknown()) - -export const zDeleteEnvironmentReply = z.record(z.string(), z.unknown()) - export const zDeleteGroupsRes = z.object({ message: z.string().optional(), }) -export const zDeleteGuard = z.object({ - canDelete: z.boolean().optional(), - disabledReason: z.string().optional(), -}) - export const zDeleteMemberReply = z.object({ account: zAccount.optional(), }) @@ -371,82 +169,6 @@ export const zDeleteUserReply = z.object({ export const zDeleteWorkspaceReply = z.record(z.string(), z.unknown()) -export const zDeployedEnvironment = z.object({ - environmentId: z.string().optional(), - environmentName: z.string().optional(), -}) - -export const zDeploymentCredentialOption = z.object({ - credentialId: z.string().optional(), - displayName: z.string().optional(), - pluginId: z.string().optional(), - pluginName: z.string().optional(), - pluginVersion: z.string().optional(), -}) - -export const zDeploymentEnvVarOption = z.object({ - envVarId: z.string().optional(), - name: z.string().optional(), - valueType: z.string().optional(), - displayValue: z.string().optional(), -}) - -export const zDeploymentBindingOptionSlot = z.object({ - slot: z.string().optional(), - kind: z.string().optional(), - label: z.string().optional(), - required: z.boolean().optional(), - candidates: z.array(zDeploymentCredentialOption).optional(), - envVarCandidates: z.array(zDeploymentEnvVarOption).optional(), -}) - -export const zDeploymentEnvironmentOption = z.object({ - id: z.string().optional(), - name: z.string().optional(), - type: z.string().optional(), - backend: z.string().optional(), - status: z.string().optional(), - managedBy: z.string().optional(), - deployable: z.boolean().optional(), - disabledReason: z.string().optional(), -}) - -export const zDeploymentRuntimeBinding = z.object({ - slot: z.string().optional(), - credentialId: z.string().optional(), - envVarId: z.string().optional(), -}) - -export const zCreateDeploymentReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - releaseId: z.string().optional(), - bindings: z.array(zDeploymentRuntimeBinding).optional(), -}) - -export const zDeploymentStatusRow = z.object({ - environment: zConsoleEnvironment.optional(), - release: zConsoleRelease.optional(), - status: z.string().optional(), -}) - -export const zDeveloperApiKeyRow = z.object({ - id: z.string().optional(), - name: z.string().optional(), - environment: zConsoleEnvironment.optional(), - maskedKey: z.string().optional(), -}) - -export const zCreateDeveloperApiKeyReply = z.object({ - apiKey: zDeveloperApiKeyRow.optional(), - token: z.string().optional(), -}) - -export const zDeveloperApiAccess = z.object({ - enabled: z.boolean().optional(), - apiKeys: z.array(zDeveloperApiKeyRow).optional(), -}) - /** * System user setting messages */ @@ -456,53 +178,6 @@ export const zEnterpriseSystemUserSettingReply = z.object({ enableEmailPasswordLogin: z.boolean().optional(), }) -export const zEnvironment = z.object({ - id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - mode: z.int().optional(), - namespace: z.string().optional(), - apiServer: z.string().optional(), - status: z.int().optional(), - statusMessage: z.string().optional(), - bootstrapProgress: zBootstrapProgress.optional(), - managedBy: z.string().optional(), - createdAt: z.iso.datetime().optional(), - updatedAt: z.iso.datetime().optional(), - backend: z.int().optional(), - host: z.string().optional(), -}) - -export const zCreateEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zEnvironmentAccessRow = z.object({ - environment: zConsoleEnvironment.optional(), - currentRelease: zConsoleRelease.optional(), - accessMode: z.string().optional(), - accessModeLabel: z.string().optional(), - hint: z.string().optional(), -}) - -export const zEnvironmentFilter = z.object({ - id: z.string().optional(), - name: z.string().optional(), - kind: z.string().optional(), -}) - -export const zGetAppInstanceOverviewReply = z.object({ - instance: zAppInstanceBasicInfo.optional(), - deployments: z.array(zDeploymentStatusRow).optional(), - access: zAccessStatus.optional(), -}) - -export const zGetAppInstanceSettingsReply = z.object({ - name: z.string().optional(), - description: z.string().optional(), - deleteGuard: zDeleteGuard.optional(), -}) - export const zGetBearerTokenResponse = z.object({ maskedToken: z.string().optional(), }) @@ -513,23 +188,6 @@ export const zGetClusterInfoReply = z.object({ verifyMode: z.string().optional(), }) -export const zGetEnvironmentAccessPolicyReply = z.object({ - policy: zAccessPolicyDetail.optional(), -}) - -export const zGetEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zGetInstanceReply = z.object({ - instanceId: z.string().optional(), - status: z.string().optional(), - desiredReleaseId: z.string().optional(), - observedReleaseId: z.string().optional(), - currentDeploymentId: z.string().optional(), - version: z.string().optional(), -}) - export const zGetLicenseStatusReply = z.object({ status: z.string().optional(), }) @@ -565,14 +223,25 @@ export const zGetWebAppWhitelistSubjectsResMember = z.object({ avatar: z.string().optional(), }) +export const zGroupAppItem = z.object({ + app_id: z.string().optional(), + app_name: z.string().optional(), + workspace_id: z.string().optional(), + workspace_name: z.string().optional(), + app_status: z.int().optional(), + token_usage: z.string().optional(), + rpm: z.string().optional(), + concurrency: z.string().optional(), +}) + export const zHealthzReply = z.object({ message: z.string().optional(), status: z.string().optional(), }) -export const zHostEnvironmentConfig = z.object({ - machineId: z.string().optional(), - joinTokenHash: z.string().optional(), +export const zInnerAdmission = z.object({ + marker: z.string().optional(), + concurrencyGroupIds: z.array(z.string()).optional(), }) export const zInnerBatchGetWebAppAccessModesByIdReq = z.object({ @@ -592,50 +261,10 @@ export const zInnerBatchIsUserAllowedToAccessWebAppRes = z.object({ permissions: z.record(z.string(), z.boolean()).optional(), }) -export const zInnerCheckAppDeployAccessReply = z.object({ - allowed: z.boolean().optional(), - matchedPolicyId: z.string().optional(), - matchedScopeType: z.string().optional(), - reason: z.string().optional(), - cacheTtlSeconds: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zInnerCheckAppDeployAccessReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - principalType: z.string().optional(), - principalId: z.string().optional(), -}) - export const zInnerCleanAppRes = z.object({ message: z.string().optional(), }) -export const zInnerGetTokenRouteReply = z.object({ - environmentId: z.string().optional(), - namespace: z.string().optional(), - serviceName: z.string().optional(), - servicePort: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - environmentStatus: z.string().optional(), - appId: z.string().optional(), - tenantId: z.string().optional(), - instanceId: z.string().optional(), - observedReleaseId: z.string().optional(), - instanceStatus: z.string().optional(), -}) - -export const zInnerGetTokenRouteReq = z.object({ - token: z.string().optional(), -}) - export const zInnerGetWebAppAccessModeByCodeRes = z.object({ accessMode: z.string().optional(), }) @@ -648,6 +277,12 @@ export const zInnerIsUserAllowedToAccessWebAppRes = z.object({ result: z.boolean().optional(), }) +export const zInnerReleaseAdmissionRequest = z.object({ + admission: zInnerAdmission.optional(), +}) + +export const zInnerReleaseAdmissionResponse = z.record(z.string(), z.unknown()) + export const zInnerTryAddAccountToDefaultWorkspaceReply = z.object({ workspaceId: z.string().optional(), joined: z.boolean().optional(), @@ -678,48 +313,32 @@ export const zJoinWorkspaceReq = z.object({ role: z.string().optional(), }) -export const zK8sEnvironmentConfig = z.object({ - namespace: z.string().optional(), - apiServer: z.string().optional(), - caBundle: z.string().optional(), - bearerToken: z.string().optional(), +export const zLimitConfig = z.object({ + type: z.int().optional(), + threshold: z.string().optional(), + action: z.int().optional(), + reached: z.boolean().optional(), }) -/** - * Field-level validation only; target (api_server) and RBAC validation happen - * in the bootstrap reconciler. - */ -export const zCreateEnvironmentReq = z.object({ - name: z.string().optional(), - description: z.string().optional(), - mode: z.int().optional(), - backend: z.int().optional(), - k8s: zK8sEnvironmentConfig.optional(), - host: zHostEnvironmentConfig.optional(), +export const zInnerGroupConfig = z.object({ + id: z.string().optional(), + enabled: z.boolean().optional(), + membershipId: z.string().optional(), + limits: z.array(zLimitConfig).optional(), }) -export const zLastError = z.object({ - phase: z.string().optional(), - code: z.string().optional(), - message: z.string().optional(), - releaseId: z.string().optional(), +export const zInnerResolveResponse = z.object({ + appId: z.string().optional(), + groups: z.array(zInnerGroupConfig).optional(), + blocked: z.boolean().optional(), + blockGroupId: z.string().optional(), + blockReason: z.string().optional(), + admission: zInnerAdmission.optional(), }) -export const zAckDeploymentReq = z.object({ - deploymentId: z.string().optional(), - instanceId: z.string().optional(), - expectedVersion: z.string().optional(), - status: z.string().optional(), - observedReleaseId: z.string().optional(), - lastError: zLastError.optional(), -}) - -export const zListDeploymentBindingOptionsReply = z.object({ - slots: z.array(zDeploymentBindingOptionSlot).optional(), -}) - -export const zListDeploymentEnvironmentOptionsReply = z.object({ - environments: z.array(zDeploymentEnvironmentOption).optional(), +export const zListGroupAppsResponse = z.object({ + items: z.array(zGroupAppItem).optional(), + total: z.string().optional(), }) export const zLoginTypesReply = z.object({ @@ -871,31 +490,6 @@ export const zPluginInstallationSettingsReply = z.object({ restrictToMarketplaceOnly: z.boolean().optional(), }) -export const zPreviewReleaseReq = z.object({ - appInstanceId: z.string().optional(), - releaseId: z.string().optional(), -}) - -export const zReleaseRow = z.object({ - id: z.string().optional(), - name: z.string().optional(), - createdAt: z.iso.datetime().optional(), - createdBy: zConsoleUser.optional(), - deployedTo: z.array(zDeployedEnvironment).optional(), -}) - -export const zReleaseRuntimeBinding = z.object({ - kind: z.string().optional(), - label: z.string().optional(), - displayValue: z.string().optional(), - valueType: z.string().optional(), -}) - -export const zPreviewReleaseReply = z.object({ - release: zConsoleRelease.optional(), - bindings: z.array(zReleaseRuntimeBinding).optional(), -}) - export const zResetMemberPasswordReply = z.object({ id: z.string().optional(), password: z.string().optional(), @@ -930,26 +524,56 @@ export const zResetUserPasswordReq = z.object({ id: z.string().optional(), }) -export const zResolveCredentialsReq = z.object({ - instanceId: z.string().optional(), - deploymentId: z.string().optional(), - slots: z.array(z.string()).optional(), +export const zResourceGroupDetail = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + rpm_action: z.int().optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_action: z.int().optional(), + token_quota: z.string().optional(), + token_action: z.int().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), }) -/** - * Exactly one of credential_id / env_var_id is populated; model/plugin slots - * carry credential_id (pool A), env_var slots carry env_var_id (pool B). - * See design §4.1. - */ -export const zResolvedCredential = z.object({ - slot: z.string().optional(), - credentialId: z.string().optional(), - envVarId: z.string().optional(), - value: z.string().optional(), +export const zResourceGroupItem = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + token_quota: z.string().optional(), + token_usage: z.string().optional(), + app_count: z.string().optional(), + rpm_status: z.int().optional(), + conc_status: z.int().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), }) -export const zResolveCredentialsReply = z.object({ - resolved: z.array(zResolvedCredential).optional(), +export const zListResourceGroupsResponse = z.object({ + items: z.array(zResourceGroupItem).optional(), + total: z.string().optional(), }) /** @@ -1002,44 +626,6 @@ export const zGetLicenseReply = z.object({ license: zLicenseInfo.optional(), }) -export const zRetryEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zRetryEnvironmentReq = z.object({ - id: z.string().optional(), -}) - -export const zRuntimeEndpoints = z.object({ - run: z.string().optional(), - health: z.string().optional(), -}) - -export const zRuntimeInstanceDetail = z.object({ - deploymentName: z.string().optional(), - replicas: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - runtimeMode: z.string().optional(), - runtimeNote: z.string().optional(), - endpoints: zRuntimeEndpoints.optional(), - bindings: z.array(zReleaseRuntimeBinding).optional(), -}) - -export const zRuntimeInstanceRow = z.object({ - id: z.string().optional(), - environment: zConsoleEnvironment.optional(), - status: z.string().optional(), - currentRelease: zConsoleRelease.optional(), - detail: zRuntimeInstanceDetail.optional(), -}) - -export const zListRuntimeInstancesReply = z.object({ - data: z.array(zRuntimeInstanceRow).optional(), -}) - /** * SSO Configuration messages */ @@ -1102,8 +688,21 @@ export const zScimSettings = z.object({ lastSyncTime: z.iso.datetime().optional(), }) -export const zSearchAccessSubjectsReply = z.object({ - data: z.array(zAccessSubjectDisplay).optional(), +export const zSearchAppItem = z.object({ + app_id: z.string().optional(), + app_name: z.string().optional(), + workspace_id: z.string().optional(), + workspace_name: z.string().optional(), + app_status: z.int().optional(), + icon: z.string().optional(), + icon_type: z.string().optional(), + icon_background: z.string().optional(), + created_by_name: z.string().optional(), +}) + +export const zSearchAppsResponse = z.object({ + items: z.array(zSearchAppItem).optional(), + total: z.string().optional(), }) export const zSecretKey = z.object({ @@ -1122,25 +721,6 @@ export const zSetDefaultWorkspaceReq = z.object({ id: z.string().optional(), }) -export const zStatusCount = z.object({ - status: z.string().optional(), - count: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zAppInstanceCard = z.object({ - id: z.string().optional(), - name: z.string().optional(), - icon: z.string().optional(), - mode: z.string().optional(), - sourceAppName: z.string().optional(), - statuses: z.array(zStatusCount).optional(), - lastDeployedAt: z.iso.datetime().optional(), -}) - export const zSubjectAccountData = z.object({ id: z.string().optional(), name: z.string().optional(), @@ -1214,38 +794,10 @@ export const zTestConnectionReply = z.object({ error: z.string().optional(), }) -export const zTestEnvironmentConnectionReply = z.object({ - ok: z.boolean().optional(), - reachableServerVersion: z.string().optional(), - namespaceExists: z.boolean().optional(), - missingPermissions: z.array(z.string()).optional(), - error: z.string().optional(), - probedAt: z.iso.datetime().optional(), -}) - -export const zTestEnvironmentConnectionReq = z.object({ - id: z.string().optional(), -}) - export const zToggleEndpointRequest = z.object({ enabled: z.boolean().optional(), }) -export const zUndeployRuntimeInstanceReply = z.object({ - deploymentId: z.string().optional(), - status: z.string().optional(), -}) - -export const zUndeployRuntimeInstanceReq = z.object({ - appInstanceId: z.string().optional(), - runtimeInstanceId: z.string().optional(), -}) - -export const zUpdateAccessChannelsReq = z.object({ - appInstanceId: z.string().optional(), - enabled: z.boolean().optional(), -}) - export const zUpdateAccessModeReq = z.object({ appId: z.string().optional(), accessMode: z.string().optional(), @@ -1255,16 +807,6 @@ export const zUpdateAccessModeRes = z.object({ message: z.string().optional(), }) -export const zUpdateAppInstanceReply = z.object({ - appInstanceId: z.string().optional(), -}) - -export const zUpdateAppInstanceReq = z.object({ - appInstanceId: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zUpdateBrandingInfoReq = z.object({ enabled: z.boolean().optional(), applicationTitle: z.string().optional(), @@ -1273,36 +815,6 @@ export const zUpdateBrandingInfoReq = z.object({ favicon: z.string().optional(), }) -export const zUpdateDeveloperApiReply = z.object({ - developerApi: zDeveloperApiAccess.optional(), -}) - -export const zUpdateDeveloperApiReq = z.object({ - appInstanceId: z.string().optional(), - enabled: z.boolean().optional(), -}) - -export const zUpdateEnvironmentAccessPolicyReply = z.object({ - permission: zEnvironmentAccessRow.optional(), -}) - -export const zUpdateEnvironmentAccessPolicyReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - accessMode: z.string().optional(), - subjects: z.array(zAccessSubject).optional(), -}) - -export const zUpdateEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zUpdateEnvironmentReq = z.object({ - id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zUpdateGroupSubjectsReq = z.object({ groupId: z.string().optional(), subjects: z.array(zSubject).optional(), @@ -1386,6 +898,27 @@ export const zUpdatePluginInstallationSettingsRequest = z.object({ restrictToMarketplaceOnly: z.boolean().optional(), }) +export const zUpdateResourceGroupRequest = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + rpm_action: z.int().optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_action: z.int().optional(), + token_quota: z.string().optional(), + token_action: z.int().optional(), +}) + export const zUpdateUserReply = z.object({ account: zAccountDetail.optional(), }) @@ -1430,27 +963,6 @@ export const zUpdateWorkspaceReq = z.object({ status: z.string().optional(), }) -export const zWebAppAccessRow = z.object({ - environment: zConsoleEnvironment.optional(), - url: z.string().optional(), -}) - -export const zAccessChannels = z.object({ - enabled: z.boolean().optional(), - webappRows: z.array(zWebAppAccessRow).optional(), - cli: zCliAccess.optional(), -}) - -export const zGetAppInstanceAccessReply = z.object({ - permissions: z.array(zEnvironmentAccessRow).optional(), - accessChannels: zAccessChannels.optional(), - developerApi: zDeveloperApiAccess.optional(), -}) - -export const zUpdateAccessChannelsReply = z.object({ - accessChannels: zAccessChannels.optional(), -}) - export const zWebAppAuthInfo = z.object({ allowSso: z.boolean().optional(), allowEmailCodeLogin: z.boolean().optional(), @@ -1572,27 +1084,11 @@ export const zPagination = z.object({ .optional(), }) -export const zListAppInstancesReply = z.object({ - filters: z.array(zEnvironmentFilter).optional(), - data: z.array(zAppInstanceCard).optional(), - pagination: zPagination.optional(), -}) - -export const zListEnvironmentsReply = z.object({ - data: z.array(zEnvironment).optional(), - pagination: zPagination.optional(), -}) - export const zListMembersReply = z.object({ data: z.array(zAccountDetail).optional(), pagination: zPagination.optional(), }) -export const zListReleasesReply = z.object({ - data: z.array(zReleaseRow).optional(), - pagination: zPagination.optional(), -}) - export const zListSecretKeysReply = z.object({ data: z.array(zSecretKey).optional(), pagination: zPagination.optional(), @@ -1608,271 +1104,6 @@ export const zListWorkspacesReply = z.object({ pagination: zPagination.optional(), }) -export const zEnterpriseAppDeployConsoleListAppInstancesQuery = z.object({ - environmentId: z.string().optional(), - notDeployed: z.boolean().optional(), - query: z.string().optional(), - pageNumber: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - resultsPerPage: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListAppInstancesResponse = zListAppInstancesReply - -export const zEnterpriseAppDeployConsoleCreateAppInstanceBody = zCreateAppInstanceReq - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateAppInstanceResponse = zCreateAppInstanceReply - -export const zEnterpriseAppDeployConsoleDeleteAppInstancePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleDeleteAppInstanceResponse = zDeleteAppInstanceReply - -export const zEnterpriseAppDeployConsoleUpdateAppInstanceBody = zUpdateAppInstanceReq - -export const zEnterpriseAppDeployConsoleUpdateAppInstancePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateAppInstanceResponse = zUpdateAppInstanceReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceAccessPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse = zGetAppInstanceAccessReply - -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsBody = zUpdateAccessChannelsReq - -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse = zUpdateAccessChannelsReply - -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsPath = z.object({ - appInstanceId: z.string(), -}) - -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery = z.object({ - keyword: z.string().optional(), - subjectTypes: z.array(z.string()).optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse = zSearchAccessSubjectsReply - -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody = zCreateDeveloperApiKeyReq - -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse = zCreateDeveloperApiKeyReply - -export const zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath = z.object({ - appInstanceId: z.string(), - apiKeyId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse = zDeleteDeveloperApiKeyReply - -export const zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse - = zListDeploymentBindingOptionsReply - -export const zEnterpriseAppDeployConsoleCreateDeploymentBody = zCreateDeploymentReq - -export const zEnterpriseAppDeployConsoleCreateDeploymentPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateDeploymentResponse = zCreateDeploymentReply - -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiBody = zUpdateDeveloperApiReq - -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse = zUpdateDeveloperApiReply - -export const zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath = z.object({ - appInstanceId: z.string(), - environmentId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse - = zGetEnvironmentAccessPolicyReply - -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody - = zUpdateEnvironmentAccessPolicyReq - -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath = z.object({ - appInstanceId: z.string(), - environmentId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse - = zUpdateEnvironmentAccessPolicyReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse - = zGetAppInstanceOverviewReply - -export const zEnterpriseAppDeployConsoleListReleasesPath = z.object({ - appInstanceId: z.string(), -}) - -export const zEnterpriseAppDeployConsoleListReleasesQuery = z.object({ - pageNumber: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - resultsPerPage: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListReleasesResponse = zListReleasesReply - -export const zEnterpriseAppDeployConsoleCreateReleaseBody = zCreateReleaseReq - -export const zEnterpriseAppDeployConsoleCreateReleasePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateReleaseResponse = zCreateReleaseReply - -export const zEnterpriseAppDeployConsolePreviewReleaseBody = zPreviewReleaseReq - -export const zEnterpriseAppDeployConsolePreviewReleasePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsolePreviewReleaseResponse = zPreviewReleaseReply - -export const zEnterpriseAppDeployConsoleListRuntimeInstancesPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListRuntimeInstancesResponse = zListRuntimeInstancesReply - -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody = zCancelRuntimeDeploymentReq - -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath = z.object({ - appInstanceId: z.string(), - runtimeInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse - = zCancelRuntimeDeploymentReply - -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody = zUndeployRuntimeInstanceReq - -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath = z.object({ - appInstanceId: z.string(), - runtimeInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse - = zUndeployRuntimeInstanceReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse - = zGetAppInstanceSettingsReply - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse - = zListDeploymentEnvironmentOptionsReply - /** * OK */ diff --git a/web/AGENTS.md b/web/AGENTS.md index 2f7e0f6cda..a2ca3857e7 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -9,10 +9,6 @@ - In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`. - Do not introduce overlay imports from `@/app/components/base/*`; when touching existing callers, migrate them. -## Query & Mutation (Mandatory) - -- `frontend-query-mutation` is the source of truth for Dify frontend contracts, query and mutation call-site patterns, conditional queries, invalidation, and mutation error handling. - ## Design Token Mapping - When translating Figma designs to code, read `../packages/dify-ui/AGENTS.md` for the Figma `--radius/*` token to Tailwind `rounded-*` class mapping. The two scales are offset by one step. diff --git a/web/contract/router.ts b/web/contract/router.ts index 953678a910..37438d028d 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -63,6 +63,9 @@ export const marketplaceRouterContract = { export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract> export const consoleRouterContract = { + // `enterprise` is the only backend-generated contract wired in here. Community API contracts + // are generated too, but backend definitions are not complete enough to consume directly yet, + // so those routes stay manually maintained for now. enterprise: enterpriseContract, account: { avatar: accountAvatarContract, diff --git a/web/docs/test.md b/web/docs/test.md index b7c6a5f5a3..402a24d30f 100644 --- a/web/docs/test.md +++ b/web/docs/test.md @@ -4,16 +4,10 @@ This document is the complete testing specification for the Dify frontend projec Goal: Readable, change-friendly, reusable, and debuggable tests. When I ask you to write/refactor/fix tests, follow these rules by default. -## Tech Stack - -- **Framework**: Next.js 15 + React 19 + TypeScript -- **Testing Tools**: Vitest 4.0.16 + React Testing Library 16.0 -- **Test Environment**: happy-dom -- **File Naming**: `ComponentName.spec.tsx` inside a same-level `__tests__/` directory -- **Placement Rule**: Component, hook, and utility tests must live in a sibling `__tests__/` folder at the same level as the source under test. For example, `foo/index.tsx` maps to `foo/__tests__/index.spec.tsx`, and `foo/bar.ts` maps to `foo/__tests__/bar.spec.ts`. - ## Running Tests +Run these commands from `web/`. From the repository root, prefix them with `pnpm -C web`. + ```bash # Run all tests pnpm test @@ -31,6 +25,8 @@ pnpm test path/to/file.spec.tsx ## Project Test Setup - **Configuration**: `vite.config.ts` sets the `happy-dom` environment, loads the Testing Library presets, and respects our path aliases (`@/...`). Check this file before adding new transformers or module name mappers. +- **File naming**: `ComponentName.spec.tsx` inside a same-level `__tests__/` directory. +- **Placement rule**: Component, hook, and utility tests must live in a sibling `__tests__/` folder at the same level as the source under test. For example, `foo/index.tsx` maps to `foo/__tests__/index.spec.tsx`, and `foo/bar.ts` maps to `foo/__tests__/bar.spec.ts`. - **Global setup**: `vitest.setup.ts` already imports `@testing-library/jest-dom`, runs `cleanup()` after every test, and defines shared mocks (for example `react-i18next`). Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently. - **Reusable mocks**: Place shared mock factories inside `web/__mocks__/` and use `vi.mock('module-name')` to point to them rather than redefining mocks in every spec. - **Mocking behavior**: Modules are not mocked automatically. Use `vi.mock(...)` in tests, or place global mocks in `vitest.setup.ts`. @@ -216,8 +212,8 @@ Simulate the interactions that matter to users—primary clicks, change events, **Guidelines**: -- Prefer spying on `global.fetch`/`axios`/`ky` and returning deterministic responses over reaching out to the network. -- Use MSW (`msw` is already installed) when you need declarative request handlers across multiple specs. +- Prefer mocking `@/service/*` modules or spying on `global.fetch` / `ky` clients with deterministic responses over reaching out to the network. +- Do not introduce an HTTP interception dependency such as MSW unless it is already declared in the workspace or adding it is part of the task. - Keep async assertions inside `await waitFor(...)` blocks or the async `findBy*` queries to avoid race conditions. ### 7. Next.js Routing @@ -281,7 +277,7 @@ For complex inputs/entities, use Builders with solid defaults and chainable over Reserve snapshots for static, deterministic fragments (icons, badges, layout chrome). Keep them tight, prefer explicit assertions for behavior, and review any snapshot updates deliberately instead of accepting them wholesale. -**Note**: Dify is a desktop application. **No need for** responsive/mobile testing. +**Note**: Dify primarily targets desktop workflows, but the supported browsers list includes mobile browsers. Do not add responsive/mobile assertions to ordinary unit tests unless the component has responsive behavior, mobile-specific behavior, or accessibility behavior that must be covered. ## Code Style From e134c1e0d590ee6da69ec7ac1b3d34cdf2988ea3 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 11 May 2026 11:53:03 +0800 Subject: [PATCH 30/53] feat(web): improve a11y and remove data-testid (#35999) --- .agents/skills/frontend-testing/SKILL.md | 9 +- .../step-definitions/apps/share-app.steps.ts | 2 +- .../base/notion-page-selector-flow.test.tsx | 4 +- .../text-generation-run-once-flow.test.tsx | 10 +- .../(commonLayout)/datasets/layout.spec.tsx | 12 +- .../(commonLayout)/role-route-guard.spec.tsx | 16 +- .../app/annotation/__tests__/filter.spec.tsx | 5 +- .../app/annotation/batch-action.tsx | 20 ++- .../__tests__/csv-uploader.spec.tsx | 4 +- .../__tests__/index.spec.tsx | 8 + .../csv-uploader.tsx | 19 ++- .../batch-add-annotation-modal/index.tsx | 11 +- .../__tests__/access-control.spec.tsx | 8 +- .../specific-groups-or-members.spec.tsx | 6 +- .../add-member-or-group-pop.tsx | 30 +++- .../specific-groups-or-members.tsx | 13 +- .../__tests__/version-info-modal.spec.tsx | 16 ++ .../app/app-publisher/version-info-modal.tsx | 11 +- .../config-var/__tests__/index.spec.tsx | 22 +-- .../config-var/__tests__/var-item.spec.tsx | 2 +- .../config-select/__tests__/index.spec.tsx | 9 +- .../config-var/config-select/index.tsx | 11 +- .../app/configuration/config-var/var-item.tsx | 23 ++- .../agent-tools/__tests__/index.spec.tsx | 2 +- .../config/agent/agent-tools/index.tsx | 24 +-- .../__tests__/instruction-editor.spec.tsx | 2 +- .../config/automatic/instruction-editor.tsx | 8 +- .../app/configuration/configuration-view.tsx | 1 - .../ctrl-btn-group/__tests__/index.spec.tsx | 8 +- .../configuration/ctrl-btn-group/index.tsx | 4 +- .../settings-modal/__tests__/index.spec.tsx | 2 +- .../dataset-config/settings-modal/index.tsx | 8 +- .../__tests__/index.spec.tsx | 2 +- .../prompt-value-panel/index.tsx | 12 +- .../__tests__/index.spec.tsx | 26 +-- .../create-app-dialog/app-list/sidebar.tsx | 39 +++-- .../components/app/create-app-modal/index.tsx | 14 +- .../__tests__/uploader.spec.tsx | 2 +- .../app/create-from-dsl-modal/uploader.tsx | 8 +- .../duplicate-modal/__tests__/index.spec.tsx | 21 +++ .../components/app/duplicate-modal/index.tsx | 11 +- .../log-annotation/__tests__/index.spec.tsx | 16 +- .../app/log/__tests__/empty-element.spec.tsx | 5 +- .../app/log/__tests__/filter.spec.tsx | 2 +- .../app/overview/app-card-sections.tsx | 70 ++++---- .../customize/__tests__/index.spec.tsx | 2 +- .../app/overview/customize/index.tsx | 2 +- .../switch-app-modal/__tests__/index.spec.tsx | 9 + .../components/app/switch-app-modal/index.tsx | 19 ++- .../workflow-log/__tests__/detail.spec.tsx | 13 +- .../workflow-log/__tests__/filter.spec.tsx | 12 +- .../components/app/workflow-log/detail.tsx | 15 +- web/app/components/app/workflow-log/list.tsx | 9 +- .../components/base/__tests__/alert.spec.tsx | 5 +- .../agent-log-modal/__tests__/detail.spec.tsx | 6 +- .../agent-log-modal/__tests__/index.spec.tsx | 2 +- .../base/agent-log-modal/detail.tsx | 18 +- .../components/base/agent-log-modal/index.tsx | 11 +- web/app/components/base/alert.tsx | 13 +- .../app-icon-picker/__tests__/index.spec.tsx | 4 +- web/app/components/base/audio-btn/index.tsx | 3 +- .../base/audio-gallery/AudioPlayer.tsx | 10 +- .../__tests__/AudioPlayer.spec.tsx | 41 +++-- .../__tests__/chat-wrapper.spec.tsx | 4 +- .../__tests__/header-in-mobile.spec.tsx | 8 +- .../mobile-operation-dropdown.spec.tsx | 12 +- .../header/mobile-operation-dropdown.tsx | 17 +- .../sidebar/__tests__/item.spec.tsx | 36 ++-- .../chat/__tests__/chat-log-modals.spec.tsx | 2 +- .../chat/chat/__tests__/question.spec.tsx | 68 ++++---- .../chat/answer/__tests__/operation.spec.tsx | 16 +- .../__tests__/human-input-form.spec.tsx | 2 +- .../human-input-content/human-input-form.tsx | 1 - .../base/chat/chat/answer/operation.tsx | 3 +- .../chat-input-area/__tests__/index.spec.tsx | 30 ++-- .../__tests__/operation.spec.tsx | 9 +- .../chat/chat/chat-input-area/operation.tsx | 11 +- .../chat/citation/__tests__/popup.spec.tsx | 32 ++-- .../base/chat/chat/citation/popup.tsx | 3 +- .../components/base/chat/chat/question.tsx | 14 +- .../header/__tests__/index.spec.tsx | 20 +-- .../chat/embedded-chatbot/header/index.tsx | 36 ++-- .../inputs-form/__tests__/index.spec.tsx | 14 +- .../embedded-chatbot/inputs-form/index.tsx | 3 - .../copy-feedback/__tests__/index.spec.tsx | 22 ++- .../components/base/copy-feedback/index.tsx | 18 +- .../base/copy-icon/__tests__/index.spec.tsx | 13 +- web/app/components/base/copy-icon/index.tsx | 4 +- .../date-picker/__tests__/index.spec.tsx | 5 +- .../date-picker/index.tsx | 9 +- .../time-picker/index.tsx | 9 +- .../components/base/emoji-picker/Inner.tsx | 60 +++++-- .../emoji-picker/__tests__/Inner.spec.tsx | 24 +-- .../emoji-picker/__tests__/index.spec.tsx | 4 +- .../__tests__/modal.spec.tsx | 10 +- .../conversation-opener/modal.tsx | 19 +-- .../__tests__/setting-content.spec.tsx | 10 +- .../file-upload/setting-content.tsx | 19 +-- .../moderation-setting-modal.spec.tsx | 16 +- .../moderation/moderation-setting-modal.tsx | 18 +- .../__tests__/param-config-content.spec.tsx | 4 +- .../text-to-speech/param-config-content.tsx | 8 +- .../__tests__/audio-preview.spec.tsx | 6 +- .../__tests__/video-preview.spec.tsx | 10 +- .../base/file-uploader/audio-preview.tsx | 13 +- .../base/file-uploader/file-list-in-log.tsx | 19 ++- .../__tests__/file-item.spec.tsx | 4 +- .../__tests__/file-image-item.spec.tsx | 93 ++-------- .../__tests__/file-item.spec.tsx | 22 +-- .../file-image-item.tsx | 35 ++-- .../file-uploader-in-chat-input/file-item.tsx | 19 ++- .../base/file-uploader/video-preview.tsx | 13 +- .../__tests__/index.spec.tsx | 2 +- .../base/float-right-container/index.tsx | 1 - .../field/__tests__/checkbox.spec.tsx | 2 +- .../base/form/components/field/checkbox.tsx | 1 + .../__tests__/audio-preview.spec.tsx | 5 +- .../__tests__/image-list.spec.tsx | 11 +- .../__tests__/image-preview.spec.tsx | 12 +- .../__tests__/video-preview.spec.tsx | 2 +- .../base/image-uploader/audio-preview.tsx | 14 +- .../base/image-uploader/image-list.tsx | 18 +- .../base/image-uploader/image-preview.tsx | 14 +- .../base/image-uploader/video-preview.tsx | 13 +- .../input-with-copy/__tests__/index.spec.tsx | 17 +- .../components/base/input-with-copy/index.tsx | 7 +- .../base/input/__tests__/index.spec.tsx | 2 +- web/app/components/base/input/index.tsx | 11 +- .../markdown-blocks/__tests__/form.spec.tsx | 4 +- .../base/mermaid/__tests__/index.spec.tsx | 4 +- .../__tests__/index.spec.tsx | 2 +- .../base/message-log-modal/index.tsx | 11 +- .../base/new-audio-button/index.tsx | 3 +- .../__tests__/base.spec.tsx | 6 +- .../__tests__/index.spec.tsx | 4 +- .../credential-selector/index.tsx | 2 +- .../search-input/__tests__/index.spec.tsx | 8 +- .../search-input/index.tsx | 19 ++- .../components/base/prompt-editor/hooks.ts | 6 +- .../__tests__/component.spec.tsx | 2 +- .../plugins/context-block/component.tsx | 23 ++- .../__tests__/component-ui.spec.tsx | 73 ++------ .../__tests__/type-switch.spec.tsx | 4 +- .../plugins/hitl-input-block/component-ui.tsx | 6 +- .../plugins/hitl-input-block/input-field.tsx | 4 +- .../plugins/hitl-input-block/type-switch.tsx | 10 +- .../prompt-log-modal/__tests__/index.spec.tsx | 4 +- .../base/prompt-log-modal/index.tsx | 13 +- .../base/qrcode/__tests__/index.spec.tsx | 22 +-- web/app/components/base/qrcode/index.tsx | 45 ++--- .../base/radio-card/__tests__/index.spec.tsx | 4 +- web/app/components/base/radio-card/index.tsx | 18 +- .../base/sort/__tests__/index.spec.tsx | 11 +- web/app/components/base/sort/index.tsx | 13 +- .../base/svg-gallery/__tests__/index.spec.tsx | 2 +- .../base/tag-input/__tests__/index.spec.tsx | 6 +- web/app/components/base/tag-input/index.tsx | 11 +- .../base/video-gallery/VideoPlayer.tsx | 21 ++- .../__tests__/VideoPlayer.spec.tsx | 21 ++- .../billing/annotation-full/modal.tsx | 2 +- .../custom-page/__tests__/index.spec.tsx | 2 +- .../components/custom/custom-page/index.tsx | 8 +- .../__tests__/status-with-action.spec.tsx | 14 +- .../status-with-action.tsx | 11 +- .../image-list/__tests__/index.spec.tsx | 2 +- .../common/image-list/__tests__/more.spec.tsx | 6 +- .../datasets/common/image-list/more.tsx | 14 +- .../__tests__/index.spec.tsx | 6 +- .../__tests__/uploader.spec.tsx | 4 +- .../create-from-dsl-modal/uploader.tsx | 8 +- .../__tests__/index.spec.tsx | 10 +- .../empty-dataset-creation-modal/index.tsx | 7 +- .../file-preview/__tests__/index.spec.tsx | 18 +- .../datasets/create/file-preview/index.tsx | 11 +- .../__tests__/index.spec.tsx | 13 +- .../create/notion-page-preview/index.tsx | 11 +- .../__tests__/index.spec.tsx | 64 +++---- .../create/stop-embedding-modal/index.tsx | 7 +- .../create/website/__tests__/preview.spec.tsx | 14 +- .../jina-reader/__tests__/base.spec.tsx | 12 +- .../website/jina-reader/base/url-input.tsx | 2 +- .../datasets/create/website/preview.tsx | 11 +- .../documents/components/operations.tsx | 46 ++--- .../file-list/__tests__/index.spec.tsx | 4 +- .../file-list/header/__tests__/index.spec.tsx | 10 +- .../documents/detail/__tests__/index.spec.tsx | 6 +- .../detail/__tests__/new-segment.spec.tsx | 15 +- .../__tests__/csv-uploader.spec.tsx | 25 ++- .../detail/batch-modal/csv-uploader.tsx | 19 ++- .../__tests__/child-segment-detail.spec.tsx | 12 +- .../__tests__/new-child-segment.spec.tsx | 12 +- .../__tests__/segment-detail.spec.tsx | 12 +- .../detail/completed/child-segment-detail.tsx | 24 ++- .../components/__tests__/menu-bar.spec.tsx | 2 +- .../detail/completed/new-child-segment.tsx | 22 ++- .../segment-card/__tests__/index.spec.tsx | 14 +- .../detail/completed/segment-card/index.tsx | 16 +- .../detail/completed/segment-detail.tsx | 24 ++- .../datasets/documents/detail/index.tsx | 3 +- .../__tests__/doc-type-selector.spec.tsx | 2 +- .../metadata/components/doc-type-selector.tsx | 8 +- .../datasets/documents/detail/new-segment.tsx | 22 ++- .../segment-add/__tests__/index.spec.tsx | 4 +- .../documents/detail/segment-add/index.tsx | 20 ++- .../datasets/documents/style.module.css | 2 +- .../__tests__/modify-retrieval-modal.spec.tsx | 14 +- .../__tests__/chunk-detail-modal.spec.tsx | 2 +- .../__tests__/result-item-footer.spec.tsx | 3 +- .../components/chunk-detail-modal.tsx | 1 - .../components/result-item-external.tsx | 2 +- .../components/result-item-footer.tsx | 9 +- .../hit-testing/modify-retrieval-modal.tsx | 11 +- .../base/__tests__/date-picker.spec.tsx | 71 ++++---- .../datasets/metadata/base/date-picker.tsx | 62 ++++--- .../__tests__/edited-beacon.spec.tsx | 14 +- .../edit-metadata-batch/edited-beacon.tsx | 11 +- .../metadata/edit-metadata-batch/modal.tsx | 2 +- .../__tests__/create-content.spec.tsx | 4 +- .../dataset-metadata-drawer.spec.tsx | 83 ++------- .../__tests__/select-metadata.spec.tsx | 20 +-- .../metadata-dataset/create-content.tsx | 8 +- .../dataset-metadata-drawer.tsx | 24 ++- .../metadata-dataset/select-metadata.tsx | 29 ++-- .../__tests__/info-group.spec.tsx | 34 ++-- .../metadata/metadata-document/info-group.tsx | 11 +- .../rename-modal/__tests__/index.spec.tsx | 12 +- .../datasets/rename-modal/index.tsx | 11 +- .../__tests__/index.spec.tsx | 2 +- .../develop/__tests__/ApiServer.spec.tsx | 16 +- .../secret-key/__tests__/input-copy.spec.tsx | 9 +- .../develop/secret-key/input-copy.tsx | 15 +- .../explore/app-list/__tests__/index.spec.tsx | 2 +- .../try-app/app/__tests__/chat.spec.tsx | 6 +- .../components/explore/try-app/app/chat.tsx | 8 +- .../__tests__/maintenance-notice.spec.tsx | 6 +- .../account-about/__tests__/index.spec.tsx | 8 +- .../components/header/account-about/index.tsx | 11 +- .../collapse/__tests__/index.spec.tsx | 8 +- .../header/account-setting/collapse/index.tsx | 21 ++- .../install-from-marketplace.tsx | 11 +- .../members-page/__tests__/index.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 18 +- .../edit-workspace-modal/index.tsx | 6 +- .../account-setting/members-page/index.tsx | 12 +- .../invite-modal/__tests__/index.spec.tsx | 4 +- .../__tests__/role-selector.spec.tsx | 39 +++-- .../members-page/invite-modal/index.tsx | 10 +- .../invite-modal/role-selector.tsx | 44 ++--- .../__tests__/invitation-link.spec.tsx | 6 +- .../invited-modal/invitation-link.tsx | 23 ++- .../__tests__/index.spec.tsx | 18 +- .../transfer-ownership-modal/index.tsx | 21 ++- .../__tests__/index.spec.tsx | 6 +- .../provider-added-card/index.tsx | 5 +- .../components/header/maintenance-notice.tsx | 21 ++- .../install-plugin/install-bundle/index.tsx | 2 +- .../install-from-github/index.tsx | 2 +- .../install-from-local-package/index.tsx | 2 +- .../install-from-marketplace/index.tsx | 2 +- .../__tests__/add-oauth-button.spec.tsx | 6 +- .../__tests__/api-key-modal.spec.tsx | 2 +- .../__tests__/authorize-components.spec.tsx | 17 +- .../__tests__/oauth-client-settings.spec.tsx | 4 +- .../authorize/add-oauth-button.tsx | 43 +++-- .../plugin-auth/authorize/api-key-modal.tsx | 1 - .../authorize/oauth-client-settings.tsx | 2 - .../create/__tests__/common-modal.spec.tsx | 2 +- .../subscription-list/create/common-modal.tsx | 1 - .../subscription-list/create/oauth-client.tsx | 2 +- .../edit/__tests__/index.spec.tsx | 161 +++++++++--------- .../edit/apikey-edit-modal.tsx | 5 +- .../edit/manual-edit-modal.tsx | 4 +- .../edit/oauth-edit-modal.tsx | 4 +- .../plugins/plugin-mutation-model/index.tsx | 2 +- .../plugin-page/__tests__/index.spec.tsx | 4 +- .../components/plugins/plugin-page/index.tsx | 4 +- .../plugins/plugin-page/plugin-info.tsx | 2 +- .../__tests__/index.spec.tsx | 19 +-- .../__tests__/plugins-picker.spec.tsx | 2 +- .../auto-update-setting/index.tsx | 15 +- .../auto-update-setting/plugins-picker.tsx | 8 +- .../plugins/reference-setting-modal/index.tsx | 2 +- .../components/__tests__/index.spec.tsx | 2 +- ...blish-as-knowledge-pipeline-modal.spec.tsx | 2 +- .../editor/__tests__/index.spec.tsx | 4 +- .../form/__tests__/show-all-settings.spec.tsx | 2 +- .../editor/form/show-all-settings.tsx | 10 +- .../panel/input-field/editor/index.tsx | 6 +- .../field-list/__tests__/index.spec.tsx | 10 +- .../panel/input-field/field-list/index.tsx | 6 +- .../publish-as-knowledge-pipeline-modal.tsx | 11 +- .../components/update-dsl-modal.tsx | 11 +- .../text-generation-result-panel.spec.tsx | 4 +- .../share/text-generation/info-modal.tsx | 2 +- .../run-once/__tests__/index.spec.tsx | 6 +- .../share/text-generation/run-once/index.tsx | 1 - .../text-generation-result-panel.tsx | 8 +- .../signin/__tests__/countdown.spec.tsx | 16 +- web/app/components/signin/countdown.tsx | 10 +- .../tools/__tests__/provider-list.spec.tsx | 2 +- web/app/components/tools/labels/filter.tsx | 9 +- .../mcp/__tests__/mcp-server-modal.spec.tsx | 10 +- .../tools/mcp/__tests__/modal.spec.tsx | 12 +- .../components/tools/mcp/mcp-server-modal.tsx | 11 +- web/app/components/tools/mcp/modal.tsx | 11 +- .../__tests__/configure-button.spec.tsx | 2 +- .../confirm-modal/__tests__/index.spec.tsx | 12 +- .../workflow-tool/confirm-modal/index.tsx | 11 +- .../components/tools/workflow-tool/index.tsx | 1 - .../tools/workflow-tool/method-selector.tsx | 20 ++- .../__tests__/selection-contextmenu.spec.tsx | 8 +- .../__tests__/update-dsl-modal.spec.tsx | 9 + .../__tests__/index-bar.spec.tsx | 2 +- .../workflow/block-selector/index-bar.tsx | 9 +- .../_base/components/before-run-form/form.tsx | 13 +- .../var-reference-picker.branches.spec.tsx | 2 +- .../__tests__/var-reference-picker.spec.tsx | 2 +- .../var-reference-picker.trigger.spec.tsx | 4 +- .../variable/var-reference-picker.trigger.tsx | 24 ++- .../nodes/code/__tests__/panel.spec.tsx | 12 +- .../components/workflow/nodes/code/panel.tsx | 28 ++- .../nodes/end/__tests__/panel.spec.tsx | 4 +- .../components/workflow/nodes/end/panel.tsx | 11 +- .../human-input/__tests__/panel.spec.tsx | 7 +- .../__tests__/single-run-form.spec.tsx | 102 +++++++++++ .../__tests__/test-email-sender.spec.tsx | 2 +- .../delivery-method/test-email-sender.tsx | 19 ++- .../components/single-run-form.tsx | 10 +- .../workflow/nodes/human-input/panel.tsx | 21 ++- .../__tests__/integration.spec.tsx | 4 +- .../components/add-dataset.tsx | 6 +- .../components/dataset-item.tsx | 2 - .../condition-list/condition-date.tsx | 67 ++++---- .../json-importer.tsx | 11 +- .../generated-result.tsx | 11 +- .../json-schema-generator/prompt-editor.tsx | 11 +- .../components/workflow/nodes/llm/panel.tsx | 11 +- .../__tests__/integration.spec.tsx | 6 +- .../__tests__/update.spec.tsx | 4 +- .../components/extract-parameter/update.tsx | 11 +- .../components/__tests__/class-list.spec.tsx | 4 +- .../components/class-list.tsx | 11 +- .../nodes/start/__tests__/panel.spec.tsx | 2 +- .../components/__tests__/var-item.spec.tsx | 38 +++++ .../nodes/start/components/var-item.tsx | 22 ++- .../components/workflow/nodes/start/panel.tsx | 11 +- .../__tests__/integration.spec.tsx | 4 +- .../__tests__/panel.spec.tsx | 4 +- .../nodes/template-transform/panel.tsx | 11 +- .../panel/__tests__/workflow-preview.spec.tsx | 4 +- .../conversation-variable-modal.spec.tsx | 3 +- .../conversation-variable-modal.tsx | 22 ++- .../workflow/panel/workflow-preview.tsx | 11 +- .../run/__tests__/loop-result-panel.spec.tsx | 9 +- .../run/__tests__/result-text.spec.tsx | 2 +- .../workflow/run/loop-result-panel.tsx | 38 +++-- .../components/workflow/run/result-text.tsx | 8 +- .../workflow/selection-contextmenu.tsx | 3 - .../components/workflow/update-dsl-modal.tsx | 11 +- .../variable-inspect/__tests__/group.spec.tsx | 10 +- .../workflow/variable-inspect/group.tsx | 53 +++--- .../education-apply/expire-notice-modal.tsx | 2 +- web/docs/test.md | 16 +- .../__tests__/dataset-card-tags.spec.tsx | 22 +-- .../__tests__/tag-filter.spec.tsx | 15 +- .../__tests__/tag-item-editor.spec.tsx | 42 +++-- .../__tests__/tag-management-modal.spec.tsx | 25 ++- .../__tests__/tag-panel.spec.tsx | 9 +- .../__tests__/tag-selector.spec.tsx | 2 +- .../__tests__/tag-trigger.spec.tsx | 5 +- .../__tests__/app-card-tags.spec.tsx | 15 +- .../tag-management/components/tag-filter.tsx | 9 +- .../components/tag-item-editor.tsx | 23 ++- .../components/tag-management-modal.tsx | 4 +- .../tag-management/components/tag-panel.tsx | 2 - .../tag-management/components/tag-trigger.tsx | 7 +- web/i18n/en-US/common.json | 7 + 377 files changed, 2806 insertions(+), 2135 deletions(-) create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx create mode 100644 web/app/components/workflow/nodes/start/components/__tests__/var-item.spec.tsx diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index 86675dfeba..21c46d75bc 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -38,13 +38,13 @@ Run these commands from `web/`. From the repository root, prefix them with `pnpm pnpm test # Watch mode -pnpm test:watch +pnpm test --watch # Run specific file pnpm test path/to/file.spec.tsx # Generate coverage report -pnpm test:coverage +pnpm test --coverage # Analyze component complexity pnpm analyze-component <path> @@ -220,7 +220,10 @@ Every test should clearly separate: ### 2. Black-Box Testing - Test observable behavior, not implementation details -- Use semantic queries (getByRole, getByLabelText) +- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`) +- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`. +- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment. +- Do not assert decorative icons by test id. Assert the named control that contains them, or mark decorative icons `aria-hidden`. - Avoid testing internal state directly - **Prefer pattern matching over hardcoded strings** in assertions: diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index d5742bdaa8..3ec038b065 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -40,7 +40,7 @@ Then('the shared app page should be accessible', async function (this: DifyWorld When('I run the shared workflow app', async function (this: DifyWorld) { const page = this.getPage() - const runButton = page.getByTestId('run-button') + const runButton = page.getByRole('button', { name: 'Execute' }) await expect(runButton).toBeEnabled({ timeout: 15_000 }) await runButton.click() diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx index 6295d2dc00..ef813ee4bc 100644 --- a/web/__tests__/base/notion-page-selector-flow.test.tsx +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -111,7 +111,7 @@ describe('Base Notion Page Selector Flow', () => { await user.type(screen.getByTestId('notion-search-input'), 'missing-page') expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() - await user.click(screen.getByTestId('notion-search-input-clear')) + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument() await user.click(screen.getByTestId('notion-page-preview-root-1')) @@ -134,7 +134,7 @@ describe('Base Notion Page Selector Flow', () => { expect(onSelectCredential).toHaveBeenCalledWith('c1') - await user.click(screen.getByTestId('notion-credential-selector-btn')) + await user.click(screen.getByRole('combobox', { name: /Workspace 1/ })) await user.click(screen.getByTestId('notion-credential-item-c2')) expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' }) diff --git a/web/__tests__/share/text-generation-run-once-flow.test.tsx b/web/__tests__/share/text-generation-run-once-flow.test.tsx index 2a5d1b882c..1471effa2d 100644 --- a/web/__tests__/share/text-generation-run-once-flow.test.tsx +++ b/web/__tests__/share/text-generation-run-once-flow.test.tsx @@ -119,7 +119,7 @@ describe('RunOnce – integration flow', () => { fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } }) // Phase 3 – submit - fireEvent.click(screen.getByTestId('run-button')) + fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' })) expect(onSend).toHaveBeenCalledTimes(1) // Phase 4 – simulate "running" state @@ -132,7 +132,7 @@ describe('RunOnce – integration flow', () => { />, ) - const stopBtn = screen.getByTestId('stop-button') + const stopBtn = screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }) expect(stopBtn).toBeInTheDocument() fireEvent.click(stopBtn) expect(onStop).toHaveBeenCalledTimes(1) @@ -145,7 +145,7 @@ describe('RunOnce – integration flow', () => { runControl={{ onStop, isStopping: true }} />, ) - expect(screen.getByTestId('stop-button')).toBeDisabled() + expect(screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' })).toBeDisabled() }) it('clear resets all field types and allows re-submit', async () => { @@ -174,7 +174,7 @@ describe('RunOnce – integration flow', () => { // Re-fill and submit fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } }) - fireEvent.click(screen.getByTestId('run-button')) + fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' })) expect(onSend).toHaveBeenCalledTimes(1) }) @@ -212,7 +212,7 @@ describe('RunOnce – integration flow', () => { fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } }) fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } }) - fireEvent.click(screen.getByTestId('run-button')) + fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' })) expect(onSend).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/(commonLayout)/datasets/layout.spec.tsx b/web/app/(commonLayout)/datasets/layout.spec.tsx index 9c01cffba8..7abc2253ce 100644 --- a/web/app/(commonLayout)/datasets/layout.spec.tsx +++ b/web/app/(commonLayout)/datasets/layout.spec.tsx @@ -63,12 +63,12 @@ describe('DatasetsLayout', () => { render(( <DatasetsLayout> - <div data-testid="datasets-content">datasets</div> + <div>datasets</div> </DatasetsLayout> )) expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByTestId('datasets-content')).not.toBeInTheDocument() + expect(screen.queryByText('datasets')).not.toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) @@ -80,11 +80,11 @@ describe('DatasetsLayout', () => { render(( <DatasetsLayout> - <div data-testid="datasets-content">datasets</div> + <div>datasets</div> </DatasetsLayout> )) - expect(screen.queryByTestId('datasets-content')).not.toBeInTheDocument() + expect(screen.queryByText('datasets')).not.toBeInTheDocument() await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/apps') }) @@ -98,11 +98,11 @@ describe('DatasetsLayout', () => { render(( <DatasetsLayout> - <div data-testid="datasets-content">datasets</div> + <div>datasets</div> </DatasetsLayout> )) - expect(screen.getByTestId('datasets-content')).toBeInTheDocument() + expect(screen.getByText('datasets')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) }) diff --git a/web/app/(commonLayout)/role-route-guard.spec.tsx b/web/app/(commonLayout)/role-route-guard.spec.tsx index ca1550f0b8..ef409393b0 100644 --- a/web/app/(commonLayout)/role-route-guard.spec.tsx +++ b/web/app/(commonLayout)/role-route-guard.spec.tsx @@ -48,12 +48,12 @@ describe('RoleRouteGuard', () => { render(( <RoleRouteGuard> - <div data-testid="guarded-content">content</div> + <div>content</div> </RoleRouteGuard> )) expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument() + expect(screen.queryByText('content')).not.toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) @@ -64,11 +64,11 @@ describe('RoleRouteGuard', () => { render(( <RoleRouteGuard> - <div data-testid="guarded-content">content</div> + <div>content</div> </RoleRouteGuard> )) - expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument() + expect(screen.queryByText('content')).not.toBeInTheDocument() await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/datasets') }) @@ -82,11 +82,11 @@ describe('RoleRouteGuard', () => { render(( <RoleRouteGuard> - <div data-testid="guarded-content">content</div> + <div>content</div> </RoleRouteGuard> )) - expect(screen.getByTestId('guarded-content')).toBeInTheDocument() + expect(screen.getByText('content')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) @@ -98,11 +98,11 @@ describe('RoleRouteGuard', () => { render(( <RoleRouteGuard> - <div data-testid="guarded-content">content</div> + <div>content</div> </RoleRouteGuard> )) - expect(screen.getByTestId('guarded-content')).toBeInTheDocument() + expect(screen.getByText('content')).toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) diff --git a/web/app/components/app/annotation/__tests__/filter.spec.tsx b/web/app/components/app/annotation/__tests__/filter.spec.tsx index 8b69494e3f..5353a32c4b 100644 --- a/web/app/components/app/annotation/__tests__/filter.spec.tsx +++ b/web/app/components/app/annotation/__tests__/filter.spec.tsx @@ -243,10 +243,7 @@ describe('Filter', () => { ) // Act - const input = screen.getByPlaceholderText('common.operation.search') - const clearButton = input.parentElement?.querySelector('div.cursor-pointer') - if (clearButton) - fireEvent.click(clearButton) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) // Assert expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' }) diff --git a/web/app/components/app/annotation/batch-action.tsx b/web/app/components/app/annotation/batch-action.tsx index 938dcb03bd..961f313746 100644 --- a/web/app/components/app/annotation/batch-action.tsx +++ b/web/app/components/app/annotation/batch-action.tsx @@ -55,15 +55,23 @@ const BatchAction: FC<IBatchActionProps> = ({ <span className="text-[13px] leading-[16px] font-semibold text-text-accent">{t(`${i18nPrefix}.selected`, { ns: 'appAnnotation' })}</span> </div> <Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" /> - <div className="flex cursor-pointer items-center gap-x-0.5 px-3 py-2" onClick={showDeleteConfirm}> - <RiDeleteBinLine className="h-4 w-4 text-components-button-destructive-ghost-text" /> - <button type="button" className="px-0.5 text-[13px] leading-[16px] font-medium text-components-button-destructive-ghost-text"> + <button + type="button" + className="flex cursor-pointer items-center gap-x-0.5 border-none bg-transparent px-3 py-2 text-left text-components-button-destructive-ghost-text focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" + onClick={showDeleteConfirm} + > + <RiDeleteBinLine className="h-4 w-4" aria-hidden="true" /> + <span className="px-0.5 text-[13px] leading-[16px] font-medium"> {t('operation.delete', { ns: 'common' })} - </button> - </div> + </span> + </button> <Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" /> - <button type="button" className="px-3.5 py-2 text-[13px] leading-[16px] font-medium text-components-button-ghost-text" onClick={onCancel}> + <button + type="button" + className="border-none bg-transparent px-3.5 py-2 text-left text-[13px] leading-[16px] font-medium text-components-button-ghost-text focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onCancel} + > {t('operation.cancel', { ns: 'common' })} </button> </div> diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx index 5fc1cd25e1..8e6dd6cc28 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx @@ -54,7 +54,7 @@ describe('CSVUploader', () => { const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click') renderComponent() - fireEvent.click(screen.getByText('appAnnotation.batchModal.browse')) + fireEvent.click(screen.getByRole('button', { name: 'appAnnotation.batchModal.browse' })) expect(clickSpy).toHaveBeenCalledTimes(1) clickSpy.mockRestore() @@ -137,7 +137,7 @@ describe('CSVUploader', () => { clickSpy.mockRestore() const valueSetter = vi.spyOn(fileInput, 'value', 'set') - const removeTrigger = screen.getByTestId('remove-file-button') + const removeTrigger = screen.getByRole('button', { name: /operation\.delete$/ }) fireEvent.click(removeTrigger) expect(updateFile).toHaveBeenCalledWith() diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx index c5d7232e12..74b59ff79f 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx @@ -115,6 +115,14 @@ describe('BatchModal', () => { expect(props.onCancel).toHaveBeenCalledTimes(1) }) + it('should call onCancel when close button is clicked', () => { + const { props } = renderComponent() + + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) + + expect(props.onCancel).toHaveBeenCalledTimes(1) + }) + it('should submit the csv file, poll status, and notify when import completes', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) const { props } = renderComponent() diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx index dc63b5c9be..75c3e8a66c 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx @@ -97,7 +97,13 @@ const CSVUploader: FC<Props> = ({ <CSVIcon className="shrink-0" /> <div className="text-text-tertiary"> {t('batchModal.csvUploadTitle', { ns: 'appAnnotation' })} - <span className="cursor-pointer text-text-accent" onClick={selectHandle}>{t('batchModal.browse', { ns: 'appAnnotation' })}</span> + <button + type="button" + className="inline cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={selectHandle} + > + {t('batchModal.browse', { ns: 'appAnnotation' })} + </button> </div> </div> {dragging && <div ref={dragRef} className="absolute top-0 left-0 h-full w-full" />} @@ -113,9 +119,14 @@ const CSVUploader: FC<Props> = ({ <div className="hidden items-center group-hover:flex"> <Button variant="secondary" onClick={selectHandle}>{t('stepOne.uploader.change', { ns: 'datasetCreation' })}</Button> <div className="mx-2 h-4 w-px bg-divider-regular" /> - <div className="cursor-pointer p-2" onClick={removeFile} data-testid="remove-file-button"> - <RiDeleteBinLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + className="cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.delete', { ns: 'common' })} + onClick={removeFile} + > + <RiDeleteBinLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> </div> )} diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index 0f6c27fd5a..7f1905c025 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -91,9 +91,14 @@ const BatchModal: FC<IBatchModalProps> = ({ <DialogContent className="w-full max-w-[520px]! overflow-hidden! rounded-xl! border-none px-8 py-6 text-left align-middle"> <div className="relative pb-1 system-xl-medium text-text-primary">{t('batchModal.title', { ns: 'appAnnotation' })}</div> - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onCancel} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <CSVUploader file={currentCSV} updateFile={handleFile} diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index a686ba9f2e..a3c63f5a0c 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -212,16 +212,16 @@ describe('SpecificGroupsOrMembers', () => { expect(screen.getByText(baseMember.name)).toBeInTheDocument() }) - const groupItem = screen.getByText(baseGroup.name).closest('div') - const groupRemove = groupItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + const groupRemove = screen.getAllByRole('button', { name: /operation\.remove$/ })[0]! + fireEvent.click(groupRemove) await waitFor(() => { expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() }) - const memberItem = screen.getByText(baseMember.name).closest('div') - const memberRemove = memberItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + const memberRemove = screen.getAllByRole('button', { name: /operation\.remove$/ })[0]! + fireEvent.click(memberRemove) await waitFor(() => { diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index 7b198c4e66..e763521940 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -86,11 +86,13 @@ describe('SpecificGroupsOrMembers', () => { expect(screen.getByText(baseMember.name)).toBeInTheDocument() }) - const groupRemove = screen.getByText(baseGroup.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + const removeButtons = screen.getAllByRole('button', { name: /operation\.remove$/ }) + const groupRemove = removeButtons[0]! + const memberRemove = removeButtons[1]! + fireEvent.click(groupRemove) expect(useAccessControlStore.getState().specificGroups).toEqual([]) - const memberRemove = screen.getByText(baseMember.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement fireEvent.click(memberRemove) expect(useAccessControlStore.getState().specificMembers).toEqual([]) }) diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index 38f9c2ab50..8d9bf19ea3 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -119,14 +119,40 @@ function SelectedGroupsBreadCrumb() { const handleReset = useCallback(() => { setSelectedGroupsForBreadcrumb([]) }, [setSelectedGroupsForBreadcrumb]) + const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0 + return ( <div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5"> - <span className={cn('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span> + {hasBreadcrumb + ? ( + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleReset} + > + {t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })} + </button> + ) + : ( + <span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span> + )} {selectedGroupsForBreadcrumb.map((group, index) => { + const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1 + return ( <div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary"> <span>/</span> - <span className={index === selectedGroupsForBreadcrumb.length - 1 ? '' : 'cursor-pointer text-text-accent'} onClick={() => handleBreadCrumbClick(index)}>{group.name}</span> + {isLastGroup + ? <span>{group.name}</span> + : ( + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => handleBreadCrumbClick(index)} + > + {group.name} + </button> + )} </div> ) })} diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx index 2cacd2cf03..ce6619ec80 100644 --- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -120,6 +120,8 @@ type BaseItemProps = { onRemove?: () => void } function BaseItem({ icon, onRemove, children }: BaseItemProps) { + const { t } = useTranslation() + return ( <div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs"> <div className="h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid"> @@ -128,9 +130,14 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) { </div> </div> {children} - <div className="flex h-4 w-4 cursor-pointer items-center justify-center" onClick={onRemove}> - <RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" /> - </div> + <button + type="button" + className="flex h-4 w-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.remove', { ns: 'common' })} + onClick={onRemove} + > + <RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" /> + </button> </div> ) } diff --git a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx index 942a199a87..252eddb47e 100644 --- a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx @@ -103,6 +103,22 @@ describe('VersionInfoModal', () => { expect(handleClose).toHaveBeenCalledTimes(1) }) + it('should close when the close button is clicked', () => { + const handleClose = vi.fn() + + render( + <VersionInfoModal + isOpen + onClose={handleClose} + onPublish={vi.fn()} + />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.close' })) + + expect(handleClose).toHaveBeenCalledTimes(1) + }) + it('should validate release note length and clear previous errors before publishing', () => { const handlePublish = vi.fn() const handleClose = vi.fn() diff --git a/web/app/components/app/app-publisher/version-info-modal.tsx b/web/app/components/app/app-publisher/version-info-modal.tsx index 264975a08b..4e5493d1b2 100644 --- a/web/app/components/app/app-publisher/version-info-modal.tsx +++ b/web/app/components/app/app-publisher/version-info-modal.tsx @@ -79,9 +79,14 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({ <div className="title-2xl-semi-bold text-text-primary first-letter:capitalize"> {versionInfo?.marked_name ? t('versionHistory.editVersionInfo', { ns: 'workflow' }) : t('versionHistory.nameThisVersion', { ns: 'workflow' })} </div> - <div className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={onClose}> - <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> - </div> + <button + type="button" + className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onClose} + > + <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" /> + </button> </div> <div className="flex flex-col gap-y-4 px-6 py-3"> <div className="flex flex-col gap-y-1"> diff --git a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx index 51683ad948..604db8288a 100644 --- a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx @@ -233,9 +233,7 @@ describe('ConfigVar', () => { const item = screen.getByTitle('name · Name') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - expect(actionButtons).toHaveLength(2) - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const editDialog = await screen.findByRole('dialog') const saveButton = within(editDialog).getByRole('button', { name: 'common.operation.save' }) @@ -259,9 +257,7 @@ describe('ConfigVar', () => { const item = screen.getByTitle('first · First') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - expect(actionButtons).toHaveLength(2) - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder') fireEvent.change(inputs[0]!, { target: { value: 'second' } }) @@ -285,9 +281,7 @@ describe('ConfigVar', () => { const item = screen.getByTitle('first · First') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - expect(actionButtons).toHaveLength(2) - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder') fireEvent.change(inputs[1]!, { target: { value: 'Second' } }) @@ -318,7 +312,7 @@ describe('ConfigVar', () => { onPromptVariablesChange, }) - const removeBtn = screen.getByTestId('var-item-delete-btn') + const removeBtn = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(removeBtn) expect(onPromptVariablesChange).toHaveBeenCalledWith([]) @@ -343,7 +337,7 @@ describe('ConfigVar', () => { }, ) - const deleteBtn = screen.getByTestId('var-item-delete-btn') + const deleteBtn = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(deleteBtn) // confirmation modal should show up fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -411,8 +405,7 @@ describe('ConfigVar', () => { const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0] @@ -460,8 +453,7 @@ describe('ConfigVar', () => { const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0] diff --git a/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx index aae00bb2b7..6f4fe5f11a 100644 --- a/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx @@ -39,7 +39,7 @@ describe('VarItem', () => { />, ) - fireEvent.click(screen.getByTestId('var-item-delete-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' })) expect(onRemove).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx index 337b3bfe1c..24517eb341 100644 --- a/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx @@ -44,12 +44,7 @@ describe('ConfigSelect Component', () => { it('handles option deletion', () => { render(<ConfigSelect {...defaultProps} />) - const optionContainer = screen.getByDisplayValue('Option 1').closest('div') - const deleteButton = optionContainer?.querySelector('div[role="button"]') - - if (!deleteButton) - return - fireEvent.click(deleteButton) + fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[0]!) expect(defaultProps.onChange).toHaveBeenCalledWith(['Option 2']) }) @@ -86,7 +81,7 @@ describe('ConfigSelect Component', () => { it('applies delete hover styles', () => { render(<ConfigSelect {...defaultProps} />) const optionContainer = screen.getByDisplayValue('Option 1').closest('div') - const deleteButton = optionContainer?.querySelector('div[role="button"]') + const deleteButton = screen.getAllByRole('button', { name: 'common.operation.delete' })[0] if (!deleteButton) return diff --git a/web/app/components/app/configuration/config-var/config-select/index.tsx b/web/app/components/app/configuration/config-var/config-select/index.tsx index 24bc3b4a06..42878852d9 100644 --- a/web/app/components/app/configuration/config-var/config-select/index.tsx +++ b/web/app/components/app/configuration/config-var/config-select/index.tsx @@ -67,9 +67,10 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ onFocus={() => setFocusID(index)} onBlur={() => setFocusID(null)} /> - <div - role="button" - className="absolute top-1/2 right-1.5 block translate-y-[-50%] cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive" + <button + type="button" + aria-label={t('operation.delete', { ns: 'common' })} + className="absolute top-1/2 right-1.5 block translate-y-[-50%] cursor-pointer rounded-md border-none bg-transparent p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" onClick={() => { onChange(options.filter((_, i) => index !== i)) setDeletingID(null) @@ -77,8 +78,8 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ onMouseEnter={() => setDeletingID(index)} onMouseLeave={() => setDeletingID(null)} > - <RiDeleteBinLine className="h-3.5 w-3.5" /> - </div> + <RiDeleteBinLine className="h-3.5 w-3.5" aria-hidden="true" /> + </button> </div> ))} </ReactSortable> diff --git a/web/app/components/app/configuration/config-var/var-item.tsx b/web/app/components/app/configuration/config-var/var-item.tsx index 80c0bf6ac6..17568683d2 100644 --- a/web/app/components/app/configuration/config-var/var-item.tsx +++ b/web/app/components/app/configuration/config-var/var-item.tsx @@ -9,6 +9,7 @@ import { } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { BracketsX as VarIcon } from '@/app/components/base/icons/src/vender/line/development' import IconTypeIcon from './input-type-icon' @@ -36,6 +37,7 @@ const VarItem: FC<ItemProps> = ({ onRemove, canDrag, }) => { + const { t } = useTranslation() const [isDeleting, setIsDeleting] = useState(false) return ( @@ -58,21 +60,24 @@ const VarItem: FC<ItemProps> = ({ <IconTypeIcon type={type as IInputTypeIconProps['type']} className="text-text-tertiary" /> </div> <div className={cn('hidden items-center justify-end rounded-lg', !readonly && 'group-hover:flex')}> - <div - className="mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-black/5" + <button + type="button" + aria-label={t('operation.edit', { ns: 'common' })} + className="mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onEdit} > - <RiEditLine className="h-4 w-4 text-text-tertiary" /> - </div> - <div - data-testid="var-item-delete-btn" - className="flex h-6 w-6 cursor-pointer items-center justify-center text-text-tertiary hover:text-text-destructive" + <RiEditLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> + <button + type="button" + aria-label={t('operation.delete', { ns: 'common' })} + className="flex h-6 w-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-text-tertiary hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" onClick={onRemove} onMouseOver={() => setIsDeleting(true)} onMouseLeave={() => setIsDeleting(false)} > - <RiDeleteBinLine className="h-4 w-4" /> - </div> + <RiDeleteBinLine className="h-4 w-4" aria-hidden="true" /> + </button> </div> </div> </div> diff --git a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx index d668737a0a..628e34bbe0 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx @@ -368,7 +368,7 @@ describe('AgentTools', () => { it('should remove tool when delete action is clicked', async () => { const { getModelConfig } = renderAgentTools() - const deleteButton = screen.getByTestId('delete-removed-tool') + const deleteButton = screen.getByRole('button', { name: /operation\.delete/i }) if (!deleteButton) throw new Error('Delete button not found') await userEvent.click(deleteButton) diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 3242bcdcf8..8ad9ad1f8f 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -96,6 +96,7 @@ const AgentTools: FC = () => { } const [isDeleting, setIsDeleting] = useState<number>(-1) + const getDeleteToolLabel = (tool: AgentTool) => `${t('operation.delete', { ns: 'common' })} ${tool.tool_label || tool.tool_name}` const getToolValue = (tool: ToolDefaultValue) => { const currToolInCollections = collectionList.find(c => c.id === tool.provider_id) const currToolWithConfigs = currToolInCollections?.tools.find(t => t.name === tool.tool_name) @@ -249,7 +250,7 @@ const AgentTools: FC = () => { <div className="mb-1.5 text-text-tertiary">{t('toolNameUsageTip', { ns: 'tools' })}</div> <button type="button" - className="cursor-pointer rounded-sm text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover" + className="cursor-pointer rounded-sm border-none bg-transparent p-0 text-left text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover" onClick={() => copy(item.tool_name)} > {t('copyToolName', { ns: 'tools' })} @@ -280,8 +281,10 @@ const AgentTools: FC = () => { {t('toolRemoved', { ns: 'tools' })} </PopoverContent> </Popover> - <div - className="cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive" + <button + type="button" + aria-label={getDeleteToolLabel(item)} + className="cursor-pointer rounded-md border-none bg-transparent p-1 text-text-tertiary outline-hidden hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-components-input-border-hover" onClick={() => { const newModelConfig = produce(modelConfig, (draft) => { draft.agentConfig.tools.splice(index, 1) @@ -292,8 +295,8 @@ const AgentTools: FC = () => { onMouseOver={() => setIsDeleting(index)} onMouseLeave={() => setIsDeleting(-1)} > - <RiDeleteBinLine className="h-4 w-4" /> - </div> + <RiDeleteBinLine className="h-4 w-4" aria-hidden="true" /> + </button> </div> )} {!item.isDeleted && !readonly && ( @@ -320,8 +323,10 @@ const AgentTools: FC = () => { </TooltipContent> </Tooltip> )} - <div - className="cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive" + <button + type="button" + aria-label={getDeleteToolLabel(item)} + className="cursor-pointer rounded-md border-none bg-transparent p-1 text-text-tertiary outline-hidden hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-components-input-border-hover" onClick={() => { const newModelConfig = produce(modelConfig, (draft) => { draft.agentConfig.tools.splice(index, 1) @@ -331,10 +336,9 @@ const AgentTools: FC = () => { }} onMouseOver={() => setIsDeleting(index)} onMouseLeave={() => setIsDeleting(-1)} - data-testid="delete-removed-tool" > - <RiDeleteBinLine className="h-4 w-4" /> - </div> + <RiDeleteBinLine className="h-4 w-4" aria-hidden="true" /> + </button> </div> )} <div className={cn(item.isDeleted && 'opacity-50')}> diff --git a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx index c1d7edac13..da4f591b51 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx @@ -82,7 +82,7 @@ describe('InstructionEditor', () => { expect(screen.getByTestId('error-block')).toHaveTextContent('true') expect(screen.getByTestId('last-run-block')).toHaveTextContent('true') - fireEvent.click(screen.getByText('generate.insertContext')) + fireEvent.click(screen.getByRole('button', { name: 'generate.insertContext' })) expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ instanceId: 'editor-1', diff --git a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx index 5596b335df..710f530c9e 100644 --- a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx +++ b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx @@ -113,7 +113,13 @@ const InstructionEditor: FC<Props> = ({ <span>{t('generate.press', { ns: 'appDebug' })}</span> <span className="flex h-4 w-3.5 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</span> <span>{t('generate.to', { ns: 'appDebug' })}</span> - <span onClick={handleInsertVariable} className="ml-1! cursor-pointer hover:border-b hover:border-dotted hover:border-text-tertiary hover:text-text-tertiary">{t('generate.insertContext', { ns: 'appDebug' })}</span> + <button + type="button" + className="ml-1! cursor-pointer border-none bg-transparent p-0 text-left hover:border-b hover:border-dotted hover:border-text-tertiary hover:text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleInsertVariable} + > + {t('generate.insertContext', { ns: 'appDebug' })} + </button> </div> </div> ) diff --git a/web/app/components/app/configuration/configuration-view.tsx b/web/app/components/app/configuration/configuration-view.tsx index 04cb3ffeda..80c2934195 100644 --- a/web/app/components/app/configuration/configuration-view.tsx +++ b/web/app/components/app/configuration/configuration-view.tsx @@ -218,7 +218,6 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({ <DrawerCloseButton aria-label={t('operation.close', { ns: 'common' })} className="h-6 w-6 rounded-md" - data-testid="close-icon" /> </div> <Debug diff --git a/web/app/components/app/configuration/ctrl-btn-group/__tests__/index.spec.tsx b/web/app/components/app/configuration/ctrl-btn-group/__tests__/index.spec.tsx index 65f03c92cd..036820f1b3 100644 --- a/web/app/components/app/configuration/ctrl-btn-group/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/ctrl-btn-group/__tests__/index.spec.tsx @@ -17,8 +17,8 @@ describe('ContrlBtnGroup', () => { render(<ContrlBtnGroup onSave={onSave} onReset={onReset} />) // Assert - expect(screen.getByTestId('apply-btn')).toBeInTheDocument() - expect(screen.getByTestId('reset-btn')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appDebug.operation.resetConfig' })).toBeInTheDocument() }) }) @@ -31,8 +31,8 @@ describe('ContrlBtnGroup', () => { render(<ContrlBtnGroup onSave={onSave} onReset={onReset} />) // Act - fireEvent.click(screen.getByTestId('apply-btn')) - fireEvent.click(screen.getByTestId('reset-btn')) + fireEvent.click(screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })) + fireEvent.click(screen.getByRole('button', { name: 'appDebug.operation.resetConfig' })) // Assert expect(onSave).toHaveBeenCalledTimes(1) diff --git a/web/app/components/app/configuration/ctrl-btn-group/index.tsx b/web/app/components/app/configuration/ctrl-btn-group/index.tsx index 6ac485c097..c746d273ba 100644 --- a/web/app/components/app/configuration/ctrl-btn-group/index.tsx +++ b/web/app/components/app/configuration/ctrl-btn-group/index.tsx @@ -15,8 +15,8 @@ const ContrlBtnGroup: FC<IContrlBtnGroupProps> = ({ onSave, onReset }) => { return ( <div className="fixed bottom-0 left-[224px] h-[64px] w-[519px]"> <div className={`${s.ctrlBtn} flex h-full items-center gap-2 bg-white pl-4`}> - <Button variant="primary" onClick={onSave} data-testid="apply-btn">{t('operation.applyConfig', { ns: 'appDebug' })}</Button> - <Button onClick={onReset} data-testid="reset-btn">{t('operation.resetConfig', { ns: 'appDebug' })}</Button> + <Button variant="primary" onClick={onSave}>{t('operation.applyConfig', { ns: 'appDebug' })}</Button> + <Button onClick={onReset}>{t('operation.resetConfig', { ns: 'appDebug' })}</Button> </div> </div> ) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index cf1df79d1e..cc7f574208 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -372,7 +372,7 @@ describe('SettingsModal', () => { // Act await renderSettingsModal(createDataset()) - await user.click(screen.getByText('datasetSettings.form.embeddingModelTipLink')) + await user.click(screen.getByRole('button', { name: 'datasetSettings.form.embeddingModelTipLink' })) // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 74e4ca5fe5..7e32ecde11 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -281,7 +281,13 @@ const SettingsModal: FC<SettingsModalProps> = ({ </div> <div className="mt-2 w-full text-xs leading-6 text-text-tertiary"> {t('form.embeddingModelTip', { ns: 'datasetSettings' })} - <span className="cursor-pointer text-text-accent" onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}>{t('form.embeddingModelTipLink', { ns: 'datasetSettings' })}</span> + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} + > + {t('form.embeddingModelTipLink', { ns: 'datasetSettings' })} + </button> </div> </div> </div> diff --git a/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx b/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx index 81c90deab6..f935abcee6 100644 --- a/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx @@ -439,7 +439,7 @@ describe('PromptValuePanel', () => { it('collapses the user input panel and hides the clear and run actions', () => { renderPanel() - fireEvent.click(screen.getByText('appDebug.inputs.userInputField')) + fireEvent.click(screen.getByRole('button', { name: 'appDebug.inputs.userInputField' })) expect(screen.queryByRole('button', { name: 'common.operation.clear' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'appDebug.inputs.run' })).not.toBeInTheDocument() diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index bfcc13c23c..97988939d4 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -111,11 +111,15 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ <> <div className="relative z-1 mx-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-md"> <div className={cn('px-4 pt-3', userInputFieldCollapse ? 'pb-3' : 'pb-1')}> - <div className="flex cursor-pointer items-center gap-0.5 py-0.5" onClick={() => setUserInputFieldCollapse(!userInputFieldCollapse)}> + <button + type="button" + className="flex cursor-pointer items-center gap-0.5 border-none bg-transparent px-0 py-0.5 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => setUserInputFieldCollapse(!userInputFieldCollapse)} + > <div className="system-md-semibold-uppercase text-text-secondary">{t('inputs.userInputField', { ns: 'appDebug' })}</div> - {userInputFieldCollapse && <RiArrowRightSLine className="h-4 w-4 text-text-secondary" />} - {!userInputFieldCollapse && <RiArrowDownSLine className="h-4 w-4 text-text-secondary" />} - </div> + {userInputFieldCollapse && <RiArrowRightSLine className="h-4 w-4 text-text-secondary" aria-hidden="true" />} + {!userInputFieldCollapse && <RiArrowDownSLine className="h-4 w-4 text-text-secondary" aria-hidden="true" />} + </button> {!userInputFieldCollapse && ( <div className="mt-1 system-xs-regular text-text-tertiary">{t('inputs.completionVarTip', { ns: 'appDebug' })}</div> )} diff --git a/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx index 59febff8d5..b80e98f8d7 100644 --- a/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx @@ -11,12 +11,12 @@ vi.mock('../app-list', () => ({ onSuccess: () => void }) { return ( - <div data-testid="app-list"> - <button data-testid="app-list-success" onClick={onSuccess}> + <div role="region" aria-label="App list"> + <button type="button" onClick={onSuccess}> Success </button> {onCreateFromBlank && ( - <button data-testid="create-from-blank" onClick={onCreateFromBlank}> + <button type="button" onClick={onCreateFromBlank}> Create from Blank </button> )} @@ -48,13 +48,13 @@ describe('CreateAppTemplateDialog', () => { render(<CreateAppTemplateDialog {...defaultProps} show={true} />) expect(screen.getByRole('dialog'))!.toBeInTheDocument() - expect(screen.getByTestId('app-list'))!.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'App list' }))!.toBeInTheDocument() }) it('should render create from blank button when onCreateFromBlank is provided', () => { render(<CreateAppTemplateDialog {...defaultProps} show={true} />) - expect(screen.getByTestId('create-from-blank'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create from Blank' }))!.toBeInTheDocument() }) it('should not render create from blank button when onCreateFromBlank is not provided', () => { @@ -62,7 +62,7 @@ describe('CreateAppTemplateDialog', () => { render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />) - expect(screen.queryByTestId('create-from-blank')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create from Blank' })).not.toBeInTheDocument() }) }) @@ -99,8 +99,8 @@ describe('CreateAppTemplateDialog', () => { const dialog = screen.getByRole('dialog') expect(dialog)!.toBeInTheDocument() - expect(screen.getByTestId('app-list'))!.toBeInTheDocument() - expect(screen.getByTestId('app-list-success'))!.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'App list' }))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Success' }))!.toBeInTheDocument() }) it('should call both onSuccess and onClose when app list success is triggered', () => { @@ -115,7 +115,7 @@ describe('CreateAppTemplateDialog', () => { />, ) - fireEvent.click(screen.getByTestId('app-list-success')) + fireEvent.click(screen.getByRole('button', { name: 'Success' })) expect(mockOnSuccess).toHaveBeenCalledTimes(1) expect(mockOnClose).toHaveBeenCalledTimes(1) @@ -131,7 +131,7 @@ describe('CreateAppTemplateDialog', () => { />, ) - fireEvent.click(screen.getByTestId('create-from-blank')) + fireEvent.click(screen.getByRole('button', { name: 'Create from Blank' })) expect(mockOnCreateFromBlank).toHaveBeenCalledTimes(1) }) @@ -186,8 +186,8 @@ describe('CreateAppTemplateDialog', () => { render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />) }).not.toThrow() - expect(screen.getByTestId('app-list'))!.toBeInTheDocument() - expect(screen.queryByTestId('create-from-blank')).not.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'App list' }))!.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create from Blank' })).not.toBeInTheDocument() }) it('should work with all required props only', () => { @@ -202,7 +202,7 @@ describe('CreateAppTemplateDialog', () => { }).not.toThrow() expect(screen.getByRole('dialog'))!.toBeInTheDocument() - expect(screen.getByTestId('app-list'))!.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'App list' }))!.toBeInTheDocument() }) }) }) diff --git a/web/app/components/app/create-app-dialog/app-list/sidebar.tsx b/web/app/components/app/create-app-dialog/app-list/sidebar.tsx index 128e597259..5b1eb1ac1f 100644 --- a/web/app/components/app/create-app-dialog/app-list/sidebar.tsx +++ b/web/app/components/app/create-app-dialog/app-list/sidebar.tsx @@ -27,10 +27,14 @@ export default function Sidebar({ current, categories, onClick, onCreateFromBlan {categories.map(category => (<CategoryItem key={category} category={category} active={current === category} onClick={onClick} />))} </ul> <Divider bgStyle="gradient" /> - <div className="flex cursor-pointer items-center gap-1 px-3 py-1 text-text-tertiary" onClick={onCreateFromBlank}> - <RiStickyNoteAddLine className="h-3.5 w-3.5" /> + <button + type="button" + className="flex w-full cursor-pointer items-center gap-1 border-none bg-transparent px-3 py-1 text-left text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onCreateFromBlank} + > + <RiStickyNoteAddLine className="h-3.5 w-3.5" aria-hidden="true" /> <span className="system-xs-regular">{t('newApp.startFromBlank', { ns: 'app' })}</span> - </div> + </button> </div> ) } @@ -42,19 +46,22 @@ type CategoryItemProps = { } function CategoryItem({ category, active, onClick }: CategoryItemProps) { return ( - <li - className={cn('group flex h-8 cursor-pointer items-center gap-2 rounded-lg p-1 pl-3 hover:bg-state-base-hover [&.active]:bg-state-base-active', active && 'active')} - onClick={() => { onClick?.(category) }} - > - {category === AppCategories.RECOMMENDED && ( - <div className="inline-flex h-5 w-5 items-center justify-center rounded-md"> - <RiThumbUpLine className="h-4 w-4 text-components-menu-item-text group-[.active]:text-components-menu-item-text-active" /> - </div> - )} - <AppCategoryLabel - category={category} - className={cn('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} - /> + <li> + <button + type="button" + className={cn('group flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent p-1 pl-3 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden [&.active]:bg-state-base-active', active && 'active')} + onClick={() => { onClick?.(category) }} + > + {category === AppCategories.RECOMMENDED && ( + <div className="inline-flex h-5 w-5 items-center justify-center rounded-md"> + <RiThumbUpLine className="h-4 w-4 text-components-menu-item-text group-[.active]:text-components-menu-item-text-active" aria-hidden="true" /> + </div> + )} + <AppCategoryLabel + category={category} + className={cn('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} + /> + </button> </li> ) } diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 42514e986d..c27231bc87 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -146,11 +146,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: <div className="mb-2 flex items-center"> <button type="button" - className="flex cursor-pointer items-center border-0 bg-transparent p-0" + className="flex cursor-pointer items-center border-0 bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)} > <span className="system-2xs-medium-uppercase text-text-tertiary">{t('newApp.forBeginners', { ns: 'app' })}</span> - <RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} /> + <RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} aria-hidden="true" /> </button> </div> {isAppTypeExpanded && ( @@ -249,12 +249,16 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: </div> {isAppsFull && <AppsFull className="mt-4" loc="app-create" />} <div className="flex items-center justify-between pt-5 pb-10"> - <div className="flex cursor-pointer items-center gap-1 system-xs-regular text-text-tertiary" onClick={onCreateFromTemplate}> + <button + type="button" + className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left system-xs-regular text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onCreateFromTemplate} + > <span>{t('newApp.noIdeaTip', { ns: 'app' })}</span> <div className="p-px"> - <RiArrowRightLine className="h-3.5 w-3.5" /> + <RiArrowRightLine className="h-3.5 w-3.5" aria-hidden="true" /> </div> - </div> + </button> <div className="flex gap-2"> <Button onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button> <Button disabled={isAppsFull || !name} className="gap-1" variant="primary" onClick={handleCreateApp}> diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx index ba097b355c..0240136d3e 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx @@ -157,7 +157,7 @@ describe('Uploader', () => { const hiddenInput = getHiddenInput() const clickSpy = vi.spyOn(hiddenInput, 'click') - fireEvent.click(screen.getByText('dslUploader.browse')) + fireEvent.click(screen.getByRole('button', { name: 'dslUploader.browse' })) expect(clickSpy).toHaveBeenCalled() diff --git a/web/app/components/app/create-from-dsl-modal/uploader.tsx b/web/app/components/app/create-from-dsl-modal/uploader.tsx index aeecb280d0..5b380bd9c5 100644 --- a/web/app/components/app/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/app/create-from-dsl-modal/uploader.tsx @@ -112,7 +112,13 @@ const Uploader: FC<Props> = ({ <RiUploadCloud2Line className="h-6 w-6 text-text-tertiary" /> <div className="text-text-tertiary"> {t('dslUploader.button', { ns: 'app' })} - <span className="cursor-pointer pl-1 text-text-accent" onClick={selectHandle}>{t('dslUploader.browse', { ns: 'app' })}</span> + <button + type="button" + className="inline cursor-pointer border-none bg-transparent p-0 pl-1 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={selectHandle} + > + {t('dslUploader.browse', { ns: 'app' })} + </button> </div> </div> {dragging && <div ref={dragRef} className="absolute top-0 left-0 h-full w-full" />} diff --git a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx index ceec1aa3c0..d8473378a0 100644 --- a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx @@ -106,6 +106,27 @@ describe('DuplicateAppModal', () => { expect(onHide).toHaveBeenCalled() }) + it('should call onHide when close button is clicked', async () => { + const onHide = vi.fn() + const user = userEvent.setup() + + render( + <DuplicateAppModal + appName="Demo App" + icon_type="emoji" + icon="🤖" + icon_background="#FFEAD5" + show + onConfirm={vi.fn()} + onHide={onHide} + />, + ) + + await user.click(screen.getByRole('button', { name: 'operation.close' })) + + expect(onHide).toHaveBeenCalledTimes(1) + }) + it('should restore the original image icon when the picker closes without selecting', async () => { const onConfirm = vi.fn() const user = userEvent.setup() diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index 7a83c6c277..996952ff2f 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -72,9 +72,14 @@ const DuplicateAppModal = ({ <Dialog open={show}> <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none px-8 text-left align-middle"> - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onHide} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <div className="relative mt-3 mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('duplicateTitle', { ns: 'app' })}</div> <div className="mb-9 system-sm-regular text-text-secondary"> <div className="mb-2 system-md-medium">{t('appCustomize.subTitle', { ns: 'explore' })}</div> diff --git a/web/app/components/app/log-annotation/__tests__/index.spec.tsx b/web/app/components/app/log-annotation/__tests__/index.spec.tsx index f75e782953..db657ed29a 100644 --- a/web/app/components/app/log-annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/log-annotation/__tests__/index.spec.tsx @@ -15,19 +15,19 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/app/components/app/annotation', () => ({ default: ({ appDetail }: { appDetail: App }) => ( - <div data-testid="annotation" data-app-id={appDetail.id} /> + <section aria-label="Annotation log">{appDetail.id}</section> ), })) vi.mock('@/app/components/app/log', () => ({ default: ({ appDetail }: { appDetail: App }) => ( - <div data-testid="log" data-app-id={appDetail.id} /> + <section aria-label="App log">{appDetail.id}</section> ), })) vi.mock('@/app/components/app/workflow-log', () => ({ default: ({ appDetail }: { appDetail: App }) => ( - <div data-testid="workflow-log" data-app-id={appDetail.id} /> + <section aria-label="Workflow log">{appDetail.id}</section> ), })) @@ -113,7 +113,7 @@ describe('LogAnnotation', () => { // Assert expect(screen.queryByText('appLog.title')).not.toBeInTheDocument() - expect(screen.getByTestId('workflow-log')).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Workflow log' })).toBeInTheDocument() }) }) @@ -127,8 +127,8 @@ describe('LogAnnotation', () => { render(<LogAnnotation pageType={PageType.log} />) // Assert - expect(screen.getByTestId('log')).toBeInTheDocument() - expect(screen.queryByTestId('annotation')).not.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'App log' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Annotation log' })).not.toBeInTheDocument() }) it('should render annotation content when page type is annotation', () => { @@ -139,8 +139,8 @@ describe('LogAnnotation', () => { render(<LogAnnotation pageType={PageType.annotation} />) // Assert - expect(screen.getByTestId('annotation')).toBeInTheDocument() - expect(screen.queryByTestId('log')).not.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Annotation log' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'App log' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app/log/__tests__/empty-element.spec.tsx b/web/app/components/app/log/__tests__/empty-element.spec.tsx index 3967097c12..41f82375f9 100644 --- a/web/app/components/app/log/__tests__/empty-element.spec.tsx +++ b/web/app/components/app/log/__tests__/empty-element.spec.tsx @@ -8,7 +8,7 @@ vi.mock('react-i18next', () => ({ t: (key: string) => key, }), Trans: ({ i18nKey, components }: { i18nKey: string, components: Record<string, React.ReactNode> }) => ( - <span data-testid="trans-component" data-i18n-key={i18nKey}> + <span> {i18nKey} {components.shareLink} {components.testLink} @@ -54,8 +54,7 @@ describe('EmptyElement', () => { const appDetail = createMockAppDetail(AppModeEnum.CHAT) render(<EmptyElement appDetail={appDetail} />) - const transComponent = screen.getByTestId('trans-component') - expect(transComponent).toHaveAttribute('data-i18n-key', 'table.empty.element.content') + expect(screen.getByText('table.empty.element.content', { exact: false })).toBeInTheDocument() }) it('should render ThreeDotsIcon SVG', () => { diff --git a/web/app/components/app/log/__tests__/filter.spec.tsx b/web/app/components/app/log/__tests__/filter.spec.tsx index ec1a0183be..3692c76a85 100644 --- a/web/app/components/app/log/__tests__/filter.spec.tsx +++ b/web/app/components/app/log/__tests__/filter.spec.tsx @@ -146,7 +146,7 @@ describe('Filter', () => { render(<Filter {...propsWithKeyword} />) - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'operation.clear' }) fireEvent.click(clearButton) expect(mockSetQueryParams).toHaveBeenCalledWith({ diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index 8db5193f2d..5328efab02 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -351,52 +351,46 @@ export const AppCardOperations = ({ if (key === 'launch' && launchConfigAction) { return ( - <MaybeTooltip - key={key} - content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''} - tooltipClassName="mt-[-8px]" - show={disabled} - > + <div key={key} className="mr-1 inline-flex"> + <MaybeTooltip + content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''} + tooltipClassName="mt-[-8px]" + show={disabled} + > + <Button + className="min-w-[88px] rounded-r-none border-0 px-0 py-0 shadow-none backdrop-blur-none hover:bg-components-button-secondary-bg" + size="small" + variant="secondary" + onClick={onClick} + disabled={disabled} + > + <div className="flex h-full min-w-[88px] items-center justify-center rounded-l-md px-2 hover:bg-components-button-secondary-bg-hover"> + <div className="flex items-center justify-center gap-px"> + <Icon className="h-3.5 w-3.5" /> + <div className="px-[3px] system-xs-medium">{label}</div> + </div> + </div> + </Button> + </MaybeTooltip> + <div + aria-hidden="true" + className="h-6 w-px shrink-0 bg-divider-regular opacity-100" + /> <Button - className="mr-1 border-0 px-0 py-0 shadow-none backdrop-blur-none hover:bg-components-button-secondary-bg" + aria-label={launchConfigAction.label} + className="w-8 rounded-l-none border-0 px-0 py-0 shadow-none backdrop-blur-none hover:bg-components-button-secondary-bg-hover" size="small" variant="secondary" - onClick={onClick} - disabled={disabled} + onClick={launchConfigAction.onClick} + disabled={disabled || launchConfigAction.disabled} > - <div className="flex h-full min-w-[88px] items-center justify-center rounded-l-md px-2 hover:bg-components-button-secondary-bg-hover"> + <div className="flex h-full w-8 shrink-0 items-center justify-center rounded-r-md"> <div className="flex items-center justify-center gap-px"> - <Icon className="h-3.5 w-3.5" /> - <div className="px-[3px] system-xs-medium">{label}</div> + <RiSettings2Line className="h-3.5 w-3.5" aria-hidden="true" /> </div> </div> - <div - aria-hidden="true" - className="h-4 w-px shrink-0 bg-divider-regular opacity-100" - /> - <div - className="flex h-full w-8 shrink-0 items-center justify-center rounded-r-md hover:bg-components-button-secondary-bg-hover" - onClick={(event) => { - event.stopPropagation() - launchConfigAction.onClick() - }} - aria-label={launchConfigAction.label} - role="button" - tabIndex={disabled ? -1 : 0} - onKeyDown={(event) => { - if (disabled) - return - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - event.stopPropagation() - launchConfigAction.onClick() - } - }} - > - <RiSettings2Line className="h-3.5 w-3.5" /> - </div> </Button> - </MaybeTooltip> + </div> ) } diff --git a/web/app/components/app/overview/customize/__tests__/index.spec.tsx b/web/app/components/app/overview/customize/__tests__/index.spec.tsx index 1f703afcd8..6b33cadcf7 100644 --- a/web/app/components/app/overview/customize/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/customize/__tests__/index.spec.tsx @@ -310,7 +310,7 @@ describe('CustomizeModal', () => { expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument() }) - const closeButton = screen.getByTestId('modal-close-button') + const closeButton = screen.getByRole('button', { name: 'Close' }) fireEvent.click(closeButton) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index 46527c30f9..89b621f32b 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -53,7 +53,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({ <DialogDescription className="mt-2 body-md-regular text-text-secondary"> {t(`${prefixCustomize}.explanation`, { ns: 'appOverview' })} </DialogDescription> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <div className="mt-4 w-full rounded-lg border-[0.5px] border-components-panel-border px-6 py-5"> <Tag bordered={true} hideBg={true} className="border-text-accent-secondary text-text-accent-secondary uppercase"> {t(`${prefixCustomize}.way`, { ns: 'appOverview' })} diff --git a/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx index 99bf1e7436..d043046dc8 100644 --- a/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx @@ -273,6 +273,15 @@ describe('SwitchAppModal', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('should call onClose when close button is clicked', async () => { + const user = userEvent.setup() + const { onClose } = renderComponent() + + await user.click(screen.getByRole('button', { name: /operation\.close$/ })) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + it('should switch app and navigate with push when keeping original', async () => { const user = userEvent.setup() // Arrange diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index c46ce7ad13..b88579fbe5 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -111,9 +111,14 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo <Dialog open={show}> <DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn('w-[600px] max-w-[600px] p-8'))}> - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onClose} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-default-burn p-3 shadow-xl"> <AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" /> </div> @@ -161,7 +166,13 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo <div className="flex items-center justify-between pt-6"> <div className="flex items-center"> <Checkbox className="shrink-0" checked={removeOriginal} onCheck={() => setRemoveOriginal(!removeOriginal)} /> - <div className="ml-2 cursor-pointer text-sm leading-5 text-text-secondary" onClick={() => setRemoveOriginal(!removeOriginal)}>{t('removeOriginal', { ns: 'app' })}</div> + <button + type="button" + className="ml-2 cursor-pointer border-none bg-transparent p-0 text-left text-sm leading-5 text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => setRemoveOriginal(!removeOriginal)} + > + {t('removeOriginal', { ns: 'app' })} + </button> </div> <div className="flex items-center"> <Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button> diff --git a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx index a58f55b031..4abbfa24a5 100644 --- a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx @@ -117,11 +117,9 @@ describe('DetailPanel', () => { }) it('should render close button', () => { - const { container } = render(<DetailPanel runID="run-123" onClose={defaultOnClose} />) + render(<DetailPanel runID="run-123" onClose={defaultOnClose} />) - // Close button has RiCloseLine icon - const closeButton = container.querySelector('span.cursor-pointer') - expect(closeButton).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) it('should render Run component with correct URLs', () => { @@ -173,12 +171,11 @@ describe('DetailPanel', () => { const user = userEvent.setup() const onClose = vi.fn() - const { container } = render(<DetailPanel runID="run-123" onClose={onClose} />) + render(<DetailPanel runID="run-123" onClose={onClose} />) - const closeButton = container.querySelector('span.cursor-pointer') - expect(closeButton).toBeInTheDocument() + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) - await user.click(closeButton!) + await user.click(closeButton) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx index 237862fd71..59063dfb98 100644 --- a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx @@ -328,22 +328,14 @@ describe('Filter', () => { const user = userEvent.setup() const setQueryParams = vi.fn() - const { container } = render( + render( <Filter queryParams={createDefaultQueryParams({ keyword: 'test' })} setQueryParams={setQueryParams} />, ) - // The Input component renders a clear icon div inside the input wrapper - // when showClearIcon is true and value exists - const inputWrapper = container.querySelector('.w-\\[200px\\]') - - // Find the clear icon div (has cursor-pointer class and contains RiCloseCircleFill) - const clearIconDiv = inputWrapper?.querySelector('div.cursor-pointer') - - expect(clearIconDiv)!.toBeInTheDocument() - await user.click(clearIconDiv!) + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) expect(setQueryParams).toHaveBeenCalledWith({ status: 'all', diff --git a/web/app/components/app/workflow-log/detail.tsx b/web/app/components/app/workflow-log/detail.tsx index 05cd6f1676..f0e80f11c0 100644 --- a/web/app/components/app/workflow-log/detail.tsx +++ b/web/app/components/app/workflow-log/detail.tsx @@ -27,9 +27,14 @@ const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => { return ( <div className="relative flex grow flex-col pt-3"> - <span className="absolute top-4 right-3 z-20 cursor-pointer p-1" onClick={onClose}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </span> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onClose} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <div className="flex items-center bg-components-panel-bg"> <h1 className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary">{t('runDetail.workflowTitle', { ns: 'appLog' })}</h1> {canReplay && ( @@ -38,11 +43,11 @@ const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => { render={( <button type="button" - className="mr-1 flex h-6 w-6 items-center justify-center rounded-md hover:bg-state-base-hover" + className="mr-1 flex h-6 w-6 items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-state-base-hover" aria-label={t('runDetail.testWithParams', { ns: 'appLog' })} onClick={handleReplay} > - <RiPlayLargeLine className="h-4 w-4 text-text-tertiary" /> + <RiPlayLargeLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> </button> )} /> diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index 78d9a329e6..51e038dbb6 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -130,12 +130,17 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => { <tr> <td className="w-5 rounded-l-lg bg-background-section-burn pr-1 pl-2 whitespace-nowrap"></td> <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> - <div className="flex cursor-pointer items-center hover:text-text-secondary" onClick={handleSort}> + <button + type="button" + className="flex cursor-pointer items-center border-none bg-transparent p-0 text-left hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleSort} + > {t('table.header.startTime', { ns: 'appLog' })} <ArrowDownIcon className={cn('ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all', 'text-text-tertiary', sortOrder === 'asc' ? 'rotate-180' : '')} + aria-hidden="true" /> - </div> + </button> </td> <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.status', { ns: 'appLog' })}</td> <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.runtime', { ns: 'appLog' })}</td> diff --git a/web/app/components/base/__tests__/alert.spec.tsx b/web/app/components/base/__tests__/alert.spec.tsx index 10c1a6bbfa..3d4e683d19 100644 --- a/web/app/components/base/__tests__/alert.spec.tsx +++ b/web/app/components/base/__tests__/alert.spec.tsx @@ -25,8 +25,7 @@ describe('Alert', () => { it('should render the close icon', () => { render(<Alert {...defaultProps} />) - const closeIcon = screen.getByTestId('close-icon') - expect(closeIcon).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) }) @@ -66,7 +65,7 @@ describe('Alert', () => { it('should call onHide when close button is clicked', () => { const onHide = vi.fn() render(<Alert {...defaultProps} onHide={onHide} />) - const closeButton = screen.getByTestId('close-icon') + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) fireEvent.click(closeButton) expect(onHide).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx index 78d4a06c4a..4c8a1d24bf 100644 --- a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx @@ -172,7 +172,7 @@ describe('AgentLogDetail', () => { await renderAndWaitForData() - fireEvent.click(screen.getByText(/runLog.tracing/i)) + fireEvent.click(screen.getByRole('button', { name: /runLog.tracing/i })) await waitFor(() => { const tracingTab = screen.getByText(/runLog.tracing/i) @@ -188,13 +188,13 @@ describe('AgentLogDetail', () => { await renderAndWaitForData() - fireEvent.click(screen.getByText(/runLog.tracing/i)) + fireEvent.click(screen.getByRole('button', { name: /runLog.tracing/i })) await waitFor(() => { expect(screen.getByText(/runLog.tracing/i).getAttribute('data-active')).toBe('true') }) - fireEvent.click(screen.getByText(/runLog.detail/i)) + fireEvent.click(screen.getByRole('button', { name: /runLog.detail/i })) await waitFor(() => { const detailTab = screen.getByText(/runLog.detail/i) diff --git a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx index d8c595202a..1c8e79f4aa 100644 --- a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx @@ -124,7 +124,7 @@ describe('AgentLogModal', () => { render(<AgentLogModal {...mockProps} />) - const closeBtn = screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling! + const closeBtn = screen.getByRole('button', { name: 'common.operation.close' }) fireEvent.click(closeBtn) expect(mockProps.onCancel).toHaveBeenCalledTimes(1) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index b12777dfe5..24c192c47e 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -67,12 +67,22 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({ activeTab = 'DETAIL', convers <div className="relative flex grow flex-col"> {/* tab */} <div className="flex shrink-0 items-center border-b-[0.5px] border-divider-regular px-4"> - <div className={cn('mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] leading-[18px] font-semibold text-text-tertiary', currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary')} data-active={currentTab === 'DETAIL'} onClick={() => switchTab('DETAIL')}> + <button + type="button" + className={cn('mr-6 cursor-pointer border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-0 py-3 text-left text-[13px] leading-[18px] font-semibold text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary')} + data-active={currentTab === 'DETAIL'} + onClick={() => switchTab('DETAIL')} + > {t('detail', { ns: 'runLog' })} - </div> - <div className={cn('mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] leading-[18px] font-semibold text-text-tertiary', currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary')} data-active={currentTab === 'TRACING'} onClick={() => switchTab('TRACING')}> + </button> + <button + type="button" + className={cn('mr-6 cursor-pointer border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-0 py-3 text-left text-[13px] leading-[18px] font-semibold text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary')} + data-active={currentTab === 'TRACING'} + onClick={() => switchTab('TRACING')} + > {t('tracing', { ns: 'runLog' })} - </div> + </button> </div> {/* panel detail */} <div className={cn('h-0 grow overflow-y-auto rounded-b-2xl bg-components-panel-bg', currentTab !== 'DETAIL' && '!bg-background-section')}> diff --git a/web/app/components/base/agent-log-modal/index.tsx b/web/app/components/base/agent-log-modal/index.tsx index 0739d0774a..c6f036ede0 100644 --- a/web/app/components/base/agent-log-modal/index.tsx +++ b/web/app/components/base/agent-log-modal/index.tsx @@ -46,9 +46,14 @@ const AgentLogModal: FC<AgentLogModalProps> = ({ ref={ref} > <h1 className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary">{t('runDetail.workflowTitle', { ns: 'appLog' })}</h1> - <span className="absolute top-4 right-3 z-20 cursor-pointer p-1" onClick={onCancel}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </span> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onCancel} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <AgentLogDetail conversationID={currentLogItem.conversationId} messageID={currentLogItem.id} diff --git a/web/app/components/base/alert.tsx b/web/app/components/base/alert.tsx index 8dfe0e4ff5..4072e113d2 100644 --- a/web/app/components/base/alert.tsx +++ b/web/app/components/base/alert.tsx @@ -3,6 +3,7 @@ import { cva } from 'class-variance-authority' import { memo, } from 'react' +import { useTranslation } from 'react-i18next' type Props = { type?: 'info' @@ -26,6 +27,8 @@ const Alert: React.FC<Props> = ({ onHide, className, }) => { + const { t } = useTranslation() + return ( <div className={cn('pointer-events-none w-full', className)}> <div @@ -41,12 +44,14 @@ const Alert: React.FC<Props> = ({ {message} </div> </div> - <div - className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-components-button-secondary-accent-border" onClick={onHide} > - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-icon" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> </div> ) diff --git a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx index 7f452e64e9..589ad5554e 100644 --- a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx @@ -195,10 +195,10 @@ describe('AppIconPicker', () => { const { onSelect } = renderPicker() await waitFor(() => { - expect(screen.queryAllByTestId(/emoji-container-/i).length).toBeGreaterThan(0) + expect(document.querySelector('em-emoji')?.closest('button'))!.toBeInTheDocument() }) - const firstEmoji = screen.queryAllByTestId(/emoji-container-/i)[0] + const firstEmoji = document.querySelector('em-emoji')?.closest('button') if (!firstEmoji) throw new Error('Could not find emoji option') diff --git a/web/app/components/base/audio-btn/index.tsx b/web/app/components/base/audio-btn/index.tsx index 3ca453213d..999193deed 100644 --- a/web/app/components/base/audio-btn/index.tsx +++ b/web/app/components/base/audio-btn/index.tsx @@ -88,8 +88,9 @@ const AudioBtn = ({ <span className="inline-flex"> <button type="button" + aria-label={tooltipContent} disabled={audioState === 'loading'} - className={`box-border flex h-6 w-6 cursor-pointer items-center justify-center ${isAudition ? 'p-0.5' : 'rounded-md bg-white p-0'}`} + className={`box-border flex h-6 w-6 cursor-pointer items-center justify-center border-none bg-transparent ${isAudition ? 'p-0.5' : 'rounded-md bg-white p-0'}`} onClick={handleToggle} > {audioState === 'loading' diff --git a/web/app/components/base/audio-gallery/AudioPlayer.tsx b/web/app/components/base/audio-gallery/AudioPlayer.tsx index 1290fff871..cb7146a8c6 100644 --- a/web/app/components/base/audio-gallery/AudioPlayer.tsx +++ b/web/app/components/base/audio-gallery/AudioPlayer.tsx @@ -1,8 +1,8 @@ import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { t } from 'i18next' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' @@ -11,6 +11,7 @@ type AudioPlayerProps = { srcs?: string[] // Support multiple sources } const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { + const { t } = useTranslation() const [isPlaying, setIsPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) @@ -22,6 +23,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { const [hoverTime, setHoverTime] = useState(0) const [isAudioAvailable, setIsAudioAvailable] = useState(true) const { theme } = useTheme() + const playPauseLabel = t(isPlaying ? 'operation.pause' : 'operation.play', { ns: 'common' }) useEffect(() => { const audio = audioRef.current /* v8 ignore next 2 - @preserve */ @@ -245,10 +247,10 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { {/* If srcs array is provided, render multiple source elements */} {srcs && srcs.map((srcUrl, index) => (<source key={index} src={srcUrl} />))} </audio> - <button type="button" data-testid="play-pause-btn" className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}> + <button type="button" aria-label={playPauseLabel} className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}> {isPlaying - ? (<div className="i-ri-pause-circle-fill h-5 w-5" />) - : (<div className="i-ri-play-large-fill h-5 w-5" />)} + ? (<div className="i-ri-pause-circle-fill h-5 w-5" aria-hidden="true" />) + : (<div className="i-ri-play-large-fill h-5 w-5" aria-hidden="true" />)} </button> <div className={cn(isAudioAvailable && 'grow')} hidden={!isAudioAvailable}> <div className="flex h-8 items-center justify-center"> diff --git a/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx b/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx index f05c5d2582..3d27286c76 100644 --- a/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx +++ b/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx @@ -50,6 +50,9 @@ function getReactProps<T extends Element>(el: T): Record<string, ReactEventHandl return key ? (el as unknown as Record<string, Record<string, ReactEventHandler>>)[key]! : {} } +const getPlayButton = () => screen.getByRole('button', { name: 'common.operation.play' }) +const getPauseButton = () => screen.getByRole('button', { name: 'common.operation.pause' }) + // ─── Setup / teardown ───────────────────────────────────────────────────────── beforeEach(() => { @@ -77,7 +80,7 @@ describe('AudioPlayer — rendering', () => { it('should render the play button and audio element when given a src', () => { render(<AudioPlayer src="https://example.com/a.mp3" />) - expect(screen.getByTestId('play-pause-btn'))!.toBeInTheDocument() + expect(getPlayButton())!.toBeInTheDocument() expect(document.querySelector('audio'))!.toBeInTheDocument() expect(document.querySelector('audio')?.getAttribute('src')).toBe('https://example.com/a.mp3') }) @@ -93,7 +96,7 @@ describe('AudioPlayer — rendering', () => { it('should render without crashing when no props are supplied', () => { render(<AudioPlayer />) - expect(screen.getByTestId('play-pause-btn'))!.toBeInTheDocument() + expect(getPlayButton())!.toBeInTheDocument() }) }) @@ -102,7 +105,7 @@ describe('AudioPlayer — rendering', () => { describe('AudioPlayer — play/pause', () => { it('should call audio.play() on first button click', async () => { render(<AudioPlayer src="https://example.com/a.mp3" />) - const btn = screen.getByTestId('play-pause-btn') + const btn = getPlayButton() await act(async () => { fireEvent.click(btn) @@ -113,13 +116,13 @@ describe('AudioPlayer — play/pause', () => { it('should call audio.pause() on second button click', async () => { render(<AudioPlayer src="https://example.com/a.mp3" />) - const btn = screen.getByTestId('play-pause-btn') + const btn = getPlayButton() await act(async () => { fireEvent.click(btn) }) await act(async () => { - fireEvent.click(btn) + fireEvent.click(getPauseButton()) }) expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1) @@ -127,7 +130,7 @@ describe('AudioPlayer — play/pause', () => { it('should show the pause icon while playing and play icon while paused', async () => { render(<AudioPlayer src="https://example.com/a.mp3" />) - const btn = screen.getByTestId('play-pause-btn') + const btn = getPlayButton() expect(btn.querySelector('.i-ri-play-large-fill'))!.toBeInTheDocument() expect(btn.querySelector('.i-ri-pause-circle-fill')).not.toBeInTheDocument() @@ -136,23 +139,25 @@ describe('AudioPlayer — play/pause', () => { fireEvent.click(btn) }) - expect(btn.querySelector('.i-ri-pause-circle-fill'))!.toBeInTheDocument() - expect(btn.querySelector('.i-ri-play-large-fill')).not.toBeInTheDocument() + const pauseBtn = getPauseButton() + expect(pauseBtn.querySelector('.i-ri-pause-circle-fill'))!.toBeInTheDocument() + expect(pauseBtn.querySelector('.i-ri-play-large-fill')).not.toBeInTheDocument() }) it('should reset to stopped state when the audio ends', async () => { render(<AudioPlayer src="https://example.com/a.mp3" />) - const btn = screen.getByTestId('play-pause-btn') + const btn = getPlayButton() await act(async () => { fireEvent.click(btn) }) - expect(btn.querySelector('.i-ri-pause-circle-fill'))!.toBeInTheDocument() + expect(getPauseButton().querySelector('.i-ri-pause-circle-fill'))!.toBeInTheDocument() const audio = document.querySelector('audio') as HTMLAudioElement await act(async () => { audio.dispatchEvent(new Event('ended')) }) + expect(getPlayButton().querySelector('.i-ri-play-large-fill'))!.toBeInTheDocument() expect(btn.querySelector('.i-ri-play-large-fill'))!.toBeInTheDocument() }) @@ -165,7 +170,7 @@ describe('AudioPlayer — play/pause', () => { audio.dispatchEvent(new Event('error')) }) - expect(screen.getByTestId('play-pause-btn'))!.toBeDisabled() + expect(getPlayButton())!.toBeDisabled() }) }) @@ -216,7 +221,7 @@ describe('AudioPlayer — audio events', () => { audio.dispatchEvent(new Event('error')) }) - expect(screen.getByTestId('play-pause-btn'))!.toBeDisabled() + expect(getPlayButton())!.toBeDisabled() }) }) @@ -276,7 +281,7 @@ describe('AudioPlayer — waveform generation', () => { render(<AudioPlayer srcs={['blob:something']} />) await advanceWaveformTimer() - expect(screen.getByTestId('play-pause-btn'))!.toBeDisabled() + expect(getPlayButton())!.toBeDisabled() }) it('should not trigger waveform generation when no src or srcs provided', async () => { @@ -462,7 +467,7 @@ describe('AudioPlayer — missing coverage', () => { vi.spyOn(HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('play failed')) render(<AudioPlayer src="https://example.com/audio.mp3" />) - const btn = screen.getByTestId('play-pause-btn') + const btn = getPlayButton() await act(async () => { fireEvent.click(btn) @@ -530,7 +535,7 @@ describe('AudioPlayer — missing coverage', () => { render(<AudioPlayer src="blob:https://example.com" />) await advanceWaveformTimer() // sets isAudioAvailable to false (invalid protocol) - const btn = screen.getByTestId('play-pause-btn') + const btn = getPlayButton() await act(async () => { fireEvent.click(btn) }) @@ -549,7 +554,7 @@ describe('AudioPlayer — missing coverage', () => { audio.dispatchEvent(new Event('error')) }) - const btn = screen.getByTestId('play-pause-btn') + const btn = getPlayButton() const props = getReactProps(btn) await act(async () => { @@ -606,7 +611,7 @@ describe('AudioPlayer — additional branch coverage', () => { audio.dispatchEvent(new Event('error')) }) - expect(screen.queryByTestId('play-pause-btn'))!.toBeDisabled() + expect(getPlayButton())!.toBeDisabled() }) it('should update current time on timeupdate event', async () => { @@ -627,7 +632,7 @@ describe('AudioPlayer — additional branch coverage', () => { audio.dispatchEvent(new Event('error')) }) - const btn = screen.getByTestId('play-pause-btn') + const btn = getPlayButton() await act(async () => { fireEvent.click(btn) }) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index 83a8666e79..563adbd59e 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -1333,10 +1333,10 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) - fireEvent.click(await screen.findByTestId('edit-btn')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' })) const editedTextarea = await screen.findByDisplayValue('Original question') fireEvent.change(editedTextarea, { target: { value: 'Edited question text' } }) - fireEvent.click(screen.getByTestId('save-edit-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) await waitFor(() => { expect(handleSend).toHaveBeenCalledWith( diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx index a54533b194..084911c75a 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx @@ -184,7 +184,7 @@ describe('HeaderInMobile', () => { render(<HeaderInMobile />) // Open dropdown (More button) - fireEvent.click(await screen.findByTestId('mobile-more-btn')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.more' })) // Find and click "View Chat Settings" await waitFor(() => { @@ -213,7 +213,7 @@ describe('HeaderInMobile', () => { render(<HeaderInMobile />) // Open dropdown and chat settings - fireEvent.click(await screen.findByTestId('mobile-more-btn')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.more' })) await waitFor(() => { expect(screen.getByText(/share\.chat\.viewChatSettings/i))!.toBeInTheDocument() }) @@ -241,7 +241,7 @@ describe('HeaderInMobile', () => { render(<HeaderInMobile />) // Open dropdown - fireEvent.click(await screen.findByTestId('mobile-more-btn')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.more' })) // "View Chat Settings" should not be present await waitFor(() => { @@ -259,7 +259,7 @@ describe('HeaderInMobile', () => { render(<HeaderInMobile />) // Open dropdown - fireEvent.click(await screen.findByTestId('mobile-more-btn')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.more' })) // Click "New Conversation" or "Reset Chat" await waitFor(() => { diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx index 525f9b89a5..3a2cbfd4cb 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx @@ -19,7 +19,7 @@ describe('MobileOperationDropdown Component', () => { render(<MobileOperationDropdown {...defaultProps} />) // Trigger button should be present (ActionButton renders a button) - const trigger = screen.getByRole('button') + const trigger = screen.getByRole('button', { name: 'common.operation.more' }) expect(trigger).toBeInTheDocument() // Menu should be hidden initially @@ -39,7 +39,7 @@ describe('MobileOperationDropdown Component', () => { const user = userEvent.setup() render(<MobileOperationDropdown {...defaultProps} hideViewChatSettings={true} />) - await user.click(screen.getByRole('button')) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) expect(screen.getByText('share.chat.resetChat')).toBeInTheDocument() expect(screen.queryByText('share.chat.viewChatSettings')).not.toBeInTheDocument() @@ -49,7 +49,7 @@ describe('MobileOperationDropdown Component', () => { const user = userEvent.setup() render(<MobileOperationDropdown {...defaultProps} />) - await user.click(screen.getByRole('button')) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) // Reset Chat await user.click(screen.getByText('share.chat.resetChat')) @@ -57,7 +57,7 @@ describe('MobileOperationDropdown Component', () => { expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1) }) - await user.click(screen.getByRole('button')) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) // View Chat Settings await user.click(screen.getByText('share.chat.viewChatSettings')) await waitFor(() => { @@ -68,7 +68,7 @@ describe('MobileOperationDropdown Component', () => { it('applies hover state to ActionButton when open', async () => { const user = userEvent.setup() render(<MobileOperationDropdown {...defaultProps} />) - const trigger = screen.getByRole('button') + const trigger = screen.getByRole('button', { name: 'common.operation.more' }) // closed state expect(trigger).not.toHaveClass('action-btn-hover') @@ -82,7 +82,7 @@ describe('MobileOperationDropdown Component', () => { const user = userEvent.setup() render(<MobileOperationDropdown {...defaultProps} />) - await user.click(screen.getByRole('button')) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) await user.click(screen.getByText('share.chat.resetChat')) await waitFor(() => { diff --git a/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx b/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx index 97180704f8..29821e3c5a 100644 --- a/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx +++ b/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx @@ -32,13 +32,16 @@ const MobileOperationDropdown = ({ onOpenChange={setOpen} > <DropdownMenuTrigger - render={<div />} - data-testid="mobile-more-btn" - > - <ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}> - <div className="i-ri-more-fill h-[18px] w-[18px]" /> - </ActionButton> - </DropdownMenuTrigger> + render={( + <ActionButton + aria-label={t('operation.more', { ns: 'common' })} + size="l" + state={open ? ActionButtonState.Hover : ActionButtonState.Default} + > + <div className="i-ri-more-fill h-[18px] w-[18px]" aria-hidden="true" /> + </ActionButton> + )} + /> <DropdownMenuContent placement="bottom-end" sideOffset={4} diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/item.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/item.spec.tsx index b46bcc4607..afd4178d56 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/item.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/item.spec.tsx @@ -7,9 +7,9 @@ import Item from '../item' vi.mock('@/app/components/base/chat/chat-with-history/sidebar/operation', () => ({ default: ({ togglePin, onRenameConversation, onDelete, isItemHovering, isActive, isPinned }: { togglePin: () => void, onRenameConversation: () => void, onDelete: () => void, isItemHovering: boolean, isActive: boolean, isPinned: boolean }) => ( <div data-testid="mock-operation"> - <button onClick={togglePin} data-testid="pin-button">Pin</button> - <button onClick={onRenameConversation} data-testid="rename-button">Rename</button> - <button onClick={onDelete} data-testid="delete-button">Delete</button> + <button onClick={togglePin}>Pin</button> + <button onClick={onRenameConversation}>Rename</button> + <button onClick={onDelete}>Delete</button> <span data-hovering={isItemHovering} data-testid="hover-indicator">Hovering</span> <span data-active={isActive} data-testid="active-indicator">Active</span> <span data-pinned={isPinned} data-testid="pinned-indicator">Pinned</span> @@ -137,7 +137,7 @@ describe('Item', () => { const onOperate = vi.fn() render(<Item {...defaultProps} onOperate={onOperate} isPin={true} />) - await user.click(screen.getByTestId('pin-button')) + await user.click(screen.getByRole('button', { name: 'Pin' })) expect(onOperate).toHaveBeenCalledWith('unpin', mockItem) }) @@ -146,7 +146,7 @@ describe('Item', () => { const onOperate = vi.fn() render(<Item {...defaultProps} onOperate={onOperate} isPin={false} />) - await user.click(screen.getByTestId('pin-button')) + await user.click(screen.getByRole('button', { name: 'Pin' })) expect(onOperate).toHaveBeenCalledWith('pin', mockItem) }) @@ -155,7 +155,7 @@ describe('Item', () => { const onOperate = vi.fn() render(<Item {...defaultProps} onOperate={onOperate} />) - await user.click(screen.getByTestId('pin-button')) + await user.click(screen.getByRole('button', { name: 'Pin' })) expect(onOperate).toHaveBeenCalledWith('pin', mockItem) }) }) @@ -213,7 +213,7 @@ describe('Item', () => { const onChangeConversation = vi.fn() render(<Item {...defaultProps} onChangeConversation={onChangeConversation} />) - const deleteButton = screen.getByTestId('delete-button') + const deleteButton = screen.getByRole('button', { name: 'Delete' }) await user.click(deleteButton) // onChangeConversation should not be called when Operation button is clicked @@ -225,7 +225,7 @@ describe('Item', () => { const onOperate = vi.fn() render(<Item {...defaultProps} onOperate={onOperate} />) - await user.click(screen.getByTestId('delete-button')) + await user.click(screen.getByRole('button', { name: 'Delete' })) expect(onOperate).toHaveBeenCalledWith('delete', mockItem) }) @@ -234,7 +234,7 @@ describe('Item', () => { const onOperate = vi.fn() render(<Item {...defaultProps} onOperate={onOperate} />) - await user.click(screen.getByTestId('rename-button')) + await user.click(screen.getByRole('button', { name: 'Rename' })) expect(onOperate).toHaveBeenCalledWith('rename', mockItem) }) @@ -243,9 +243,9 @@ describe('Item', () => { const onOperate = vi.fn() render(<Item {...defaultProps} onOperate={onOperate} />) - await user.click(screen.getByTestId('rename-button')) - await user.click(screen.getByTestId('pin-button')) - await user.click(screen.getByTestId('delete-button')) + await user.click(screen.getByRole('button', { name: 'Rename' })) + await user.click(screen.getByRole('button', { name: 'Pin' })) + await user.click(screen.getByRole('button', { name: 'Delete' })) expect(onOperate).toHaveBeenCalledTimes(3) }) @@ -296,13 +296,13 @@ describe('Item', () => { const onOperate = vi.fn() render(<Item {...defaultProps} onOperate={onOperate} />) - await user.click(screen.getByTestId('rename-button')) + await user.click(screen.getByRole('button', { name: 'Rename' })) expect(onOperate).toHaveBeenNthCalledWith(1, 'rename', mockItem) - await user.click(screen.getByTestId('pin-button')) + await user.click(screen.getByRole('button', { name: 'Pin' })) expect(onOperate).toHaveBeenNthCalledWith(2, 'pin', mockItem) - await user.click(screen.getByTestId('delete-button')) + await user.click(screen.getByRole('button', { name: 'Delete' })) expect(onOperate).toHaveBeenNthCalledWith(3, 'delete', mockItem) }) @@ -314,12 +314,12 @@ describe('Item', () => { <Item {...defaultProps} onOperate={onOperate} isPin={false} />, ) - await user.click(screen.getByTestId('pin-button')) + await user.click(screen.getByRole('button', { name: 'Pin' })) expect(onOperate).toHaveBeenCalledWith('pin', mockItem) rerender(<Item {...defaultProps} onOperate={onOperate} isPin={true} />) - await user.click(screen.getByTestId('pin-button')) + await user.click(screen.getByRole('button', { name: 'Pin' })) expect(onOperate).toHaveBeenCalledWith('unpin', mockItem) }) }) @@ -412,7 +412,7 @@ describe('Item', () => { rerender(<Item {...defaultProps} onOperate={newOnOperate} />) - await user.click(screen.getByTestId('delete-button')) + await user.click(screen.getByRole('button', { name: 'Delete' })) expect(newOnOperate).toHaveBeenCalledWith('delete', mockItem) expect(oldOnOperate).not.toHaveBeenCalled() diff --git a/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx index 36e7cec67d..d888fbce98 100644 --- a/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx @@ -98,7 +98,7 @@ describe('ChatLogModals', () => { />, ) - await user.click(screen.getByTestId('close-btn-container')) + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(setCurrentLogItem).toHaveBeenCalled() expect(setShowPromptLogModal).toHaveBeenCalledWith(false) diff --git a/web/app/components/base/chat/chat/__tests__/question.spec.tsx b/web/app/components/base/chat/chat/__tests__/question.spec.tsx index df02cc1e03..9da528737a 100644 --- a/web/app/components/base/chat/chat/__tests__/question.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/question.spec.tsx @@ -183,7 +183,7 @@ describe('Question component', () => { renderWithProvider(makeItem()) - const copyBtn = screen.getByTestId('copy-btn') + const copyBtn = screen.getByRole('button', { name: 'common.operation.copy' }) await user.click(copyBtn) await waitFor(() => { @@ -195,8 +195,8 @@ describe('Question component', () => { it('should not show edit action when enableEdit is false', () => { renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { enableEdit: false }) - expect(screen.getByTestId('copy-btn')).toBeInTheDocument() - expect(screen.queryByTestId('edit-btn')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.copy' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument() }) it('should enter edit mode when edit action clicked, allow editing and call onRegenerate on resend', async () => { @@ -206,7 +206,7 @@ describe('Question component', () => { const item = makeItem() renderWithProvider(item, onRegenerate) - const editBtn = screen.getByTestId('edit-btn') + const editBtn = screen.getByRole('button', { name: 'common.operation.edit' }) await user.click(editBtn) const textbox = await screen.findByRole('textbox') @@ -227,14 +227,14 @@ describe('Question component', () => { const user = userEvent.setup() const { container } = renderWithProvider(makeItem()) - const editBtn = screen.getByTestId('edit-btn') + const editBtn = screen.getByRole('button', { name: 'common.operation.edit' }) await user.click(editBtn) const textbox = await screen.findByRole('textbox') await user.clear(textbox) await user.type(textbox, 'Edited question') - const cancelBtn = await screen.findByTestId('cancel-edit-btn') + const cancelBtn = await screen.findByRole('button', { name: 'common.operation.cancel' }) await user.click(cancelBtn) await waitFor(() => { @@ -250,7 +250,7 @@ describe('Question component', () => { renderWithProvider(makeItem(), onRegenerate) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') await user.clear(textbox) @@ -269,7 +269,7 @@ describe('Question component', () => { renderWithProvider(makeItem(), onRegenerate) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') await user.clear(textbox) @@ -285,7 +285,7 @@ describe('Question component', () => { renderWithProvider(makeItem(), onRegenerate) - fireEvent.click(screen.getByTestId('edit-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = screen.getByRole('textbox') fireEvent.compositionStart(textbox) @@ -302,7 +302,7 @@ describe('Question component', () => { const onRegenerate = vi.fn() as unknown as OnRegenerate renderWithProvider(makeItem(), onRegenerate) - fireEvent.click(screen.getByTestId('edit-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = screen.getByRole('textbox') fireEvent.change(textbox, { target: { value: 'IME guard text' } }) @@ -392,7 +392,7 @@ describe('Question component', () => { renderWithProvider(item, onRegenerate) - const editBtn = screen.getByTestId('edit-btn') + const editBtn = screen.getByRole('button', { name: 'common.operation.edit' }) await user.click(editBtn) const textbox = await screen.findByRole('textbox') @@ -431,7 +431,7 @@ describe('Question component', () => { renderWithProvider(item, onRegenerate) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') // Press Shift+Enter @@ -445,7 +445,7 @@ describe('Question component', () => { const onRegenerate = vi.fn() as unknown as OnRegenerate renderWithProvider(makeItem(), onRegenerate) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') // Create an event with nativeEvent.isComposing = true @@ -461,7 +461,7 @@ describe('Question component', () => { const onRegenerate = vi.fn() as unknown as OnRegenerate const { unmount } = renderWithProvider(makeItem(), onRegenerate) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') fireEvent.compositionStart(textbox) @@ -471,11 +471,11 @@ describe('Question component', () => { fireEvent.compositionStart(textbox) fireEvent.compositionEnd(textbox) - const cancelBtn = await screen.findByTestId('cancel-edit-btn') + const cancelBtn = await screen.findByRole('button', { name: 'common.operation.cancel' }) await user.click(cancelBtn) // Test unmount clearing timer - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox2 = await screen.findByRole('textbox') fireEvent.compositionStart(textbox2) fireEvent.compositionEnd(textbox2) @@ -489,13 +489,13 @@ describe('Question component', () => { const onRegenerate = vi.fn() as unknown as OnRegenerate renderWithProvider(makeItem(), onRegenerate) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') fireEvent.compositionStart(textbox) fireEvent.compositionEnd(textbox) // starts timer - const saveBtn = screen.getByTestId('save-edit-btn') + const saveBtn = screen.getByRole('button', { name: 'common.operation.save' }) await user.click(saveBtn) // handleResend clears timer expect(onRegenerate).toHaveBeenCalled() @@ -661,14 +661,14 @@ describe('Question component', () => { it('should hide edit button when enableEdit is explicitly true', () => { renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { enableEdit: true }) - expect(screen.getByTestId('edit-btn')).toBeInTheDocument() - expect(screen.getByTestId('copy-btn')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.edit' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.copy' })).toBeInTheDocument() }) it('should show copy button always regardless of enableEdit setting', () => { renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { enableEdit: false }) - expect(screen.getByTestId('copy-btn')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.copy' })).toBeInTheDocument() }) it('should not render content switch when no siblings exist', () => { @@ -687,7 +687,7 @@ describe('Question component', () => { const user = userEvent.setup() renderWithProvider(makeItem()) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') expect(textbox).toHaveValue('This is the question content') @@ -713,7 +713,7 @@ describe('Question component', () => { const { container } = renderWithProvider(makeItem({ message_files: files })) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) // FileList should be visible in edit mode with mb-3 margin expect(screen.getByText(/test.txt/i)).toBeInTheDocument() @@ -769,7 +769,7 @@ describe('Question component', () => { const onRegenerate = vi.fn() as unknown as OnRegenerate renderWithProvider(makeItem(), onRegenerate) - await userEvent.click(screen.getByTestId('edit-btn')) + await userEvent.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') // Rapid composition cycles @@ -792,7 +792,7 @@ describe('Question component', () => { const onRegenerate = vi.fn() as unknown as OnRegenerate renderWithProvider(makeItem(), onRegenerate) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') await user.clear(textbox) @@ -821,7 +821,7 @@ describe('Question component', () => { const item = makeItem({ message_files: files }) renderWithProvider(item, onRegenerate) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') await user.clear(textbox) @@ -842,24 +842,24 @@ describe('Question component', () => { renderWithProvider(makeItem()) // First edit cycle - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) let textbox = await screen.findByRole('textbox') fireEvent.compositionStart(textbox) fireEvent.compositionEnd(textbox) // Cancel and re-edit - let cancelBtn = await screen.findByTestId('cancel-edit-btn') + let cancelBtn = await screen.findByRole('button', { name: 'common.operation.cancel' }) await user.click(cancelBtn) // Second edit cycle - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) textbox = await screen.findByRole('textbox') expect(textbox).toHaveValue('This is the question content') fireEvent.compositionStart(textbox) fireEvent.compositionEnd(textbox) - cancelBtn = await screen.findByTestId('cancel-edit-btn') + cancelBtn = await screen.findByRole('button', { name: 'common.operation.cancel' }) await user.click(cancelBtn) expect(screen.queryByRole('textbox')).not.toBeInTheDocument() @@ -875,7 +875,7 @@ describe('Question component', () => { expect(contentContainer).toHaveClass('rounded-2xl') expect(contentContainer).toHaveClass('bg-background-gradient-bg-fill-chat-bubble-bg-3') - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) // Edit mode classes expect(contentContainer).toHaveClass('rounded-[24px]') @@ -918,8 +918,8 @@ describe('Question component', () => { </ChatContextProvider>, ) - await user.click(screen.getByTestId('edit-btn')) - await user.click(screen.getByTestId('save-edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) // Should not throw }) @@ -969,7 +969,7 @@ describe('Question component', () => { it('should clear timer on unmount when timer is active', async () => { const user = userEvent.setup() const { unmount } = renderWithProvider(makeItem()) - await user.click(screen.getByTestId('edit-btn')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) const textbox = await screen.findByRole('textbox') fireEvent.compositionStart(textbox) fireEvent.compositionEnd(textbox) // starts timer diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index 094c5c987b..816fd33341 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -219,13 +219,13 @@ describe('Operation', () => { it('should show copy and regenerate buttons', () => { renderOperation() - expect(screen.getByTestId('copy-btn'))!.toBeInTheDocument() - expect(screen.getByTestId('regenerate-btn'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'operation.copy' }))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'operation.regenerate' }))!.toBeInTheDocument() }) it('should hide regenerate button when noChatInput is true', () => { renderOperation({ ...baseProps, noChatInput: true }) - expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'operation.regenerate' })).not.toBeInTheDocument() }) it('should show TTS button when text_to_speech is enabled', () => { @@ -259,7 +259,7 @@ describe('Operation', () => { it('should copy content on copy click', async () => { const user = userEvent.setup() renderOperation() - await user.click(screen.getByTestId('copy-btn')) + await user.click(screen.getByRole('button', { name: 'operation.copy' })) expect(copy).toHaveBeenCalledWith('Hello world') }) @@ -274,7 +274,7 @@ describe('Operation', () => { ], } renderOperation({ ...baseProps, item }) - await user.click(screen.getByTestId('copy-btn')) + await user.click(screen.getByRole('button', { name: 'operation.copy' })) expect(copy).toHaveBeenCalledWith('Hello World') }) }) @@ -283,7 +283,7 @@ describe('Operation', () => { it('should call onRegenerate on regenerate click', async () => { const user = userEvent.setup() renderOperation() - await user.click(screen.getByTestId('regenerate-btn')) + await user.click(screen.getByRole('button', { name: 'operation.regenerate' })) expect(mockContextValue.onRegenerate).toHaveBeenCalledWith(baseItem) }) }) @@ -299,7 +299,7 @@ describe('Operation', () => { const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem renderOperation({ ...baseProps, item }) expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument() - expect(screen.queryByTestId('copy-btn')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'operation.copy' })).not.toBeInTheDocument() }) }) @@ -793,7 +793,7 @@ describe('Operation', () => { const user = userEvent.setup() const item: ChatItem = { ...baseItem, agent_thoughts: [] } renderOperation({ ...baseProps, item }) - await user.click(screen.getByTestId('copy-btn')) + await user.click(screen.getByRole('button', { name: 'operation.copy' })) expect(copy).toHaveBeenCalledWith('Hello world') }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx index 4b3f7b2445..fb6c1259c0 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx @@ -50,7 +50,7 @@ describe('HumanInputForm', () => { expect(contentItems[1])!.toHaveTextContent('{{#$output.field1#}}') expect(contentItems[2])!.toHaveTextContent('Part 2') - const buttons = screen.getAllByTestId('action-button') + const buttons = screen.getAllByRole('button').filter(button => button.textContent !== 'Update') expect(buttons).toHaveLength(4) expect(buttons[0])!.toHaveTextContent('Submit') expect(buttons[1])!.toHaveTextContent('Cancel') diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx index 0e7881d972..18d9d72376 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx @@ -49,7 +49,6 @@ const HumanInputForm = ({ disabled={isSubmitting} variant={getButtonStyle(action.button_style) as ButtonProps['variant']} onClick={() => submit(formToken, action.id, inputs)} - data-testid="action-button" > {action.title} </Button> diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index 6804271d64..265435acb0 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -328,13 +328,12 @@ function Operation({ copy(content) toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) }} - data-testid="copy-btn" > <span aria-hidden="true" className="i-ri-clipboard-line h-4 w-4" /> </ActionButton> )} {!noChatInput && ( - <ActionButton aria-label={regenerateLabel} onClick={() => onRegenerate?.(item)} data-testid="regenerate-btn"> + <ActionButton aria-label={regenerateLabel} onClick={() => onRegenerate?.(item)}> <span aria-hidden="true" className="i-ri-reset-left-line h-4 w-4" /> </ActionButton> )} diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx index f17f94ff9e..8896533f28 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx @@ -312,7 +312,7 @@ describe('ChatInputArea', () => { it('should render the send button', () => { render(<ChatInputArea visionConfig={mockVisionConfig} />) - expect(screen.getByTestId('send-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.send' })).toBeInTheDocument() }) }) @@ -334,7 +334,7 @@ describe('ChatInputArea', () => { const textarea = getTextarea()! await user.type(textarea, 'Hello world') - await user.click(screen.getByTestId('send-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) expect(onSend).toHaveBeenCalled() expect(textarea).toHaveValue('') @@ -448,14 +448,14 @@ describe('ChatInputArea', () => { describe('Voice Input', () => { it('should render the voice input button when enabled', () => { render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) - expect(screen.getByTestId('voice-input-button')).toBeTruthy() + expect(screen.getByRole('button', { name: 'common.voiceInput.start' })).toBeTruthy() }) it('should handle stop recording in VoiceInput', async () => { const user = userEvent.setup({ delay: null }) render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) - await user.click(screen.getByTestId('voice-input-button')) + await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' })) // Wait for VoiceInput to show speaking await screen.findByText(/voiceInput.speaking/i) const stopBtn = screen.getByTestId('voice-input-stop') @@ -473,7 +473,7 @@ describe('ChatInputArea', () => { const user = userEvent.setup({ delay: null }) render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) - await user.click(screen.getByTestId('voice-input-button')) + await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' })) await screen.findByText(/voiceInput.speaking/i) const stopBtn = screen.getByTestId('voice-input-stop') await user.click(stopBtn) @@ -493,7 +493,7 @@ describe('ChatInputArea', () => { render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) - await user.click(screen.getByTestId('voice-input-button')) + await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' })) // Permission denied should trigger error toast await waitFor(() => { @@ -513,7 +513,7 @@ describe('ChatInputArea', () => { render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) - await user.click(screen.getByTestId('voice-input-button')) + await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' })) await screen.findByText(/voiceInput.speaking/i) const stopBtn = screen.getByTestId('voice-input-stop') await user.click(stopBtn) @@ -532,7 +532,7 @@ describe('ChatInputArea', () => { const onSend = vi.fn() render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />) - await user.click(screen.getByTestId('send-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) expect(onSend).not.toHaveBeenCalled() expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' })) }) @@ -543,7 +543,7 @@ describe('ChatInputArea', () => { render(<ChatInputArea onSend={onSend} isResponding visionConfig={mockVisionConfig} />) await user.type(getTextarea()!, 'Hello') - await user.click(screen.getByTestId('send-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) expect(onSend).not.toHaveBeenCalled() expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' })) }) @@ -555,7 +555,7 @@ describe('ChatInputArea', () => { render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />) await user.type(getTextarea()!, 'Hello') - await user.click(screen.getByTestId('send-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) expect(onSend).not.toHaveBeenCalled() expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' })) @@ -569,7 +569,7 @@ describe('ChatInputArea', () => { render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />) await user.type(getTextarea()!, 'Hello') - await user.click(screen.getByTestId('send-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) expect(onSend).toHaveBeenCalledWith('Hello', [completedFile]) }) @@ -586,7 +586,7 @@ describe('ChatInputArea', () => { render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />) await user.type(getTextarea()!, 'Remote test') - await user.click(screen.getByTestId('send-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) expect(onSend).toHaveBeenCalledWith('Remote test', [remoteFile]) }) @@ -598,7 +598,7 @@ describe('ChatInputArea', () => { render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />) await user.type(getTextarea()!, 'Validation fail') - await user.click(screen.getByTestId('send-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) expect(onSend).not.toHaveBeenCalled() }) @@ -608,7 +608,7 @@ describe('ChatInputArea', () => { render(<ChatInputArea visionConfig={mockVisionConfig} />) await user.type(getTextarea()!, 'No onSend') - await user.click(screen.getByTestId('send-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) // Should not throw }) }) @@ -663,7 +663,7 @@ describe('ChatInputArea', () => { mockIsMultipleLine.value = true render(<ChatInputArea visionConfig={mockVisionConfig} />) // Send button should still be present - expect(screen.getByTestId('send-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.send' })).toBeInTheDocument() }) it('should handle drag enter event on textarea', () => { diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/operation.spec.tsx index bda994cd43..5a34a7a7ea 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/operation.spec.tsx @@ -58,7 +58,8 @@ describe('Operation', () => { />, ) - expect(screen.getAllByRole('button')).toHaveLength(2) + expect(screen.getByRole('button', { name: 'common.voiceInput.start' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.send' })).toBeInTheDocument() }) it('should not render voice input button when speechToTextConfig.enabled is false', () => { @@ -136,8 +137,7 @@ describe('Operation', () => { />, ) - const buttons = screen.getAllByRole('button') - const voiceButton = buttons[0] + const voiceButton = screen.getByRole('button', { name: 'common.voiceInput.start' }) await user.click(voiceButton!) @@ -157,8 +157,7 @@ describe('Operation', () => { />, ) - const buttons = screen.getAllByRole('button') - const voiceButton = buttons[0] + const voiceButton = screen.getByRole('button', { name: 'common.voiceInput.start' }) expect(voiceButton)!.toBeDisabled() diff --git a/web/app/components/base/chat/chat/chat-input-area/operation.tsx b/web/app/components/base/chat/chat/chat-input-area/operation.tsx index 05e55e038a..d09ea0da5d 100644 --- a/web/app/components/base/chat/chat/chat-input-area/operation.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/operation.tsx @@ -12,6 +12,7 @@ import { } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { memo } from 'react' +import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { FileUploaderInChatInput } from '@/app/components/base/file-uploader' @@ -33,6 +34,8 @@ const Operation: FC<OperationProps> = ({ onSend, theme, }) => { + const { t } = useTranslation() + return ( <div className={cn( @@ -49,20 +52,20 @@ const Operation: FC<OperationProps> = ({ speechToTextConfig?.enabled && ( <ActionButton size="l" + aria-label={t('voiceInput.start', { ns: 'common' })} disabled={readonly} onClick={onShowVoiceInput} - data-testid="voice-input-button" > - <RiMicLine className="h-5 w-5" /> + <RiMicLine className="h-5 w-5" aria-hidden="true" /> </ActionButton> ) } </div> <Button + aria-label={t('operation.send', { ns: 'common' })} className="ml-3 w-8 px-0" variant="primary" onClick={readonly ? noop : onSend} - data-testid="send-button" style={ theme ? { @@ -71,7 +74,7 @@ const Operation: FC<OperationProps> = ({ : {} } > - <RiSendPlane2Fill className="h-4 w-4" /> + <RiSendPlane2Fill className="h-4 w-4" aria-hidden="true" /> </Button> </div> </div> diff --git a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx index 1b8518e869..d861014305 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx @@ -61,6 +61,8 @@ const makeData = (overrides: Partial<Resources> = {}): Resources => ({ const openPopup = async (user: ReturnType<typeof userEvent.setup>) => { await user.click(screen.getByTestId('popup-trigger')) } +const getDownloadButton = (name = 'report.pdf') => screen.getByRole('button', { name }) +const queryDownloadButton = (name = 'report.pdf') => screen.queryByRole('button', { name }) describe('Popup', () => { beforeEach(() => { @@ -142,7 +144,7 @@ describe('Popup', () => { await openPopup(user) - expect(screen.getByTestId('popup-download-btn'))!.toBeInTheDocument() + expect(getDownloadButton()).toBeInTheDocument() }) it('should render download button in header for file dataSourceType with dataset_id', async () => { @@ -157,7 +159,7 @@ describe('Popup', () => { await openPopup(user) - expect(screen.getByTestId('popup-download-btn'))!.toBeInTheDocument() + expect(getDownloadButton()).toBeInTheDocument() }) it('should render plain document name in header (no button) for notion type', async () => { @@ -173,7 +175,7 @@ describe('Popup', () => { await openPopup(user) - expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument() + expect(queryDownloadButton('Notion Doc')).not.toBeInTheDocument() }) it('should render plain document name in header when dataset_id is absent', async () => { @@ -188,7 +190,7 @@ describe('Popup', () => { await openPopup(user) - expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument() + expect(queryDownloadButton()).not.toBeInTheDocument() }) it('should disable the download button while isDownloading is true', async () => { @@ -201,7 +203,7 @@ describe('Popup', () => { await openPopup(user) - expect(screen.getByTestId('popup-download-btn'))!.toBeDisabled() + expect(getDownloadButton()).toBeDisabled() }) }) @@ -457,7 +459,7 @@ describe('Popup', () => { render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />) await openPopup(user) - await user.click(screen.getByTestId('popup-download-btn')) + await user.click(getDownloadButton()) await waitFor(() => { expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'doc-1' }) @@ -471,7 +473,7 @@ describe('Popup', () => { render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />) await openPopup(user) - await user.click(screen.getByTestId('popup-download-btn')) + await user.click(getDownloadButton()) await waitFor(() => expect(mockDownloadDocument).toHaveBeenCalled()) expect(mockDownloadUrl).not.toHaveBeenCalled() @@ -489,7 +491,7 @@ describe('Popup', () => { await openPopup(user) - expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument() + expect(queryDownloadButton('Notion Doc')).not.toBeInTheDocument() expect(mockDownloadDocument).not.toHaveBeenCalled() }) @@ -502,7 +504,7 @@ describe('Popup', () => { render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />) await openPopup(user) - await user.click(screen.getByTestId('popup-download-btn')) + await user.click(getDownloadButton()) expect(mockDownloadDocument).not.toHaveBeenCalled() }) @@ -520,7 +522,7 @@ describe('Popup', () => { ) await openPopup(user) - await user.click(screen.getByTestId('popup-download-btn')) + await user.click(getDownloadButton()) await waitFor(() => { expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'primary-doc-id' }) @@ -539,7 +541,7 @@ describe('Popup', () => { ) await openPopup(user) - await user.click(screen.getByTestId('popup-download-btn')) + await user.click(getDownloadButton()) await waitFor(() => { expect(mockDownloadDocument).toHaveBeenCalled() @@ -559,7 +561,7 @@ describe('Popup', () => { ) await openPopup(user) - await user.click(screen.getByTestId('popup-download-btn')) + await user.click(getDownloadButton()) expect(mockDownloadDocument).not.toHaveBeenCalled() }) @@ -741,7 +743,7 @@ describe('Popup', () => { // we check the handler directly if possible, or just the button absence. // Even if the button is rendered (it shouldn't be based on line 71), // we check the handler directly if possible, or just the button absence. - expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument() + expect(queryDownloadButton()).not.toBeInTheDocument() }) it('should return early if both documentIds are missing', async () => { @@ -756,7 +758,7 @@ describe('Popup', () => { />, ) await openPopup(user) - const btn = screen.queryByTestId('popup-download-btn') + const btn = queryDownloadButton() if (btn) { await user.click(btn) expect(mockDownloadDocument).not.toHaveBeenCalled() @@ -774,7 +776,7 @@ describe('Popup', () => { />, ) await openPopup(user) - expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument() + expect(queryDownloadButton()).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx index 9ea4a6b742..5854fdceac 100644 --- a/web/app/components/base/chat/chat/citation/popup.tsx +++ b/web/app/components/base/chat/chat/citation/popup.tsx @@ -70,9 +70,8 @@ const Popup: FC<PopupProps> = ({ {(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id ? ( <button - data-testid="popup-download-btn" type="button" - className="cursor-pointer truncate text-text-tertiary hover:underline" + className="cursor-pointer truncate border-none bg-transparent p-0 text-left text-text-tertiary hover:underline" onClick={handleDownloadUploadFile} disabled={isDownloading} > diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx index 5bfd8a3000..e5393d1737 100644 --- a/web/app/components/base/chat/chat/question.tsx +++ b/web/app/components/base/chat/chat/question.tsx @@ -52,6 +52,8 @@ const Question: FC<QuestionProps> = ({ const { onRegenerate, } = useChatContext() + const copyLabel = t('operation.copy', { ns: 'common' }) + const editLabel = t('operation.edit', { ns: 'common' }) const [isEditing, setIsEditing] = useState(false) const [editedContent, setEditedContent] = useState(content) @@ -168,17 +170,17 @@ const Question: FC<QuestionProps> = ({ style={{ right: contentWidth + 8 }} > <ActionButton - data-testid="copy-btn" + aria-label={copyLabel} onClick={() => { copy(content) toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) }} > - <div className="i-ri-clipboard-line h-4 w-4" /> + <div className="i-ri-clipboard-line h-4 w-4" aria-hidden="true" /> </ActionButton> {enableEdit && ( - <ActionButton data-testid="edit-btn" onClick={handleEdit}> - <div className="i-ri-edit-line h-4 w-4" /> + <ActionButton aria-label={editLabel} onClick={handleEdit}> + <div className="i-ri-edit-line h-4 w-4" aria-hidden="true" /> </ActionButton> )} </div> @@ -222,8 +224,8 @@ const Question: FC<QuestionProps> = ({ /> </div> <div className="flex items-center justify-end gap-2"> - <Button className="min-w-24" onClick={handleCancelEditing} data-testid="cancel-edit-btn">{t('operation.cancel', { ns: 'common' })}</Button> - <Button className="min-w-24" variant="primary" onClick={handleResend} data-testid="save-edit-btn">{t('operation.save', { ns: 'common' })}</Button> + <Button className="min-w-24" onClick={handleCancelEditing}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button className="min-w-24" variant="primary" onClick={handleResend}>{t('operation.save', { ns: 'common' })}</Button> </div> </div> )} diff --git a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx index 3142bcd315..8b3c6bb3ca 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx @@ -153,7 +153,7 @@ describe('EmbeddedChatbot Header', () => { it('should render reset button when allowResetChat is true and conversation exists', () => { render(<Header title="Test Chatbot" allowResetChat={true} />) - expect(screen.getByTestId('reset-chat-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'share.chat.resetChat' })).toBeInTheDocument() }) it('should call onCreateNewChat when reset button is clicked', async () => { @@ -161,7 +161,7 @@ describe('EmbeddedChatbot Header', () => { const onCreateNewChat = vi.fn() render(<Header title="Test Chatbot" allowResetChat={true} onCreateNewChat={onCreateNewChat} />) - await user.click(screen.getByTestId('reset-chat-button')) + await user.click(screen.getByRole('button', { name: 'share.chat.resetChat' })) expect(onCreateNewChat).toHaveBeenCalled() }) @@ -218,7 +218,7 @@ describe('EmbeddedChatbot Header', () => { it('should render mobile reset button when allowed', () => { render(<Header title="Mobile Chatbot" isMobile allowResetChat />) - expect(screen.getByTestId('mobile-reset-chat-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'share.chat.resetChat' })).toBeInTheDocument() }) it('should NOT render mobile reset button when currentConversationId is missing', () => { @@ -228,7 +228,7 @@ describe('EmbeddedChatbot Header', () => { } as EmbeddedChatbotContextValue) render(<Header title="Mobile Chatbot" isMobile allowResetChat />) - expect(screen.queryByTestId('mobile-reset-chat-button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'share.chat.resetChat' })).not.toBeInTheDocument() }) it('should render ViewFormDropdown in mobile when conditions are met', () => { @@ -247,7 +247,7 @@ describe('EmbeddedChatbot Header', () => { await dispatchChatbotConfigMessage('https://parent.com', { isToggledByButton: true, isDraggable: false }) - const expandBtn = await screen.findByTestId('mobile-expand-button') + const expandBtn = await screen.findByRole('button', { name: 'share.chat.expand' }) expect(expandBtn).toBeInTheDocument() await user.click(expandBtn) @@ -276,7 +276,7 @@ describe('EmbeddedChatbot Header', () => { await dispatchChatbotConfigMessage('https://parent.com', { isToggledByButton: true, isDraggable: false }) - const expandBtn = await screen.findByTestId('expand-button') + const expandBtn = await screen.findByRole('button', { name: 'share.chat.expand' }) expect(expandBtn).toBeInTheDocument() await user.click(expandBtn) @@ -295,7 +295,7 @@ describe('EmbeddedChatbot Header', () => { await dispatchChatbotConfigMessage('https://parent.com', { isToggledByButton: true, isDraggable: true }) await waitFor(() => { - expect(screen.queryByTestId('expand-button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'share.chat.expand' })).not.toBeInTheDocument() }) }) @@ -305,12 +305,12 @@ describe('EmbeddedChatbot Header', () => { await dispatchChatbotConfigMessage('https://secure.com', { isToggledByButton: true, isDraggable: false }) - await screen.findByTestId('expand-button') + await screen.findByRole('button', { name: 'share.chat.expand' }) await dispatchChatbotConfigMessage('https://malicious.com', { isToggledByButton: false, isDraggable: false }) // Should still be visible (not hidden by the malicious message) - expect(screen.getByTestId('expand-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'share.chat.expand' })).toBeInTheDocument() }) it('should ignore non-config messages for origin locking', async () => { @@ -327,7 +327,7 @@ describe('EmbeddedChatbot Header', () => { await dispatchChatbotConfigMessage('https://second.com', { isToggledByButton: true, isDraggable: false }) // Should lock to second.com - const expandBtn = await screen.findByTestId('expand-button') + const expandBtn = await screen.findByRole('button', { name: 'share.chat.expand' }) expect(expandBtn).toBeInTheDocument() }) diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index 6d0cc9bc06..22c1e95bc8 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -114,11 +114,15 @@ const Header: FC<IHeaderProps> = ({ <Tooltip> <TooltipTrigger render={( - <ActionButton size="l" onClick={handleToggleExpand} data-testid="expand-button"> + <ActionButton + size="l" + aria-label={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })} + onClick={handleToggleExpand} + > { expanded - ? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" /> - : <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" /> + ? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" aria-hidden="true" /> + : <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" aria-hidden="true" /> } </ActionButton> )} @@ -133,8 +137,12 @@ const Header: FC<IHeaderProps> = ({ <Tooltip> <TooltipTrigger render={( - <ActionButton size="l" onClick={onCreateNewChat} data-testid="reset-chat-button"> - <div className="i-ri-reset-left-line h-[18px] w-[18px]" /> + <ActionButton + size="l" + aria-label={t('chat.resetChat', { ns: 'share' })} + onClick={onCreateNewChat} + > + <div className="i-ri-reset-left-line h-[18px] w-[18px]" aria-hidden="true" /> </ActionButton> )} /> @@ -171,11 +179,15 @@ const Header: FC<IHeaderProps> = ({ <Tooltip> <TooltipTrigger render={( - <ActionButton size="l" onClick={handleToggleExpand} data-testid="mobile-expand-button"> + <ActionButton + size="l" + aria-label={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })} + onClick={handleToggleExpand} + > { expanded - ? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} /> - : <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} /> + ? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} aria-hidden="true" /> + : <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} aria-hidden="true" /> } </ActionButton> )} @@ -190,8 +202,12 @@ const Header: FC<IHeaderProps> = ({ <Tooltip> <TooltipTrigger render={( - <ActionButton size="l" onClick={onCreateNewChat} data-testid="mobile-reset-chat-button"> - <div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} /> + <ActionButton + size="l" + aria-label={t('chat.resetChat', { ns: 'share' })} + onClick={onCreateNewChat} + > + <div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} aria-hidden="true" /> </ActionButton> )} /> diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx index 42cf7f8b21..e688ebe17c 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx @@ -56,19 +56,19 @@ describe('InputsFormNode', () => { render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />) expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument() expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument() - expect(screen.getByTestId('inputs-form-start-chat-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'share.chat.startChat' })).toBeInTheDocument() }) it('should render collapsed state correctly', () => { render(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />) expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument() expect(screen.queryByTestId('mock-inputs-form-content')).not.toBeInTheDocument() - expect(screen.getByTestId('inputs-form-edit-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.edit' })).toBeInTheDocument() }) it('should handle edit button click', async () => { render(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />) - await user.click(screen.getByTestId('inputs-form-edit-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) expect(setCollapsed).toHaveBeenCalledWith(false) }) @@ -78,7 +78,7 @@ describe('InputsFormNode', () => { currentConversationId: 'conv-123', } as unknown as any) render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />) - await user.click(screen.getByTestId('inputs-form-close-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(setCollapsed).toHaveBeenCalledWith(true) }) @@ -90,7 +90,7 @@ describe('InputsFormNode', () => { handleStartChat, } as unknown as any) render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />) - await user.click(screen.getByTestId('inputs-form-start-chat-button')) + await user.click(screen.getByRole('button', { name: 'share.chat.startChat' })) expect(handleStartChat).toHaveBeenCalled() expect(setCollapsed).toHaveBeenCalledWith(true) }) @@ -105,7 +105,7 @@ describe('InputsFormNode', () => { }, } as unknown as any) render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />) - const button = screen.getByTestId('inputs-form-start-chat-button') + const button = screen.getByRole('button', { name: 'share.chat.startChat' }) expect(button).toHaveStyle({ backgroundColor: '#ff0000' }) }) @@ -138,7 +138,7 @@ describe('InputsFormNode', () => { expect(screen.getByTestId('mock-inputs-form-content').parentElement).toHaveClass('p-4') // Start chat button container - expect(screen.getByTestId('inputs-form-start-chat-button').parentElement).toHaveClass('p-4') + expect(screen.getByRole('button', { name: 'share.chat.startChat' }).parentElement).toHaveClass('p-4') // Collapsed state mobile styles rerender(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />) diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx index 7cf9ee2968..1282126f7d 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx @@ -56,7 +56,6 @@ const InputsFormNode = ({ size="small" variant="ghost" onClick={() => setCollapsed(false)} - data-testid="inputs-form-edit-button" > {t('operation.edit', { ns: 'common' })} </Button> @@ -67,7 +66,6 @@ const InputsFormNode = ({ size="small" variant="ghost" onClick={() => setCollapsed(true)} - data-testid="inputs-form-close-button" > {t('operation.close', { ns: 'common' })} </Button> @@ -84,7 +82,6 @@ const InputsFormNode = ({ variant="primary" className="w-full" onClick={() => handleStartChat(() => setCollapsed(true))} - data-testid="inputs-form-start-chat-button" style={ themeBuilder?.theme ? { diff --git a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx index 83e873ab89..b2484031db 100644 --- a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx @@ -35,15 +35,15 @@ describe('CopyFeedback', () => { describe('User Interactions', () => { it('calls copy with content when clicked', () => { render(<CopyFeedback content="test content" />) - const button = screen.getByRole('button') - fireEvent.click(button.firstChild as Element) + const button = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }) + fireEvent.click(button) expect(mockCopy).toHaveBeenCalledWith('test content') }) it('does not reset on mouse leave (relies on hook timeout)', () => { render(<CopyFeedback content="test content" />) - const button = screen.getByRole('button') - fireEvent.mouseLeave(button.firstChild as Element) + const button = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }) + fireEvent.mouseLeave(button) expect(mockReset).not.toHaveBeenCalled() }) }) @@ -57,8 +57,8 @@ describe('CopyFeedbackNew', () => { describe('Rendering', () => { it('renders the component', () => { - const { container } = render(<CopyFeedbackNew content="test content" />) - expect(container.querySelector('.cursor-pointer')).toBeInTheDocument() + render(<CopyFeedbackNew content="test content" />) + expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })).toBeInTheDocument() }) it('renders with custom className', () => { @@ -82,16 +82,14 @@ describe('CopyFeedbackNew', () => { describe('User Interactions', () => { it('calls copy with content when clicked', () => { - const { container } = render(<CopyFeedbackNew content="test content" />) - const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement - fireEvent.click(clickableArea) + render(<CopyFeedbackNew content="test content" />) + fireEvent.click(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })) expect(mockCopy).toHaveBeenCalledWith('test content') }) it('does not reset on mouse leave (relies on hook timeout)', () => { - const { container } = render(<CopyFeedbackNew content="test content" />) - const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement - fireEvent.mouseLeave(clickableArea) + render(<CopyFeedbackNew content="test content" />) + fireEvent.mouseLeave(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })) expect(mockReset).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/base/copy-feedback/index.tsx b/web/app/components/base/copy-feedback/index.tsx index 860b88b245..027e34d665 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -38,11 +38,9 @@ const CopyFeedback = ({ content }: Props) => { <Tooltip> <TooltipTrigger render={( - <ActionButton> - <div onClick={handleCopy}> - {copied && <RiClipboardFill className="h-4 w-4" />} - {!copied && <RiClipboardLine className="h-4 w-4" />} - </div> + <ActionButton aria-label={safeText} onClick={handleCopy}> + {copied && <RiClipboardFill className="h-4 w-4" aria-hidden="true" />} + {!copied && <RiClipboardLine className="h-4 w-4" aria-hidden="true" />} </ActionButton> )} /> @@ -73,15 +71,17 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' <Tooltip> <TooltipTrigger render={( - <div - className={`h-8 w-8 cursor-pointer rounded-lg hover:bg-components-button-ghost-bg-hover ${className ?? ''}`} + <button + type="button" + aria-label={safeText} + className={`h-8 w-8 cursor-pointer rounded-lg border-none bg-transparent p-0 hover:bg-components-button-ghost-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden ${className ?? ''}`} + onClick={handleCopy} > <div - onClick={handleCopy} className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''}`} > </div> - </div> + </button> )} /> <TooltipContent> diff --git a/web/app/components/base/copy-icon/__tests__/index.spec.tsx b/web/app/components/base/copy-icon/__tests__/index.spec.tsx index 1ce9e6dbf5..28044a20d0 100644 --- a/web/app/components/base/copy-icon/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-icon/__tests__/index.spec.tsx @@ -21,29 +21,24 @@ describe('copy icon component', () => { it('renders normally', () => { render(<CopyIcon content="this is some test content for the copy icon component" />) - const icon = screen.getByTestId('copy-icon') - expect(icon).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })).toBeInTheDocument() }) it('shows copy check icon when copied', () => { copied = true render(<CopyIcon content="this is some test content for the copy icon component" />) - const icon = screen.getByTestId('copied-icon') - expect(icon).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copied' })).toBeInTheDocument() }) it('handles copy when clicked', () => { render(<CopyIcon content="this is some test content for the copy icon component" />) - const icon = screen.getByTestId('copy-icon') - fireEvent.click(icon as Element) + fireEvent.click(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })) expect(copy).toBeCalledTimes(1) }) it('resets on mouse leave', () => { render(<CopyIcon content="this is some test content for the copy icon component" />) - const icon = screen.getByTestId('copy-icon') - const div = icon?.parentElement as HTMLElement - fireEvent.mouseLeave(div) + fireEvent.mouseLeave(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })) expect(reset).toBeCalledTimes(1) }) }) diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx index a770430580..2cc714dae5 100644 --- a/web/app/components/base/copy-icon/index.tsx +++ b/web/app/components/base/copy-icon/index.tsx @@ -36,8 +36,8 @@ const CopyIcon = ({ content }: Props) => { onMouseLeave={reset} > {!copied - ? (<span aria-hidden className="i-custom-vender-line-files-copy h-3.5 w-3.5" data-testid="copy-icon" />) - : (<span aria-hidden className="i-custom-vender-line-files-copy-check h-3.5 w-3.5" data-testid="copied-icon" />)} + ? (<span aria-hidden className="i-custom-vender-line-files-copy h-3.5 w-3.5" />) + : (<span aria-hidden className="i-custom-vender-line-files-copy-check h-3.5 w-3.5" />)} </button> )} /> diff --git a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx index d37647f358..6163d70151 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx @@ -513,10 +513,7 @@ describe('DatePicker', () => { // Open year/month picker fireEvent.click(screen.getByText(/2024/)) - // The header in year/month view shows selected month/year with an up arrow - // Clicking it closes the year/month picker - const headerButtons = screen.getAllByRole('button') - fireEvent.click(headerButtons[0]!) // First button in year/month view is the header + fireEvent.click(screen.getByRole('button', { name: /time\.months\.June 2024/ })) // Should return to date view expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0) diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx index 60888a6ec4..454ac1ad9c 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx @@ -228,7 +228,14 @@ const DatePicker = ({ placeholder={placeholderDate} /> <span className={cn('i-ri-calendar-line h-4 w-4 shrink-0 text-text-quaternary', isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', (displayValue || (isOpen && selectedDate)) && 'group-hover:hidden')} /> - <span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary')} onClick={handleClear} data-testid="date-picker-clear-button" /> + <button + type="button" + aria-label={t('operation.clear', { ns: 'common' })} + className={cn('hidden h-4 w-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block')} + onClick={handleClear} + > + <span className="i-ri-close-circle-fill h-4 w-4" aria-hidden="true" /> + </button> </div> )} /> diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx index 34ec21f0a5..f0aa55a316 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -227,7 +227,14 @@ const TimePicker = ({ <TimezoneLabel timezone={timezone} inline className="shrink-0 text-xs select-none" /> )} <span className={cn('i-ri-time-line h-4 w-4 shrink-0 text-text-quaternary', isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:hidden')} /> - <span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block hover:text-text-secondary')} role="button" aria-label={t('operation.clear', { ns: 'common' })} onClick={handleClear} /> + <button + type="button" + className={cn('hidden h-4 w-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block')} + aria-label={t('operation.clear', { ns: 'common' })} + onClick={handleClear} + > + <span className="i-ri-close-circle-fill h-4 w-4" aria-hidden="true" /> + </button> </div> )} /> diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx index 36a98f7dd1..f05221989d 100644 --- a/web/app/components/base/emoji-picker/Inner.tsx +++ b/web/app/components/base/emoji-picker/Inner.tsx @@ -57,6 +57,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ const [searchedEmojis, setSearchedEmojis] = useState<string[]>([]) const [isSearching, setIsSearching] = useState(false) + const styleColorsLabelId = React.useId() React.useEffect(() => { if (selectedEmoji) { @@ -101,18 +102,20 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ <div className="grid h-full w-full grid-cols-8 gap-1"> {searchedEmojis.map((emoji: string, index: number) => { return ( - <div + <button + type="button" key={`emoji-search-${index}`} - className="inline-flex h-10 w-10 items-center justify-center rounded-lg" + aria-label={emoji} + className="inline-flex h-10 w-10 items-center justify-center rounded-lg border-none bg-transparent p-0" onClick={() => { setSelectedEmoji(emoji) setShowStyleColors(true) }} > - <div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1" data-testid={`emoji-search-result-${emoji}`}> + <span className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1"> <em-emoji id={emoji} /> - </div> - </div> + </span> + </button> ) })} </div> @@ -127,18 +130,20 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ <div className="grid h-full w-full grid-cols-8 gap-1"> {category.emojis.map((emoji, index: number) => { return ( - <div + <button + type="button" key={`emoji-${index}`} - className="inline-flex h-10 w-10 items-center justify-center rounded-lg" + aria-label={emoji} + className="inline-flex h-10 w-10 items-center justify-center rounded-lg border-none bg-transparent p-0" onClick={() => { setSelectedEmoji(emoji) setShowStyleColors(true) }} > - <div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1" data-testid={`emoji-container-${emoji}`}> + <span className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1"> <em-emoji id={emoji} /> - </div> - </div> + </span> + </button> ) })} @@ -150,21 +155,40 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ {/* Color Select */} <div className={cn('flex items-center justify-between p-3 pb-0')}> - <p className="mb-2 system-xs-medium-uppercase text-text-primary">Choose Style</p> + <p id={styleColorsLabelId} className="mb-2 system-xs-medium-uppercase text-text-primary">Choose Style</p> {showStyleColors - ? <span className="i-heroicons-chevron-down h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" /> - : <span className="i-heroicons-chevron-up h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" />} + ? ( + <button + type="button" + aria-labelledby={styleColorsLabelId} + aria-expanded="true" + className="i-heroicons-chevron-down h-4 w-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary" + onClick={() => setShowStyleColors(!showStyleColors)} + /> + ) + : ( + <button + type="button" + aria-labelledby={styleColorsLabelId} + aria-expanded="false" + className="i-heroicons-chevron-up h-4 w-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary" + onClick={() => setShowStyleColors(!showStyleColors)} + /> + )} </div> {showStyleColors && ( <div className="grid w-full grid-cols-8 gap-1 px-3"> {backgroundColors.map((color) => { return ( - <div + <button + type="button" key={color} + aria-label={color} className={ cn( 'cursor-pointer', - 'ring-offset-1 hover:ring-1', + 'border-none bg-transparent p-0', + 'ring-components-input-border-hover ring-offset-1 hover:ring-1', 'inline-flex h-10 w-10 items-center justify-center rounded-lg', color === selectedBackground ? 'ring-1 ring-components-input-border-hover' : '', ) @@ -173,15 +197,15 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ setSelectedBackground(color) }} > - <div + <span className={cn( 'flex h-8 w-8 items-center justify-center rounded-lg p-1', )} style={{ background: color }} > {selectedEmoji !== '' && <em-emoji id={selectedEmoji} />} - </div> - </div> + </span> + </button> ) })} </div> diff --git a/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx b/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx index 41683d7af3..c2f4bce55c 100644 --- a/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx +++ b/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx @@ -75,10 +75,10 @@ describe('EmojiPickerInner', () => { it('updates selected emoji and calls onSelect when an emoji is clicked', async () => { render(<EmojiPickerInner onSelect={mockOnSelect} />) - const emojiContainers = screen.getAllByTestId(/^emoji-container-/) + const emojiButton = screen.getByRole('button', { name: 'rabbit' }) await act(async () => { - fireEvent.click(emojiContainers[0]!) + fireEvent.click(emojiButton) }) expect(mockOnSelect).toHaveBeenCalledWith('rabbit', expect.any(String)) @@ -89,7 +89,7 @@ describe('EmojiPickerInner', () => { expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument() - const toggleButton = screen.getByTestId('toggle-colors') + const toggleButton = screen.getByRole('button', { name: 'Choose Style' }) expect(toggleButton)!.toBeInTheDocument() await act(async () => { @@ -104,21 +104,21 @@ describe('EmojiPickerInner', () => { it('updates background color and calls onSelect when a color is clicked', async () => { render(<EmojiPickerInner onSelect={mockOnSelect} />) - const toggleButton = screen.getByTestId('toggle-colors') + const toggleButton = screen.getByRole('button', { name: 'Choose Style' }) await act(async () => { fireEvent.click(toggleButton!) }) - const emojiContainers = screen.getAllByTestId(/^emoji-container-/) + const emojiButton = screen.getByRole('button', { name: 'rabbit' }) await act(async () => { - fireEvent.click(emojiContainers[0]!) + fireEvent.click(emojiButton) }) mockOnSelect.mockClear() - const colorOptions = document.querySelectorAll('[style^="background:"]') + const colorOptions = screen.getAllByRole('button', { name: /^#/ }) await act(async () => { - fireEvent.click(colorOptions[1]!.parentElement!) + fireEvent.click(colorOptions[1]!) }) expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC') @@ -134,9 +134,9 @@ describe('EmojiPickerInner', () => { await screen.findByText('Search') - const searchEmojis = screen.getAllByTestId(/^emoji-search-result-/) + const searchEmoji = screen.getByRole('button', { name: 'dog' }) await act(async () => { - fireEvent.click(searchEmojis![0]!) + fireEvent.click(searchEmoji) }) expect(mockOnSelect).toHaveBeenCalledWith('dog', expect.any(String)) @@ -145,7 +145,7 @@ describe('EmojiPickerInner', () => { it('toggles style colors display back and forth', async () => { render(<EmojiPickerInner onSelect={mockOnSelect} />) - const toggleButton = screen.getByTestId('toggle-colors') + const toggleButton = screen.getByRole('button', { name: 'Choose Style' }) await act(async () => { fireEvent.click(toggleButton!) @@ -153,7 +153,7 @@ describe('EmojiPickerInner', () => { expect(screen.getByText('Choose Style'))!.toBeInTheDocument() await act(async () => { - fireEvent.click(screen.getByTestId('toggle-colors')!) // It should be the other icon now + fireEvent.click(screen.getByRole('button', { name: 'Choose Style' })) }) expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument() }) diff --git a/web/app/components/base/emoji-picker/__tests__/index.spec.tsx b/web/app/components/base/emoji-picker/__tests__/index.spec.tsx index 36b161b7c7..ed5f9bed75 100644 --- a/web/app/components/base/emoji-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/emoji-picker/__tests__/index.spec.tsx @@ -81,10 +81,8 @@ describe('EmojiPicker', () => { ) }) - const emojiWrappers = screen.getAllByTestId(/^emoji-container-/) - expect(emojiWrappers.length).toBeGreaterThan(0) await act(async () => { - fireEvent.click(emojiWrappers[0]!) + fireEvent.click(screen.getByRole('button', { name: 'emoji1' })) }) const okButton = screen.getByText(/OK/i) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx index d8cdc56849..56551be922 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx @@ -146,7 +146,7 @@ describe('OpeningSettingModal', () => { />, ) - const closeButton = screen.getByTestId('close-modal') + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) await userEvent.click(closeButton) expect(onCancel).toHaveBeenCalled() @@ -162,7 +162,7 @@ describe('OpeningSettingModal', () => { />, ) - const closeButton = screen.getByTestId('close-modal') + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() await userEvent.keyboard('{Enter}') @@ -179,9 +179,9 @@ describe('OpeningSettingModal', () => { />, ) - const closeButton = screen.getByTestId('close-modal') + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() - fireEvent.keyDown(closeButton, { key: ' ' }) + await userEvent.keyboard(' ') expect(onCancel).toHaveBeenCalledTimes(1) }) @@ -196,7 +196,7 @@ describe('OpeningSettingModal', () => { />, ) - const closeButton = screen.getByTestId('close-modal') + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() fireEvent.keyDown(closeButton, { key: 'Escape' }) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index f69b507115..78798d27cf 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -227,21 +227,14 @@ const OpeningSettingModal = ({ <DialogContent className="mt-14 w-[640px] max-w-none rounded-2xl bg-components-panel-bg-blur p-6"> <div className="mb-6 flex items-center justify-between"> <div className="title-2xl-semi-bold text-text-primary">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div> - <div - className="cursor-pointer p-1" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onCancel} - data-testid="close-modal" - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onCancel() - } - }} > - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> <div className="mb-8 space-y-4"> <div diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx index 4b26c411e3..92841a2e04 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx @@ -120,7 +120,7 @@ describe('SettingContent', () => { const onClose = vi.fn() renderWithProvider({ onClose }) - const closeIconButton = screen.getByTestId('close-setting-modal') + const closeIconButton = screen.getByRole('button', { name: 'common.operation.close' }) expect(closeIconButton).toBeInTheDocument() if (!closeIconButton) throw new Error('Close icon button should exist') @@ -134,7 +134,7 @@ describe('SettingContent', () => { const onClose = vi.fn() renderWithProvider({ onClose }) - const closeIconButton = screen.getByTestId('close-setting-modal') + const closeIconButton = screen.getByRole('button', { name: 'common.operation.close' }) closeIconButton.focus() await userEvent.keyboard('{Enter}') expect(onClose).toHaveBeenCalledTimes(1) @@ -144,9 +144,9 @@ describe('SettingContent', () => { const onClose = vi.fn() renderWithProvider({ onClose }) - const closeIconButton = screen.getByTestId('close-setting-modal') + const closeIconButton = screen.getByRole('button', { name: 'common.operation.close' }) closeIconButton.focus() - fireEvent.keyDown(closeIconButton, { key: ' ' }) + await userEvent.keyboard(' ') expect(onClose).toHaveBeenCalledTimes(1) }) @@ -154,7 +154,7 @@ describe('SettingContent', () => { const onClose = vi.fn() renderWithProvider({ onClose }) - const closeIconButton = screen.getByTestId('close-setting-modal') + const closeIconButton = screen.getByRole('button', { name: 'common.operation.close' }) closeIconButton.focus() fireEvent.keyDown(closeIconButton, { key: 'Escape' }) diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx index 393b66ba19..10d83f3a8f 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx @@ -58,21 +58,14 @@ const SettingContent = ({ <> <div className="mb-4 flex items-center justify-between"> <div className="system-xl-semibold text-text-primary">{!imageUpload ? t('feature.fileUpload.modalTitle', { ns: 'appDebug' }) : t('feature.imageUpload.modalTitle', { ns: 'appDebug' })}</div> - <div - className="cursor-pointer p-1" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onClose} - data-testid="close-setting-modal" - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onClose() - } - }} > - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> <FileUploadSetting isMultiple diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx index da4c658080..a42766c349 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx @@ -1,5 +1,6 @@ import type { ModerationConfig } from '@/models/debug' import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as i18n from 'react-i18next' import ModerationSettingModal from '../moderation-setting-modal' @@ -181,10 +182,10 @@ describe('ModerationSettingModal', () => { />, ) - const closeButton = document.querySelector('div[role="button"][tabindex="0"]') as HTMLElement - expect(closeButton)!.toBeInTheDocument() + const user = userEvent.setup() + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() - fireEvent.keyDown(closeButton, { key: 'Enter' }) + await user.keyboard('{Enter}') expect(onCancel).toHaveBeenCalledTimes(1) }) @@ -199,10 +200,10 @@ describe('ModerationSettingModal', () => { />, ) - const closeButton = document.querySelector('div[role="button"][tabindex="0"]') as HTMLElement - expect(closeButton)!.toBeInTheDocument() + const user = userEvent.setup() + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() - fireEvent.keyDown(closeButton, { key: ' ' }) + await user.keyboard(' ') expect(onCancel).toHaveBeenCalledTimes(1) }) @@ -217,8 +218,7 @@ describe('ModerationSettingModal', () => { />, ) - const closeButton = document.querySelector('div[role="button"][tabindex="0"]') as HTMLElement - expect(closeButton)!.toBeInTheDocument() + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() fireEvent.keyDown(closeButton, { key: 'Escape' }) diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index 7522c73445..7937f05bf4 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -230,20 +230,14 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ <div className="flex items-center justify-between"> <div className="title-2xl-semi-bold text-text-primary">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div> - <div - role="button" - tabIndex={0} - className="cursor-pointer p-1" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onCancel} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onCancel() - } - }} > - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> <div className="py-2"> <div className="text-sm leading-9 font-medium text-text-primary"> diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx index 27a6cd96d0..06ed515efe 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx @@ -131,7 +131,7 @@ describe('ParamConfigContent', () => { it('should render audition button when language has example', () => { renderWithProvider() - const auditionButton = screen.queryByTestId('audition-button') + const auditionButton = screen.queryByRole('group', { name: /appApi\.play|play/i }) expect(auditionButton)!.toBeInTheDocument() }) @@ -143,7 +143,7 @@ describe('ParamConfigContent', () => { renderWithProvider() - const auditionButton = screen.queryByTestId('audition-button') + const auditionButton = screen.queryByRole('group', { name: /appApi\.play|play/i }) expect(auditionButton).toBeNull() }) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index 24670fa748..5f3c895054 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -79,7 +79,7 @@ const VoiceParamConfig = ({ <div className="system-xl-semibold text-text-primary">{t('voice.voiceSettings.title', { ns: 'appDebug' })}</div> <button type="button" - className="rounded-md p-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden" + className="rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden" aria-label={t('appDebug:voice.voiceSettings.close')} onClick={onClose} > @@ -158,7 +158,11 @@ const VoiceParamConfig = ({ </div> </Select> {languageItem?.example && ( - <div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1" data-testid="audition-button"> + <div + className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1" + role="group" + aria-label={t('play', { ns: 'appApi', defaultValue: 'Play' })} + > <AudioBtn value={languageItem?.example} isAudition diff --git a/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx index 7029bc8e0a..9313efa071 100644 --- a/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx @@ -25,16 +25,14 @@ describe('AudioPreview', () => { it('should render close button with icon', () => { render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />) - const closeIcon = screen.getByTestId('close-btn') - expect(closeIcon).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) it('should call onCancel when close button is clicked', () => { const onCancel = vi.fn() render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />) - const closeIcon = screen.getByTestId('close-btn') - fireEvent.click(closeIcon.parentElement!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(onCancel).toHaveBeenCalled() }) diff --git a/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx index 0430bd37f9..fc6a9d6f79 100644 --- a/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx @@ -23,18 +23,16 @@ describe('VideoPreview', () => { }) it('should render close button with icon', () => { - const { getByTestId } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />) + render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />) - const closeIcon = getByTestId('video-preview-close-btn') - expect(closeIcon).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) it('should call onCancel when close button is clicked', () => { const onCancel = vi.fn() - const { getByTestId } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />) + render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />) - const closeIcon = getByTestId('video-preview-close-btn') - fireEvent.click(closeIcon.parentElement!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(onCancel).toHaveBeenCalled() }) diff --git a/web/app/components/base/file-uploader/audio-preview.tsx b/web/app/components/base/file-uploader/audio-preview.tsx index 6d38fad169..c60500bef9 100644 --- a/web/app/components/base/file-uploader/audio-preview.tsx +++ b/web/app/components/base/file-uploader/audio-preview.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { useTranslation } from 'react-i18next' type AudioPreviewProps = { url: string @@ -11,6 +12,8 @@ const AudioPreview: FC<AudioPreviewProps> = ({ title, onCancel, }) => { + const { t } = useTranslation() + return ( <Dialog open @@ -37,12 +40,14 @@ const AudioPreview: FC<AudioPreviewProps> = ({ /> </audio> </div> - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-white/[0.08] p-0 backdrop-blur-[2px]" onClick={onCancel} > - <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-btn" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-gray-500" aria-hidden="true" /> + </button> </DialogContent> </Dialog> ) diff --git a/web/app/components/base/file-uploader/file-list-in-log.tsx b/web/app/components/base/file-uploader/file-list-in-log.tsx index 9edd1ffed4..c8cb76b8cf 100644 --- a/web/app/components/base/file-uploader/file-list-in-log.tsx +++ b/web/app/components/base/file-uploader/file-list-in-log.tsx @@ -39,7 +39,13 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi <div className={cn('px-3 py-2', expanded && 'py-3', !noBorder && 'border-t border-divider-subtle', noPadding && 'p-0!')}> <div className="flex justify-between gap-1"> {expanded && ( - <div className="grow cursor-pointer py-1 system-xs-semibold-uppercase text-text-secondary" onClick={() => setExpanded(!expanded)}>{t('runDetail.fileListLabel', { ns: 'appLog' })}</div> + <button + type="button" + className="grow cursor-pointer border-none bg-transparent px-0 py-1 text-left system-xs-semibold-uppercase text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => setExpanded(!expanded)} + > + {t('runDetail.fileListLabel', { ns: 'appLog' })} + </button> )} {!expanded && ( <div className="flex gap-1"> @@ -87,10 +93,15 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi })} </div> )} - <div className="flex cursor-pointer items-center gap-1" onClick={() => setExpanded(!expanded)}> + <button + type="button" + aria-label={t('runDetail.fileListDetail', { ns: 'appLog' })} + className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => setExpanded(!expanded)} + > {!expanded && <div className="system-xs-medium-uppercase text-text-tertiary">{t('runDetail.fileListDetail', { ns: 'appLog' })}</div>} - <RiArrowRightSLine className={cn('h-4 w-4 text-text-tertiary', expanded && 'rotate-90')} /> - </div> + <RiArrowRightSLine className={cn('h-4 w-4 text-text-tertiary', expanded && 'rotate-90')} aria-hidden="true" /> + </button> </div> {expanded && ( <div className="flex flex-col gap-3"> diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx index 183313cd91..83ab265084 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx @@ -191,8 +191,8 @@ describe('FileInAttachmentItem', () => { expect(previewContainer)!.toBeInTheDocument() // Close button is the last clickable div with an SVG in the preview container - const closeIcon = screen.getByTestId('image-preview-close-button') - fireEvent.click(closeIcon.parentElement!) + const closeIcon = screen.getByRole('button', { name: 'common.operation.cancel' }) + fireEvent.click(closeIcon) // Preview should be removed // Preview should be removed diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx index c84d9e3d69..983bdf9393 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx @@ -44,16 +44,14 @@ describe('FileImageItem', () => { it('should render delete button when showDeleteAction is true', () => { render(<FileImageItem file={createFile()} showDeleteAction />) - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'common.operation.remove' })).toBeInTheDocument() }) it('should call onRemove when delete button is clicked', () => { const onRemove = vi.fn() render(<FileImageItem file={createFile()} showDeleteAction onRemove={onRemove} />) - const buttons = screen.getAllByRole('button') - fireEvent.click(buttons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) expect(onRemove).toHaveBeenCalledWith('file-1') }) @@ -69,21 +67,18 @@ describe('FileImageItem', () => { }) it('should render replay icon when upload failed', () => { - const { container } = render(<FileImageItem file={createFile({ progress: -1 })} />) + render(<FileImageItem file={createFile({ progress: -1 })} />) - // ReplayLine renders as an SVG icon with data-icon attribute - const replaySvg = container.querySelector('svg[data-icon="ReplayLine"]') - expect(replaySvg)!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument() }) it('should call onReUpload when replay icon is clicked', () => { const onReUpload = vi.fn() - const { container } = render( + render( <FileImageItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />, ) - const replaySvg = container.querySelector('svg[data-icon="ReplayLine"]') - fireEvent.click(replaySvg!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' })) expect(onReUpload).toHaveBeenCalledWith('file-1') }) @@ -118,30 +113,23 @@ describe('FileImageItem', () => { expect(previewContainer)!.toBeInTheDocument() // Close button is the last clickable div with an SVG in the preview container - const closeIcon = screen.getByTestId('image-preview-close-button') - fireEvent.click(closeIcon.parentElement!) + const closeIcon = screen.getByRole('button', { name: 'common.operation.cancel' }) + fireEvent.click(closeIcon) expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument() }) it('should render download overlay when showDownloadAction is true', () => { - const { container } = render(<FileImageItem file={createFile()} showDownloadAction />) + render(<FileImageItem file={createFile()} showDownloadAction />) - // The download icon SVG should be present - const svgs = container.querySelectorAll('svg') - expect(svgs.length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'common.operation.download' })).toBeInTheDocument() }) it('should call downloadUrl when download button is clicked', async () => { const { downloadUrl } = await import('@/utils/download') - const { container } = render(<FileImageItem file={createFile()} showDownloadAction />) + render(<FileImageItem file={createFile()} showDownloadAction />) - // Find the RiDownloadLine SVG (it doesn't have data-icon attribute, unlike ReplayLine) - const svgs = container.querySelectorAll('svg') - const downloadSvg = Array.from(svgs).find( - svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'), - ) - fireEvent.click(downloadSvg!.parentElement!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.download' })) expect(downloadUrl).toHaveBeenCalled() }) @@ -169,15 +157,9 @@ describe('FileImageItem', () => { it('should use url with attachment param for download_url when url is available', async () => { const { downloadUrl } = await import('@/utils/download') const file = createFile({ url: 'https://example.com/photo.png' }) - const { container } = render(<FileImageItem file={file} showDownloadAction />) + render(<FileImageItem file={file} showDownloadAction />) - // The download SVG should be rendered - const svgs = container.querySelectorAll('svg') - expect(svgs.length).toBeGreaterThanOrEqual(1) - const downloadSvg = Array.from(svgs).find( - svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'), - ) - fireEvent.click(downloadSvg!.parentElement!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.download' })) expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ url: expect.stringContaining('as_attachment=true'), })) @@ -186,13 +168,9 @@ describe('FileImageItem', () => { it('should use base64Url for download_url when url is not available', async () => { const { downloadUrl } = await import('@/utils/download') const file = createFile({ url: undefined, base64Url: 'data:image/png;base64,abc' }) - const { container } = render(<FileImageItem file={file} showDownloadAction />) + render(<FileImageItem file={file} showDownloadAction />) - const svgs = container.querySelectorAll('svg') - const downloadSvg = Array.from(svgs).find( - svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'), - ) - fireEvent.click(downloadSvg!.parentElement!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.download' })) expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ url: 'data:image/png;base64,abc', @@ -223,37 +201,6 @@ describe('FileImageItem', () => { const img = screen.getByRole('img') fireEvent.click(img.parentElement!) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) - // Preview won't show because imagePreviewUrl is empty string (falsy) // Preview won't show because imagePreviewUrl is empty string (falsy) expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument() }) @@ -261,13 +208,9 @@ describe('FileImageItem', () => { it('should call downloadUrl with correct params when download button is clicked', async () => { const { downloadUrl } = await import('@/utils/download') const file = createFile({ url: 'https://example.com/photo.png', name: 'photo.png' }) - const { container } = render(<FileImageItem file={file} showDownloadAction />) + render(<FileImageItem file={file} showDownloadAction />) - const svgs = container.querySelectorAll('svg') - const downloadSvg = Array.from(svgs).find( - svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'), - ) - fireEvent.click(downloadSvg!.parentElement!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.download' })) expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ url: expect.stringContaining('as_attachment=true'), diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx index 00dd7d9971..0a1902c9c5 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx @@ -61,14 +61,13 @@ describe('FileItem (chat-input)', () => { it('should render delete button when showDeleteAction is true', () => { render(<FileItem file={createFile()} showDeleteAction />) - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'common.operation.remove' })).toBeInTheDocument() }) it('should call onRemove when delete button is clicked', () => { const onRemove = vi.fn() render(<FileItem file={createFile()} showDeleteAction onRemove={onRemove} />) - const delete_button = screen.getByTestId('delete-button') + const delete_button = screen.getByRole('button', { name: 'common.operation.remove' }) fireEvent.click(delete_button) expect(onRemove).toHaveBeenCalledWith('file-1') }) @@ -85,8 +84,7 @@ describe('FileItem (chat-input)', () => { it('should render replay icon when upload failed', () => { render(<FileItem file={createFile({ progress: -1 })} />) - const replayIcon = screen.getByTestId('replay-icon') - expect(replayIcon).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument() }) it('should call onReUpload when replay icon is clicked', () => { @@ -95,7 +93,7 @@ describe('FileItem (chat-input)', () => { <FileItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />, ) - const replayIcon = screen.getByTestId('replay-icon') + const replayIcon = screen.getByRole('button', { name: 'common.operation.retry' }) fireEvent.click(replayIcon!) expect(onReUpload).toHaveBeenCalledWith('file-1') @@ -176,7 +174,7 @@ describe('FileItem (chat-input)', () => { fireEvent.click(screen.getByText(/audio\.mp3/i)) expect(document.querySelector('audio')).toBeInTheDocument() - const deleteButton = screen.getByTestId('close-btn') + const deleteButton = screen.getByRole('button', { name: 'common.operation.close' }) fireEvent.click(deleteButton) expect(document.querySelector('audio')).not.toBeInTheDocument() @@ -185,15 +183,14 @@ describe('FileItem (chat-input)', () => { it('should render download button when showDownloadAction is true and url exists', () => { render(<FileItem file={createFile()} showDownloadAction />) - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'common.operation.download' })).toBeInTheDocument() }) it('should call downloadUrl when download button is clicked', async () => { const { downloadUrl } = await import('@/utils/download') render(<FileItem file={createFile()} showDownloadAction />) - const downloadBtn = screen.getByTestId('download-button') + const downloadBtn = screen.getByRole('button', { name: 'common.operation.download' }) fireEvent.click(downloadBtn) expect(downloadUrl).toHaveBeenCalled() @@ -252,7 +249,7 @@ describe('FileItem (chat-input)', () => { fireEvent.click(screen.getByText(/video\.mp4/i)) expect(document.querySelector('video')).toBeInTheDocument() - const closeBtn = screen.getByTestId('video-preview-close-btn') + const closeBtn = screen.getByRole('button', { name: 'common.operation.close' }) fireEvent.click(closeBtn) expect(document.querySelector('video')).not.toBeInTheDocument() @@ -334,8 +331,7 @@ describe('FileItem (chat-input)', () => { />, ) - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'common.operation.download' })).toBeInTheDocument() }) it('should not render extension separator when ext is empty', () => { diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx index 14e4ef30b4..485a351e55 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx @@ -5,6 +5,7 @@ import { RiDownloadLine, } from '@remixicon/react' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { ReplayLine } from '@/app/components/base/icons/src/vender/other' import ImagePreview from '@/app/components/base/image-uploader/image-preview' import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' @@ -30,6 +31,7 @@ const FileImageItem = ({ onRemove, onReUpload, }: FileImageItemProps) => { + const { t } = useTranslation() const { id, progress, base64Url, url, name } = file const [imagePreviewUrl, setImagePreviewUrl] = useState('') const download_url = url ? `${url}&as_attachment=true` : base64Url @@ -43,10 +45,14 @@ const FileImageItem = ({ { showDeleteAction && ( <Button + aria-label={t('operation.remove', { ns: 'common' })} className="absolute -top-1.5 -right-1.5 z-11 hidden h-5 w-5 rounded-full p-0 group-hover/file-image:flex" - onClick={() => onRemove?.(id)} + onClick={(e) => { + e.stopPropagation() + onRemove?.(id) + }} > - <RiCloseLine className="h-4 w-4 text-components-button-secondary-text" /> + <RiCloseLine className="h-4 w-4 text-components-button-secondary-text" aria-hidden="true" /> </Button> ) } @@ -71,25 +77,34 @@ const FileImageItem = ({ { progress === -1 && ( <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-state-destructive-border bg-background-overlay-destructive"> - <ReplayLine - className="h-5 w-5" - onClick={() => onReUpload?.(id)} - /> + <button + type="button" + aria-label={t('operation.retry', { ns: 'common' })} + className="h-5 w-5 border-none bg-transparent p-0" + onClick={(e) => { + e.stopPropagation() + onReUpload?.(id) + }} + > + <ReplayLine className="h-5 w-5" aria-hidden="true" /> + </button> </div> ) } { showDownloadAction && ( <div className="absolute inset-0.5 z-10 hidden bg-background-overlay-alt group-hover/file-image:block"> - <div - className="absolute right-0.5 bottom-0.5 flex h-6 w-6 items-center justify-center rounded-lg bg-components-actionbar-bg shadow-md" + <button + type="button" + aria-label={t('operation.download', { ns: 'common' })} + className="absolute right-0.5 bottom-0.5 flex h-6 w-6 items-center justify-center rounded-lg border-none bg-components-actionbar-bg p-0 shadow-md" onClick={(e) => { e.stopPropagation() downloadUrl({ url: download_url || '', fileName: name, target: '_blank' }) }} > - <RiDownloadLine className="h-4 w-4 text-text-tertiary" /> - </div> + <RiDownloadLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> ) } diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx index 3dae5f60f8..ee65a79f6d 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx @@ -2,6 +2,7 @@ import type { FileEntity } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import AudioPreview from '@/app/components/base/file-uploader/audio-preview' import PdfPreview from '@/app/components/base/file-uploader/dynamic-pdf-preview' @@ -32,6 +33,7 @@ const FileItem = ({ onReUpload, canPreview, }: FileItemProps) => { + const { t } = useTranslation() const { id, name, type, progress, url, base64Url, isRemote } = file const [previewUrl, setPreviewUrl] = useState('') const ext = getFileExtension(name, type, isRemote) @@ -56,11 +58,11 @@ const FileItem = ({ { showDeleteAction && ( <Button + aria-label={t('operation.remove', { ns: 'common' })} className="absolute -top-1.5 -right-1.5 z-11 hidden h-5 w-5 rounded-full p-0 group-hover/file-item:flex" onClick={() => onRemove?.(id)} - data-testid="delete-button" > - <span className="i-ri-close-line h-4 w-4 text-components-button-secondary-text" /> + <span className="i-ri-close-line h-4 w-4 text-components-button-secondary-text" aria-hidden="true" /> </Button> ) } @@ -93,15 +95,15 @@ const FileItem = ({ { showDownloadAction && download_url && ( <ActionButton + aria-label={t('operation.download', { ns: 'common' })} size="m" className="absolute -top-1 -right-1 hidden group-hover/file-item:flex" onClick={(e) => { e.stopPropagation() downloadUrl({ url: download_url || '', fileName: name, target: '_blank' }) }} - data-testid="download-button" > - <span className="i-ri-download-line h-3.5 w-3.5 text-text-tertiary" /> + <span className="i-ri-download-line h-3.5 w-3.5 text-text-tertiary" aria-hidden="true" /> </ActionButton> ) } @@ -116,7 +118,14 @@ const FileItem = ({ } { uploadError && ( - <span className="i-custom-vender-other-replay-line h-4 w-4 cursor-pointer text-text-tertiary" onClick={() => onReUpload?.(id)} data-testid="replay-icon" role="button" tabIndex={0} /> + <button + type="button" + aria-label={t('operation.retry', { ns: 'common' })} + className="h-4 w-4 cursor-pointer border-none bg-transparent p-0 text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => onReUpload?.(id)} + > + <span className="i-custom-vender-other-replay-line block h-4 w-4" aria-hidden="true" /> + </button> ) } </div> diff --git a/web/app/components/base/file-uploader/video-preview.tsx b/web/app/components/base/file-uploader/video-preview.tsx index 986bf61dce..c42fed42bf 100644 --- a/web/app/components/base/file-uploader/video-preview.tsx +++ b/web/app/components/base/file-uploader/video-preview.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { useTranslation } from 'react-i18next' type VideoPreviewProps = { url: string @@ -11,6 +12,8 @@ const VideoPreview: FC<VideoPreviewProps> = ({ title, onCancel, }) => { + const { t } = useTranslation() + return ( <Dialog open @@ -37,12 +40,14 @@ const VideoPreview: FC<VideoPreviewProps> = ({ /> </video> </div> - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-white/[0.08] p-0 backdrop-blur-[2px]" onClick={onCancel} > - <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="video-preview-close-btn" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-gray-500" aria-hidden="true" /> + </button> </DialogContent> </Dialog> ) diff --git a/web/app/components/base/float-right-container/__tests__/index.spec.tsx b/web/app/components/base/float-right-container/__tests__/index.spec.tsx index 4466b2cadc..6f84e5afd8 100644 --- a/web/app/components/base/float-right-container/__tests__/index.spec.tsx +++ b/web/app/components/base/float-right-container/__tests__/index.spec.tsx @@ -90,7 +90,7 @@ describe('FloatRightContainer', () => { ) await screen.findByRole('dialog') - const closeIcon = screen.getByTestId('close-icon') + const closeIcon = screen.getByRole('button', { name: 'common.operation.close' }) expect(closeIcon).toBeInTheDocument() await userEvent.click(closeIcon) diff --git a/web/app/components/base/float-right-container/index.tsx b/web/app/components/base/float-right-container/index.tsx index db3b73da95..ee79b22226 100644 --- a/web/app/components/base/float-right-container/index.tsx +++ b/web/app/components/base/float-right-container/index.tsx @@ -63,7 +63,6 @@ const FloatRightContainer = ({ <DrawerCloseButton aria-label={t('operation.close', { ns: 'common' })} className="h-6 w-6 rounded-md" - data-testid="close-icon" /> )} </div> diff --git a/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx b/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx index 6005d9261b..d5e5a6d25c 100644 --- a/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx @@ -21,7 +21,7 @@ describe('CheckboxField', () => { it('should toggle on when unchecked users click the checkbox', () => { mockField.state.value = false render(<CheckboxField label="Enable feature" />) - fireEvent.click(screen.getByTestId('checkbox-checkbox-field')) + fireEvent.click(screen.getByRole('checkbox', { name: 'Enable feature' })) expect(mockField.handleChange).toHaveBeenCalledWith(true) }) diff --git a/web/app/components/base/form/components/field/checkbox.tsx b/web/app/components/base/form/components/field/checkbox.tsx index cd5b95f2fa..29ba50d56b 100644 --- a/web/app/components/base/form/components/field/checkbox.tsx +++ b/web/app/components/base/form/components/field/checkbox.tsx @@ -19,6 +19,7 @@ const CheckboxField = ({ <Checkbox id={field.name} checked={field.state.value} + ariaLabel={label} onCheck={() => { field.handleChange(!field.state.value) }} diff --git a/web/app/components/base/image-uploader/__tests__/audio-preview.spec.tsx b/web/app/components/base/image-uploader/__tests__/audio-preview.spec.tsx index da15c1c339..5608e1cabb 100644 --- a/web/app/components/base/image-uploader/__tests__/audio-preview.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/audio-preview.spec.tsx @@ -35,8 +35,7 @@ describe('AudioPreview', () => { it('should render close button', () => { render(<AudioPreview {...defaultProps} />) - const closeBtn = screen.getByTestId('close-preview') - expect(closeBtn).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) it('should render via portal into document.body', () => { @@ -78,7 +77,7 @@ describe('AudioPreview', () => { const onCancel = vi.fn() render(<AudioPreview {...defaultProps} onCancel={onCancel} />) - const closeBtn = screen.getByTestId('close-preview') + const closeBtn = screen.getByRole('button', { name: 'common.operation.close' }) await user.click(closeBtn) expect(onCancel).toHaveBeenCalledTimes(1) diff --git a/web/app/components/base/image-uploader/__tests__/image-list.spec.tsx b/web/app/components/base/image-uploader/__tests__/image-list.spec.tsx index 44353ca19f..feb4294643 100644 --- a/web/app/components/base/image-uploader/__tests__/image-list.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/image-list.spec.tsx @@ -31,7 +31,7 @@ describe('ImageList', () => { describe('Rendering', () => { it('should render without crashing with empty list', () => { render(<ImageList list={[]} />) - expect(screen.getByTestId('image-list')).toBeInTheDocument() + expect(screen.getByRole('list', { name: 'common.imageUploader.imageList' })).toBeInTheDocument() }) it('should render images for each item in the list', () => { @@ -73,7 +73,7 @@ describe('ImageList', () => { const list = [createLocalFile({ _id: 'file-1' })] render(<ImageList list={list} onRemove={vi.fn()} />) - expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.remove' })).toBeInTheDocument() }) it('should not show remove buttons when readonly', () => { @@ -104,7 +104,7 @@ describe('ImageList', () => { const list = [createLocalFile({ _id: 'file-1', progress: -1 })] render(<ImageList list={list} onReUpload={onReUpload} />) - expect(screen.getByTestId('retry-icon')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument() expect(screen.queryByText(/\d+\s*%/)).not.toBeInTheDocument() }) }) @@ -150,8 +150,7 @@ describe('ImageList', () => { const onReUpload = vi.fn() const list = [createLocalFile({ _id: 'file-1', progress: -1 })] render(<ImageList list={list} onReUpload={onReUpload} />) - const retryIcon = screen.getByTestId('retry-icon') - await user.click(retryIcon) + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) expect(onReUpload).toHaveBeenCalledWith('file-1') }) @@ -186,7 +185,7 @@ describe('ImageList', () => { expect(screen.queryByTestId('image-preview-container')).toBeInTheDocument() // Close preview - const closeButton = screen.getByTestId('image-preview-close-button') + const closeButton = screen.getByRole('button', { name: 'common.operation.cancel' }) await user.click(closeButton) expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument() }) diff --git a/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx b/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx index 48db2df3d2..b82075fdd7 100644 --- a/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx @@ -28,12 +28,12 @@ vi.mock('@/utils/download', () => ({ })) const getOverlay = () => screen.getByTestId('image-preview-container') as HTMLDivElement -const getCloseButton = () => screen.getByTestId('image-preview-close-button') as HTMLDivElement -const getCopyButton = () => screen.getByTestId('image-preview-copy-button') as HTMLDivElement -const getZoomOutButton = () => screen.getByTestId('image-preview-zoom-out-button') as HTMLDivElement -const getZoomInButton = () => screen.getByTestId('image-preview-zoom-in-button') as HTMLDivElement -const getDownloadButton = () => screen.getByTestId('image-preview-download-button') as HTMLDivElement -const getOpenInTabButton = () => screen.getByTestId('image-preview-open-in-tab-button') as HTMLDivElement +const getCloseButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) as HTMLButtonElement +const getCopyButton = () => screen.getByRole('button', { name: 'common.operation.copyImage' }) as HTMLButtonElement +const getZoomOutButton = () => screen.getByRole('button', { name: 'common.operation.zoomOut' }) as HTMLButtonElement +const getZoomInButton = () => screen.getByRole('button', { name: 'common.operation.zoomIn' }) as HTMLButtonElement +const getDownloadButton = () => screen.getByRole('button', { name: 'common.operation.download' }) as HTMLButtonElement +const getOpenInTabButton = () => screen.getByRole('button', { name: 'common.operation.openInNewTab' }) as HTMLButtonElement const base64Image = 'aGVsbG8=' const dataImage = `data:image/png;base64,${base64Image}` diff --git a/web/app/components/base/image-uploader/__tests__/video-preview.spec.tsx b/web/app/components/base/image-uploader/__tests__/video-preview.spec.tsx index 90d7e1ea8f..4b98b4af9c 100644 --- a/web/app/components/base/image-uploader/__tests__/video-preview.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/video-preview.spec.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event' import VideoPreview from '../video-preview' const getOverlay = () => screen.getByTestId('video-preview') -const getCloseButton = () => screen.getByTestId('close-button') +const getCloseButton = () => screen.getByRole('button', { name: 'common.operation.close' }) describe('VideoPreview', () => { const defaultProps = { url: 'https://example.com/video.mp4', diff --git a/web/app/components/base/image-uploader/audio-preview.tsx b/web/app/components/base/image-uploader/audio-preview.tsx index 4f7dd83400..d5e15fd3bf 100644 --- a/web/app/components/base/image-uploader/audio-preview.tsx +++ b/web/app/components/base/image-uploader/audio-preview.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { useTranslation } from 'react-i18next' type AudioPreviewProps = { url: string @@ -11,6 +12,8 @@ const AudioPreview: FC<AudioPreviewProps> = ({ title, onCancel, }) => { + const { t } = useTranslation() + return ( <Dialog open @@ -38,13 +41,14 @@ const AudioPreview: FC<AudioPreviewProps> = ({ /> </audio> </div> - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-white/[0.08] p-0 backdrop-blur-[2px]" onClick={onCancel} - data-testid="close-preview" > - <span className="i-ri-close-line h-4 w-4 text-gray-500" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-gray-500" aria-hidden="true" /> + </button> </DialogContent> </Dialog> ) diff --git a/web/app/components/base/image-uploader/image-list.tsx b/web/app/components/base/image-uploader/image-list.tsx index 2e56b88f65..a149d675d2 100644 --- a/web/app/components/base/image-uploader/image-list.tsx +++ b/web/app/components/base/image-uploader/image-list.tsx @@ -43,10 +43,11 @@ const ImageList: FC<ImageListProps> = ({ } return ( - <div className="flex flex-wrap" data-testid="image-list"> + <div className="flex flex-wrap" role="list" aria-label={t('imageUploader.imageList', { ns: 'common' })}> {list.map(item => ( <div key={item._id} + role="listitem" className="group relative mr-1 rounded-lg border-[0.5px] border-black/5" > {item.type === TransferMethod.local_file && item.progress !== 100 && ( @@ -56,7 +57,14 @@ const ImageList: FC<ImageListProps> = ({ style={{ left: item.progress > -1 ? `${item.progress}%` : 0 }} > {item.progress === -1 && ( - <span className="i-custom-vender-line-arrows-refresh-ccw-01 h-5 w-5 text-white" onClick={() => onReUpload?.(item._id)} data-testid="retry-icon" /> + <button + type="button" + aria-label={t('operation.retry', { ns: 'common' })} + className="h-5 w-5 border-none bg-transparent p-0 text-white focus-visible:ring-1 focus-visible:ring-white focus-visible:outline-hidden" + onClick={() => onReUpload?.(item._id)} + > + <span className="i-custom-vender-line-arrows-refresh-ccw-01 h-5 w-5" aria-hidden="true" /> + </button> )} </div> {item.progress > -1 && ( @@ -117,14 +125,14 @@ const ImageList: FC<ImageListProps> = ({ <button type="button" className={cn( - 'absolute -top-[9px] -right-[9px] z-10 h-[18px] w-[18px] items-center justify-center', + 'absolute -top-[9px] -right-[9px] z-10 h-[18px] w-[18px] items-center justify-center border-none bg-transparent p-0', 'rounded-2xl shadow-lg hover:bg-state-base-hover', item.progress === -1 ? 'flex' : 'hidden group-hover:flex', )} onClick={() => onRemove?.(item._id)} - data-testid="remove-button" + aria-label={t('operation.remove', { ns: 'common' })} > - <span className="i-ri-close-line h-3 w-3 text-text-tertiary" /> + <span className="i-ri-close-line h-3 w-3 text-text-tertiary" aria-hidden="true" /> </button> )} </div> diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index 071188bf12..1400ac111f 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -227,8 +227,8 @@ const ImagePreview: FC<ImagePreviewProps> = ({ onClick={imageCopy} > {isCopied - ? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" data-testid="image-preview-copied-icon" /> - : <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" data-testid="image-preview-copy-button" />} + ? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" aria-hidden="true" /> + : <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" aria-hidden="true" />} </button> )} /> @@ -245,7 +245,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ className="absolute top-6 right-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" onClick={zoomOut} > - <span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-out-button" /> + <span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" aria-hidden="true" /> </button> )} /> @@ -262,7 +262,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ className="absolute top-6 right-32 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" onClick={zoomIn} > - <span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-in-button" /> + <span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" aria-hidden="true" /> </button> )} /> @@ -279,7 +279,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" onClick={downloadImage} > - <span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" data-testid="image-preview-download-button" /> + <span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" aria-hidden="true" /> </button> )} /> @@ -296,7 +296,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg" onClick={openInNewTab} > - <span className="i-ri-add-box-line h-4 w-4 text-gray-500" data-testid="image-preview-open-in-tab-button" /> + <span className="i-ri-add-box-line h-4 w-4 text-gray-500" aria-hidden="true" /> </button> )} /> @@ -313,7 +313,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]" onClick={onCancel} > - <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" /> + <span className="i-ri-close-line h-4 w-4 text-gray-500" aria-hidden="true" /> </button> )} /> diff --git a/web/app/components/base/image-uploader/video-preview.tsx b/web/app/components/base/image-uploader/video-preview.tsx index 8d5cb4dab3..c7302d700f 100644 --- a/web/app/components/base/image-uploader/video-preview.tsx +++ b/web/app/components/base/image-uploader/video-preview.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { useTranslation } from 'react-i18next' type VideoPreviewProps = { url: string @@ -11,6 +12,8 @@ const VideoPreview: FC<VideoPreviewProps> = ({ title, onCancel, }) => { + const { t } = useTranslation() + return ( <Dialog open @@ -38,12 +41,14 @@ const VideoPreview: FC<VideoPreviewProps> = ({ /> </video> </div> - <div - className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-white/[0.08] p-0 backdrop-blur-[2px]" onClick={onCancel} > - <span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-button" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-gray-500" aria-hidden="true" /> + </button> </DialogContent> </Dialog> ) diff --git a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx index 33ebec5cbc..b985a0f018 100644 --- a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx +++ b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx @@ -163,13 +163,8 @@ describe('InputWithCopy component', () => { const mockOnChange = vi.fn() render(<InputWithCopy value="test value" onChange={mockOnChange} />) - // The tooltip content should use the 'copied' translation - const copyButton = screen.getByRole('button') + const copyButton = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copied' }) expect(copyButton).toBeInTheDocument() - - // Verify the filled clipboard icon is rendered (not the line variant) - const filledIcon = screen.getByTestId('copied-icon') - expect(filledIcon).toBeInTheDocument() }) it('shows copy tooltip text when copied state is false', () => { @@ -177,20 +172,16 @@ describe('InputWithCopy component', () => { const mockOnChange = vi.fn() render(<InputWithCopy value="test value" onChange={mockOnChange} />) - const copyButton = screen.getByRole('button') + const copyButton = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }) expect(copyButton).toBeInTheDocument() - - const lineIcon = screen.getByTestId('copy-icon') - expect(lineIcon).toBeInTheDocument() }) it('calls reset on mouse leave from copy button wrapper', () => { const mockOnChange = vi.fn() render(<InputWithCopy value="test value" onChange={mockOnChange} />) - const wrapper = screen.getByTestId('copy-button-wrapper') - expect(wrapper).toBeInTheDocument() - fireEvent.mouseLeave(wrapper) + const copyButton = screen.getByRole('button') + fireEvent.mouseLeave(copyButton) expect(mockReset).toHaveBeenCalled() }) diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx index 38a7ceed9c..7e1ee60331 100644 --- a/web/app/components/base/input-with-copy/index.tsx +++ b/web/app/components/base/input-with-copy/index.tsx @@ -61,8 +61,6 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>(( {showCopyButton && ( <div className="absolute top-1/2 right-2 -translate-y-1/2" - onMouseLeave={reset} - data-testid="copy-button-wrapper" > <Tooltip> <TooltipTrigger @@ -71,11 +69,12 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>(( size="xs" aria-label={safeTooltipText} onClick={handleCopy} + onMouseLeave={reset} className="hover:bg-components-button-ghost-bg-hover" > {copied - ? (<span className="i-ri-clipboard-fill h-3.5 w-3.5 text-text-tertiary" data-testid="copied-icon" />) - : (<span className="i-ri-clipboard-line h-3.5 w-3.5 text-text-tertiary" data-testid="copy-icon" />)} + ? (<span className="i-ri-clipboard-fill h-3.5 w-3.5 text-text-tertiary" aria-hidden="true" />) + : (<span className="i-ri-clipboard-line h-3.5 w-3.5 text-text-tertiary" aria-hidden="true" />)} </ActionButton> )} /> diff --git a/web/app/components/base/input/__tests__/index.spec.tsx b/web/app/components/base/input/__tests__/index.spec.tsx index 7dc5d22eab..90bac0095f 100644 --- a/web/app/components/base/input/__tests__/index.spec.tsx +++ b/web/app/components/base/input/__tests__/index.spec.tsx @@ -66,7 +66,7 @@ describe('Input component', () => { it('calls onClear when clear icon is clicked', () => { const onClear = vi.fn() render(<Input showClearIcon value="test" onClear={onClear} />) - const clearIconContainer = screen.getByTestId('input-clear') + const clearIconContainer = screen.getByRole('button', { name: 'common.operation.clear' }) fireEvent.click(clearIconContainer!) expect(onClear).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index d41cd2c0cf..deeca1e3c4 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -109,13 +109,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ {...props} /> {!!(showClearIcon && value && !disabled && !destructive) && ( - <div - className={cn('group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer p-px')} + <button + type="button" + aria-label={t('operation.clear', { ns: 'common' })} + className={cn('group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px')} onClick={onClear} - data-testid="input-clear" > - <span className="i-ri-close-circle-fill h-3.5 w-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" /> - </div> + <span className="i-ri-close-circle-fill h-3.5 w-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" aria-hidden="true" /> + </button> )} {destructive && ( <span className="absolute top-1/2 right-2 i-ri-error-warning-line h-4 w-4 -translate-y-1/2 text-text-destructive-secondary" /> diff --git a/web/app/components/base/markdown-blocks/__tests__/form.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/form.spec.tsx index ebf70b6b77..f5a685a2e6 100644 --- a/web/app/components/base/markdown-blocks/__tests__/form.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/form.spec.tsx @@ -369,8 +369,8 @@ describe('MarkdownForm', () => { render(<MarkdownForm node={node} />) - const clearIcon = screen.getByTestId('date-picker-clear-button') - await user.click(clearIcon) + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) + await user.click(clearButton) await user.click(screen.getByRole('button', { name: 'Submit' })) diff --git a/web/app/components/base/mermaid/__tests__/index.spec.tsx b/web/app/components/base/mermaid/__tests__/index.spec.tsx index 310f346083..14c178e418 100644 --- a/web/app/components/base/mermaid/__tests__/index.spec.tsx +++ b/web/app/components/base/mermaid/__tests__/index.spec.tsx @@ -414,7 +414,7 @@ describe('Mermaid Flowchart Component', () => { }) // Wait for image preview to appear - const cancelBtn = await screen.findByTestId('image-preview-close-button') + const cancelBtn = await screen.findByRole('button', { name: 'common.operation.cancel' }) expect(cancelBtn).toBeInTheDocument() await act(async () => { @@ -423,7 +423,7 @@ describe('Mermaid Flowchart Component', () => { await waitFor(() => { expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument() - expect(screen.queryByTestId('image-preview-close-button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/base/message-log-modal/__tests__/index.spec.tsx b/web/app/components/base/message-log-modal/__tests__/index.spec.tsx index 525f190743..49f2970654 100644 --- a/web/app/components/base/message-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/message-log-modal/__tests__/index.spec.tsx @@ -91,7 +91,7 @@ describe('MessageLogModal', () => { describe('Interaction', () => { it('calls onCancel when close icon is clicked', () => { render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />) - const closeButton = screen.getByTestId('close-button') + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) expect(closeButton)!.toBeInTheDocument() fireEvent.click(closeButton) expect(onCancel).toHaveBeenCalledTimes(1) diff --git a/web/app/components/base/message-log-modal/index.tsx b/web/app/components/base/message-log-modal/index.tsx index 2d8d1dec66..9a58a0213d 100644 --- a/web/app/components/base/message-log-modal/index.tsx +++ b/web/app/components/base/message-log-modal/index.tsx @@ -58,9 +58,14 @@ const MessageLogModal: FC<MessageLogModalProps> = ({ ref={ref} > <h1 className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary">{t('runDetail.title', { ns: 'appLog' })}</h1> - <span className="absolute top-4 right-3 z-20 cursor-pointer p-1" onClick={onCancel} data-testid="close-button"> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </span> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onCancel} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <Run hideResult activeTab={defaultTab as any} diff --git a/web/app/components/base/new-audio-button/index.tsx b/web/app/components/base/new-audio-button/index.tsx index 56ada77df6..d9da5fd286 100644 --- a/web/app/components/base/new-audio-button/index.tsx +++ b/web/app/components/base/new-audio-button/index.tsx @@ -88,10 +88,11 @@ const AudioBtn = ({ ? ActionButtonState.Active : ActionButtonState.Default } + aria-label={tooltipContent} onClick={handleToggle} disabled={audioState === 'loading'} > - <RiVolumeUpLine className="h-4 w-4" /> + <RiVolumeUpLine className="h-4 w-4" aria-hidden="true" /> </ActionButton> </span> )} diff --git a/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx b/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx index 54e33b8fbe..07459d0361 100644 --- a/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx +++ b/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx @@ -148,11 +148,11 @@ describe('NotionPageSelector Base', () => { const user = userEvent.setup() render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />) - const searchInput = screen.getByTestId('notion-search-input') + const searchInput = screen.getByPlaceholderText('common.dataSource.notion.selector.searchPages') await user.type(searchInput, 'no-such-page') expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() - await user.click(screen.getByTestId('notion-search-input-clear')) + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument() }) @@ -170,7 +170,7 @@ describe('NotionPageSelector Base', () => { />, ) - const selectorBtn = screen.getByTestId('notion-credential-selector-btn') + const selectorBtn = screen.getByRole('combobox', { name: /Workspace 1/ }) await user.click(selectorBtn) const item2 = screen.getByTestId('notion-credential-item-c2') await user.click(item2) diff --git a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx index f1f1cf08d2..5ad5f63840 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx @@ -32,7 +32,7 @@ describe('CredentialSelector', () => { const user = userEvent.setup() render(<CredentialSelector value="1" items={mockItems} onSelect={vi.fn()} />) - const btn = screen.getByTestId('notion-credential-selector-btn') + const btn = screen.getByRole('combobox', { name: /Notion Workspace 1/ }) await user.click(btn) expect(screen.getByTestId('notion-credential-item-1')).toBeInTheDocument() @@ -44,7 +44,7 @@ describe('CredentialSelector', () => { const user = userEvent.setup() render(<CredentialSelector value="1" items={mockItems} onSelect={handleSelect} />) - const btn = screen.getByTestId('notion-credential-selector-btn') + const btn = screen.getByRole('combobox', { name: /Notion Workspace 1/ }) await user.click(btn) const item2 = screen.getByTestId('notion-credential-item-2') diff --git a/web/app/components/base/notion-page-selector/credential-selector/index.tsx b/web/app/components/base/notion-page-selector/credential-selector/index.tsx index 81ee1c06d8..6820720c07 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/index.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/index.tsx @@ -37,8 +37,8 @@ const CredentialSelector = ({ return ( <Select value={currentCredential?.credentialId ?? null} onValueChange={nextValue => nextValue && onSelect(nextValue)}> <SelectTrigger + aria-label={currentDisplayName} className="w-[168px]" - data-testid="notion-credential-selector-btn" > <span className="flex min-w-0 items-center"> <CredentialIcon diff --git a/web/app/components/base/notion-page-selector/search-input/__tests__/index.spec.tsx b/web/app/components/base/notion-page-selector/search-input/__tests__/index.spec.tsx index 39e7e09cf8..e68409d3ef 100644 --- a/web/app/components/base/notion-page-selector/search-input/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-page-selector/search-input/__tests__/index.spec.tsx @@ -16,7 +16,7 @@ describe('SearchInput', () => { const user = userEvent.setup() render(<SearchInput value="" onChange={handleChange} />) - const input = screen.getByTestId('notion-search-input') + const input = screen.getByPlaceholderText('common.dataSource.notion.selector.searchPages') await user.type(input, 'test query') expect(handleChange).toHaveBeenCalled() @@ -25,7 +25,7 @@ describe('SearchInput', () => { it('should show clear button when value is not empty', () => { render(<SearchInput value="some value" onChange={vi.fn()} />) - expect(screen.getByTestId('notion-search-input-clear')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument() }) it('should call onChange with empty string when clear button is clicked', async () => { @@ -33,7 +33,7 @@ describe('SearchInput', () => { const user = userEvent.setup() render(<SearchInput value="some value" onChange={handleChange} />) - const clearBtn = screen.getByTestId('notion-search-input-clear') + const clearBtn = screen.getByRole('button', { name: 'common.operation.clear' }) await user.click(clearBtn) expect(handleChange).toHaveBeenCalledWith('') @@ -42,6 +42,6 @@ describe('SearchInput', () => { it('should not show clear button when value is empty', () => { render(<SearchInput value="" onChange={vi.fn()} />) - expect(screen.queryByTestId('notion-search-input-clear')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.clear' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/base/notion-page-selector/search-input/index.tsx b/web/app/components/base/notion-page-selector/search-input/index.tsx index 55bf506ce6..a7102e19b2 100644 --- a/web/app/components/base/notion-page-selector/search-input/index.tsx +++ b/web/app/components/base/notion-page-selector/search-input/index.tsx @@ -35,13 +35,18 @@ const SearchInput = ({ data-testid="notion-search-input" /> { - value && ( - <div - className="i-ri-close-circle-fill h-4 w-4 shrink-0 cursor-pointer text-components-input-text-placeholder" - onClick={handleClear} - data-testid="notion-search-input-clear" - /> - ) + value + ? ( + <button + type="button" + aria-label={t('operation.clear', { ns: 'common' })} + className="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active" + onClick={handleClear} + > + <span className="i-ri-close-circle-fill h-4 w-4 text-components-input-text-placeholder" aria-hidden="true" /> + </button> + ) + : null } </div> ) diff --git a/web/app/components/base/prompt-editor/hooks.ts b/web/app/components/base/prompt-editor/hooks.ts index 70869d6a17..ff95cc497c 100644 --- a/web/app/components/base/prompt-editor/hooks.ts +++ b/web/app/components/base/prompt-editor/hooks.ts @@ -110,9 +110,9 @@ export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, com return [ref, isSelected] } -type UseTriggerHandler = () => [RefObject<HTMLDivElement | null>, boolean, Dispatch<SetStateAction<boolean>>] -export const useTrigger: UseTriggerHandler = () => { - const triggerRef = useRef<HTMLDivElement>(null) +type UseTriggerHandler = <T extends HTMLElement = HTMLDivElement>() => [RefObject<T | null>, boolean, Dispatch<SetStateAction<boolean>>] +export const useTrigger: UseTriggerHandler = <T extends HTMLElement = HTMLDivElement>() => { + const triggerRef = useRef<T>(null) const [open, setOpen] = useState(false) const handleOpen = useCallback((e: MouseEvent) => { e.stopPropagation() diff --git a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx index 64bca0a24d..f7bb4a17fc 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx @@ -252,7 +252,7 @@ describe('ContextBlockComponent', () => { />, ) - const addButton = screen.getByTestId('add-button') + const addButton = screen.getByRole('button', { name: 'common.promptEditor.context.modal.add' }) await userEvent.click(addButton) expect(handleAddContext).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/base/prompt-editor/plugins/context-block/component.tsx b/web/app/components/base/prompt-editor/plugins/context-block/component.tsx index 05fff5b4e8..b12e632843 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/component.tsx @@ -25,7 +25,7 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({ }) => { const { t } = useTranslation() const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_CONTEXT_BLOCK_COMMAND) - const [triggerRef, open, setOpen] = useTrigger() + const [triggerRef, open, setOpen] = useTrigger<HTMLButtonElement>() const { eventEmitter } = useEventEmitterContextContext() const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets) @@ -54,18 +54,19 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({ onOpenChange={setOpen} > <PopoverTrigger - nativeButton={false} render={( - <div + <button + type="button" + aria-label={t('promptEditor.context.item.title', { ns: 'common' })} className={` - flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded text-[11px] font-semibold + flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded border-none p-0 text-[11px] font-semibold ${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'} `} ref={triggerRef} onClick={e => e.preventDefault()} > {localDatasets.length} - </div> + </button> )} /> <PopoverContent @@ -91,12 +92,16 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({ )) } </div> - <div className="flex h-8 cursor-pointer items-center text-[#155EEF]" onClick={onAddContext}> - <div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-gray-100" data-testid="add-button"> - <span className="i-ri-add-line h-[14px] w-[14px]" /> + <button + type="button" + className="flex h-8 cursor-pointer items-center border-none bg-transparent p-0 text-left text-[#155EEF] focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onAddContext} + > + <div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-gray-100"> + <span className="i-ri-add-line h-[14px] w-[14px]" aria-hidden="true" /> </div> <div className="text-[13px] font-medium" title="">{t('promptEditor.context.modal.add', { ns: 'common' })}</div> - </div> + </button> </div> <div className="rounded-b-xl border-t-[0.5px] border-gray-50 bg-gray-50 px-4 py-3 text-xs text-gray-500"> {t('promptEditor.context.modal.footer', { ns: 'common' })} diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx index 1520c24abe..ca279a1d4d 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx @@ -4,7 +4,7 @@ import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/ import type { ValueSelector } from '@/app/components/workflow/types' import { LexicalComposer } from '@lexical/react/LexicalComposer' -import { cleanup, fireEvent, render } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import HITLInputComponentUI from '../component-ui' import { HITLInputNode } from '../node' @@ -83,10 +83,10 @@ describe('HITLInputComponentUI', () => { describe('Rendering', () => { it('should render action buttons correctly', () => { - const { getAllByTestId } = renderComponent() + renderComponent() - const buttons = getAllByTestId(/action-btn-/) - expect(buttons).toHaveLength(2) + expect(screen.getByRole('button', { name: 'common.operation.edit' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.remove' })).toBeInTheDocument() }) it('should render variable block when default type is variable', () => { @@ -107,17 +107,18 @@ describe('HITLInputComponentUI', () => { }) it('should hide action buttons when readonly is true', () => { - const { queryAllByTestId } = renderComponent({ readonly: true }) + renderComponent({ readonly: true }) - expect(queryAllByTestId(/action-btn-/)).toHaveLength(0) + expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.remove' })).not.toBeInTheDocument() }) }) describe('Remove action', () => { it('should call onRemove when remove button is clicked', () => { - const { getByTestId, onRemove } = renderComponent() + const { onRemove } = renderComponent() - fireEvent.click(getByTestId('action-btn-remove')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) expect(onRemove).toHaveBeenCalledWith(varName) expect(onRemove).toHaveBeenCalledTimes(1) @@ -125,39 +126,19 @@ describe('HITLInputComponentUI', () => { }) describe('Edit flow', () => { - // it('should call onChange when name is unchanged', async () => { - // const { findByRole, findByTestId, onChange, onRename } = renderComponent() - - // fireEvent.click(await findByTestId('action-btn-edit')) - - // await findByRole('textbox') - - // const saveBtn = await findByTestId('hitl-input-save-btn') - // fireEvent.click(saveBtn) - - // expect(onChange).toHaveBeenCalledWith( - // expect.objectContaining({ - // output_variable_name: varName, - // }), - // ) - - // expect(onRename).not.toHaveBeenCalled() - // }) - it('should close modal without update when cancel is clicked', async () => { const { findByRole, - findByTestId, queryByRole, onChange, onRename, } = renderComponent() - fireEvent.click(await findByTestId('action-btn-edit')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' })) await findByRole('textbox') - fireEvent.click(await findByTestId('hitl-input-cancel-btn')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.cancel' })) expect(onChange).not.toHaveBeenCalled() expect(onRename).not.toHaveBeenCalled() @@ -168,45 +149,19 @@ describe('HITLInputComponentUI', () => { describe('Default formInput', () => { it('should pass default payload to InputField when formInput is undefined', async () => { - const { findByTestId, findByRole } = renderComponent({ + const { findByRole } = renderComponent({ formInput: undefined, }) - fireEvent.click(await findByTestId('action-btn-edit')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' })) const textbox = await findByRole('textbox') - fireEvent.click(await findByTestId('hitl-input-save-btn')) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.save' })) expect(textbox).toHaveValue('customer_name') }) - // it('should call onRename when variable name changes', async () => { - // const { - // findByRole, - // findByTestId, - // onChange, - // onRename, - // } = renderComponent() - - // fireEvent.click(await findByTestId('action-btn-edit')) - - // const input = (await findByRole('textbox')) as HTMLInputElement - - // fireEvent.change(input, { target: { value: 'updated_name' } }) - - // fireEvent.click(await screen.findByTestId('hitl-input-save-btn')) - - // expect(onChange).not.toHaveBeenCalled() - - // expect(onRename).toHaveBeenCalledWith( - // expect.objectContaining({ - // output_variable_name: 'updated_name', - // }), - // varName, - // ) - // }) - it('should render variable selector when workflowNodesMap fallback is used', () => { const { getByText } = renderComponent({ workflowNodesMap: undefined as unknown as WorkflowNodesMap, diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/type-switch.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/type-switch.spec.tsx index a23ef088f9..f487dbaae5 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/type-switch.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/type-switch.spec.tsx @@ -15,7 +15,7 @@ describe('TypeSwitch', () => { <TypeSwitch isVariable={false} onIsVariableChange={onIsVariableChange} />, ) - const trigger = screen.getByText('workflow.nodes.humanInput.insertInputField.useVarInstead') + const trigger = screen.getByRole('button', { name: 'workflow.nodes.humanInput.insertInputField.useVarInstead' }) await user.click(trigger) expect(onIsVariableChange).toHaveBeenCalledWith(true) @@ -29,7 +29,7 @@ describe('TypeSwitch', () => { <TypeSwitch isVariable onIsVariableChange={onIsVariableChange} />, ) - const trigger = screen.getByText('workflow.nodes.humanInput.insertInputField.useConstantInstead') + const trigger = screen.getByRole('button', { name: 'workflow.nodes.humanInput.insertInputField.useConstantInstead' }) await user.click(trigger) expect(onIsVariableChange).toHaveBeenCalledWith(false) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx index f3fc1a605c..322e2e0d75 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx @@ -136,20 +136,18 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({ <div className="flex h-full items-center" ref={editBtnRef}> <ActionButton size="s" - data-testid="action-btn-edit" aria-label={t('operation.edit', { ns: 'common' })} > - <span className="i-ri-edit-line size-4 text-text-tertiary" /> + <span className="i-ri-edit-line size-4 text-text-tertiary" aria-hidden="true" /> </ActionButton> </div> <div className="flex h-full items-center" ref={removeBtnRef}> <ActionButton size="s" - data-testid="action-btn-remove" aria-label={t('operation.remove', { ns: 'common' })} > - <span className="i-ri-delete-bin-line size-4 text-text-tertiary" /> + <span className="i-ri-delete-bin-line size-4 text-text-tertiary" aria-hidden="true" /> </ActionButton> </div> </div> diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx index e0fcd3bdba..ca7dc387ed 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx @@ -123,11 +123,10 @@ const InputField: React.FC<InputFieldProps> = ({ /> </div> <div className="mt-4 flex justify-end space-x-2"> - <Button data-testid="hitl-input-cancel-btn" onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button> + <Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button> {isEdit ? ( <Button - data-testid="hitl-input-save-btn" variant="primary" onClick={handleSave} disabled={!nameValid} @@ -137,7 +136,6 @@ const InputField: React.FC<InputFieldProps> = ({ ) : ( <Button - data-testid="hitl-input-insert-btn" className="flex" variant="primary" disabled={!nameValid} diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx index 86ab518fc5..f4e0f4304e 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx @@ -18,10 +18,14 @@ const TypeSwitch: FC<Props> = ({ }) => { const { t } = useTranslation() return ( - <div className={cn('inline-flex h-6 cursor-pointer items-center space-x-1 rounded-md pr-2 pl-1.5 text-text-tertiary select-none hover:bg-components-button-ghost-bg-hover', className)} onClick={() => onIsVariableChange?.(!isVariable)}> - <Variable02 className="size-3.5" /> + <button + type="button" + className={cn('inline-flex h-6 cursor-pointer items-center space-x-1 rounded-md border-none bg-transparent py-0 pr-2 pl-1.5 text-left text-text-tertiary select-none hover:bg-components-button-ghost-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', className)} + onClick={() => onIsVariableChange?.(!isVariable)} + > + <Variable02 className="size-3.5" aria-hidden="true" /> <div className="system-xs-medium">{t(`nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}`, { ns: 'workflow' })}</div> - </div> + </button> ) } export default React.memo(TypeSwitch) diff --git a/web/app/components/base/prompt-log-modal/__tests__/index.spec.tsx b/web/app/components/base/prompt-log-modal/__tests__/index.spec.tsx index 95de5aff39..d47cafd14b 100644 --- a/web/app/components/base/prompt-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-log-modal/__tests__/index.spec.tsx @@ -43,7 +43,7 @@ describe('PromptLogModal', () => { it('renders copy feedback when log length is 1', () => { render(<PromptLogModal {...defaultProps} />) - expect(screen.getByTestId('close-btn-container'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' }))!.toBeInTheDocument() }) it('renders multiple logs in Card correctly', () => { @@ -72,7 +72,7 @@ describe('PromptLogModal', () => { describe('Interactions', () => { it('calls onCancel when close button is clicked', () => { render(<PromptLogModal {...defaultProps} />) - const closeBtn = screen.getByTestId('close-btn') + const closeBtn = screen.getByRole('button', { name: 'common.operation.close' }) expect(closeBtn)!.toBeInTheDocument() fireEvent.click(closeBtn) expect(defaultProps.onCancel).toHaveBeenCalled() diff --git a/web/app/components/base/prompt-log-modal/index.tsx b/web/app/components/base/prompt-log-modal/index.tsx index 08200623ae..b9225fabb4 100644 --- a/web/app/components/base/prompt-log-modal/index.tsx +++ b/web/app/components/base/prompt-log-modal/index.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { IChatItem } from '@/app/components/base/chat/chat/type' import { useClickAway } from 'ahooks' import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { CopyFeedbackNew } from '@/app/components/base/copy-feedback' import Card from './card' @@ -15,6 +16,7 @@ const PromptLogModal: FC<PromptLogModalProps> = ({ width, onCancel, }) => { + const { t } = useTranslation() const ref = useRef(null) const [mounted, setMounted] = useState(false) @@ -53,13 +55,14 @@ const PromptLogModal: FC<PromptLogModalProps> = ({ </> ) } - <div + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} onClick={onCancel} - className="flex h-6 w-6 cursor-pointer items-center justify-center" - data-testid="close-btn-container" + className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-components-button-secondary-accent-border" > - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-btn" /> - </div> + <span className="i-ri-close-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> </div> <div className="grow overflow-y-auto p-2"> diff --git a/web/app/components/base/qrcode/__tests__/index.spec.tsx b/web/app/components/base/qrcode/__tests__/index.spec.tsx index cfc78cef85..0029f1840a 100644 --- a/web/app/components/base/qrcode/__tests__/index.spec.tsx +++ b/web/app/components/base/qrcode/__tests__/index.spec.tsx @@ -17,7 +17,7 @@ describe('ShareQRCode', () => { describe('Rendering', () => { it('renders correctly', () => { render(<ShareQRCode content={content} />) - expect(screen.getByRole('button').firstElementChild).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' })).toBeInTheDocument() }) }) @@ -27,7 +27,7 @@ describe('ShareQRCode', () => { render(<ShareQRCode content={content} />) expect(screen.queryByRole('img')).not.toBeInTheDocument() - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) expect(screen.getByRole('img')).toBeInTheDocument() @@ -40,16 +40,16 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render( <div> - <div data-testid="outside">Outside</div> + <div>Outside</div> <ShareQRCode content={content} /> </div>, ) - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) expect(screen.getByRole('img')).toBeInTheDocument() - await user.click(screen.getByTestId('outside')) + await user.click(screen.getByText('Outside')) expect(screen.queryByRole('img')).not.toBeInTheDocument() }) @@ -57,7 +57,7 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render(<ShareQRCode content={content} />) - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) const canvas = screen.getByRole('img') @@ -75,10 +75,10 @@ describe('ShareQRCode', () => { try { render(<ShareQRCode content={content} />) - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger!) - const downloadBtn = screen.getByText('appOverview.overview.appInfo.qrcode.download') + const downloadBtn = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.download' }) await user.click(downloadBtn) expect(downloadUrl).toHaveBeenCalledWith({ @@ -95,7 +95,7 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render(<ShareQRCode content={content} />) - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) // Override querySelector on the panel to simulate canvas not being found @@ -108,7 +108,7 @@ describe('ShareQRCode', () => { }) as typeof panel.querySelector try { - const downloadBtn = screen.getByText('appOverview.overview.appInfo.qrcode.download') + const downloadBtn = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.download' }) await user.click(downloadBtn) expect(downloadUrl).not.toHaveBeenCalled() } @@ -121,7 +121,7 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render(<ShareQRCode content={content} />) - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) // Click on the scan text inside the panel — panel should remain open diff --git a/web/app/components/base/qrcode/index.tsx b/web/app/components/base/qrcode/index.tsx index cfc30d752b..d4c9918aa6 100644 --- a/web/app/components/base/qrcode/index.tsx +++ b/web/app/components/base/qrcode/index.tsx @@ -52,32 +52,39 @@ const ShareQRCode = ({ content }: Props) => { const tooltipText = t(`${prefixEmbedded}`, { ns: 'appOverview' }) /* v8 ignore next -- react-i18next returns a non-empty key/string in configured runtime; empty fallback protects against missing i18n payloads. @preserve */ const safeTooltipText = tooltipText || '' + const downloadText = t('overview.appInfo.qrcode.download', { ns: 'appOverview' }) return ( <Tooltip> - <TooltipTrigger - render={( - <div className="relative h-6 w-6" onClick={toggleQRCode} data-testid="qrcode-container"> - <ActionButton> - <span className="i-ri-qr-code-line h-4 w-4" /> + <div className="relative h-6 w-6"> + <TooltipTrigger + render={( + <ActionButton aria-label={safeTooltipText} onClick={toggleQRCode}> + <span className="i-ri-qr-code-line h-4 w-4" aria-hidden="true" /> </ActionButton> - {isShow && ( - <div - ref={qrCodeRef} - className="absolute top-8 -right-8 z-10 flex w-[232px] flex-col items-center rounded-lg bg-components-panel-bg p-4 shadow-xs" - onClick={handlePanelClick} + )} + /> + {isShow && ( + <div + ref={qrCodeRef} + className="absolute top-8 -right-8 z-10 flex w-[232px] flex-col items-center rounded-lg bg-components-panel-bg p-4 shadow-xs" + onClick={handlePanelClick} + > + <QRCode size={160} value={content} className="mb-2" /> + <div className="flex items-center system-xs-regular"> + <div className="text-text-tertiary">{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}</div> + <div className="text-text-tertiary">·</div> + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left text-text-accent-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={downloadQR} > - <QRCode size={160} value={content} className="mb-2" /> - <div className="flex items-center system-xs-regular"> - <div className="text-text-tertiary">{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}</div> - <div className="text-text-tertiary">·</div> - <div className="cursor-pointer text-text-accent-secondary" onClick={downloadQR}>{t('overview.appInfo.qrcode.download', { ns: 'appOverview' })}</div> - </div> - </div> - )} + {downloadText} + </button> + </div> </div> )} - /> + </div> <TooltipContent> {safeTooltipText} </TooltipContent> diff --git a/web/app/components/base/radio-card/__tests__/index.spec.tsx b/web/app/components/base/radio-card/__tests__/index.spec.tsx index f4bc7a5b0e..b2d21fa123 100644 --- a/web/app/components/base/radio-card/__tests__/index.spec.tsx +++ b/web/app/components/base/radio-card/__tests__/index.spec.tsx @@ -32,7 +32,7 @@ describe('RadioCard', () => { />, ) - await user.click(screen.getByText('Clickable')) + await user.click(screen.getByRole('button', { name: /Clickable/ })) expect(onChosen).toHaveBeenCalledTimes(1) }) @@ -117,7 +117,7 @@ describe('RadioCard', () => { ) // click title should trigger onChosen - await user.click(screen.getByText('ClickNoRadio')) + await user.click(screen.getByRole('button', { name: /ClickNoRadio/ })) expect(onChosen).toHaveBeenCalledTimes(1) // radio area should be absent diff --git a/web/app/components/base/radio-card/index.tsx b/web/app/components/base/radio-card/index.tsx index 87e2c96258..dad810e1e1 100644 --- a/web/app/components/base/radio-card/index.tsx +++ b/web/app/components/base/radio-card/index.tsx @@ -37,7 +37,11 @@ const RadioCard: FC<Props> = ({ className, )} > - <div className="flex gap-x-2" onClick={onChosen}> + <button + type="button" + className="flex w-full gap-x-2 border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onChosen} + > <div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}> {icon} </div> @@ -47,15 +51,17 @@ const RadioCard: FC<Props> = ({ </div> {!noRadio && ( <div className="absolute top-3 right-3"> - <div className={cn( - 'h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs', - isChosen && 'border-[5px] border-components-radio-border-checked', - )} + <div + className={cn( + 'h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs', + isChosen && 'border-[5px] border-components-radio-border-checked', + )} + aria-hidden="true" > </div> </div> )} - </div> + </button> {!!((isChosen && chosenConfig) || noRadio) && ( <div className="mt-2 flex gap-x-2"> <div className="size-8 shrink-0"></div> diff --git a/web/app/components/base/sort/__tests__/index.spec.tsx b/web/app/components/base/sort/__tests__/index.spec.tsx index 18dfd242ce..ea85e8f3fe 100644 --- a/web/app/components/base/sort/__tests__/index.spec.tsx +++ b/web/app/components/base/sort/__tests__/index.spec.tsx @@ -11,10 +11,10 @@ const mockItems = [ ] describe('Sort component — real portal integration', () => { - const setup = (props = {}) => { + const setup = (props: Partial<React.ComponentProps<typeof Sort>> = {}) => { const onSelect = vi.fn() const user = userEvent.setup() - const { container, rerender } = render( + const { rerender } = render( <Sort value="created_at" items={mockItems} onSelect={onSelect} order="" {...props} />, ) @@ -28,10 +28,9 @@ describe('Sort component — real portal integration', () => { // helper: returns right-side sort button element const getSortButton = (): HTMLElement => { - const btn = container.querySelector('.rounded-r-lg') - if (!btn) - throw new Error('Sort button (rounded-r-lg) not found in rendered container') - return btn as HTMLElement + return screen.getByRole('button', { + name: props.order ? 'appLog.filter.ascending' : 'appLog.filter.descending', + }) } return { user, onSelect, rerender, getTriggerWrapper, getSortButton } diff --git a/web/app/components/base/sort/index.tsx b/web/app/components/base/sort/index.tsx index 69cf4dd220..57affdd96b 100644 --- a/web/app/components/base/sort/index.tsx +++ b/web/app/components/base/sort/index.tsx @@ -85,10 +85,15 @@ const Sort: FC<Props> = ({ </DropdownMenuContent> </div> </DropdownMenu> - <div className="ml-px cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover" onClick={() => onSelect(`${order ? '' : '-'}${value}`)}> - {!order && <RiSortAsc className="h-4 w-4 text-components-button-tertiary-text" />} - {order && <RiSortDesc className="h-4 w-4 text-components-button-tertiary-text" />} - </div> + <button + type="button" + aria-label={t(`filter.${order ? 'ascending' : 'descending'}`, { ns: 'appLog' })} + className="ml-px cursor-pointer rounded-r-lg border-none bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => onSelect(`${order ? '' : '-'}${value}`)} + > + {!order && <RiSortAsc className="h-4 w-4 text-components-button-tertiary-text" aria-hidden="true" />} + {order && <RiSortDesc className="h-4 w-4 text-components-button-tertiary-text" aria-hidden="true" />} + </button> </div> ) diff --git a/web/app/components/base/svg-gallery/__tests__/index.spec.tsx b/web/app/components/base/svg-gallery/__tests__/index.spec.tsx index c08b527d94..a93cc9da0e 100644 --- a/web/app/components/base/svg-gallery/__tests__/index.spec.tsx +++ b/web/app/components/base/svg-gallery/__tests__/index.spec.tsx @@ -131,7 +131,7 @@ describe('SVGRenderer', () => { expect(screen.getByAltText('Preview'))!.toBeInTheDocument() - await user.click(screen.getByTestId('image-preview-close-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { expect(screen.queryByAltText('Preview')).not.toBeInTheDocument() diff --git a/web/app/components/base/tag-input/__tests__/index.spec.tsx b/web/app/components/base/tag-input/__tests__/index.spec.tsx index b64f4aaab6..78bfeb33a8 100644 --- a/web/app/components/base/tag-input/__tests__/index.spec.tsx +++ b/web/app/components/base/tag-input/__tests__/index.spec.tsx @@ -63,7 +63,7 @@ describe('TagInput', () => { it('should hide remove controls when remove is disabled', () => { renderTagInput({ items: ['alpha'], disableRemove: true }) - expect(screen.queryByTestId('remove-tag')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.remove alpha' })).not.toBeInTheDocument() }) it('should apply focused style in special mode when input is focused', async () => { @@ -83,9 +83,9 @@ describe('TagInput', () => { it('should remove item when remove control is clicked', async () => { const { onChange } = renderTagInput({ items: ['alpha', 'beta'] }) - const removeControl = screen.getAllByTestId('remove-tag')[0] + const removeControl = screen.getByRole('button', { name: 'common.operation.remove alpha' }) - await userEvent.click(removeControl!) + await userEvent.click(removeControl) expect(onChange).toHaveBeenCalledWith(['beta']) }) diff --git a/web/app/components/base/tag-input/index.tsx b/web/app/components/base/tag-input/index.tsx index 716f1571e6..81f4d12af5 100644 --- a/web/app/components/base/tag-input/index.tsx +++ b/web/app/components/base/tag-input/index.tsx @@ -67,9 +67,14 @@ const TagInput: FC<TagInputProps> = ({ items, onChange, disableAdd, disableRemov <div key={item} className={cn('mt-1 mr-1 flex items-center rounded-md border border-divider-deep bg-components-badge-white-to-dark py-1 pr-1 pl-1.5 system-xs-regular text-text-secondary')}> {item} {!disableRemove && ( - <div className="flex h-4 w-4 cursor-pointer items-center justify-center" onClick={() => handleRemove(index)}> - <span className="ml-0.5 i-ri-close-line h-3.5 w-3.5 text-text-tertiary" data-testid="remove-tag" /> - </div> + <button + type="button" + aria-label={`${t('operation.remove', { ns: 'common' })} ${item}`} + className="flex h-4 w-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => handleRemove(index)} + > + <span className="ml-0.5 i-ri-close-line h-3.5 w-3.5 text-text-tertiary" aria-hidden="true" /> + </button> )} </div> ))} diff --git a/web/app/components/base/video-gallery/VideoPlayer.tsx b/web/app/components/base/video-gallery/VideoPlayer.tsx index 889836258f..6f7304474a 100644 --- a/web/app/components/base/video-gallery/VideoPlayer.tsx +++ b/web/app/components/base/video-gallery/VideoPlayer.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import styles from './VideoPlayer.module.css' type VideoPlayerProps = { @@ -8,36 +9,37 @@ type VideoPlayerProps = { } const PlayIcon = () => ( - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M8 5V19L19 12L8 5Z" fill="currentColor" /> </svg> ) const PauseIcon = () => ( - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M6 19H10V5H6V19ZM14 5V19H18V5H14Z" fill="currentColor" /> </svg> ) const MuteIcon = () => ( - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M3 9V15H7L12 20V4L7 9H3ZM16.5 12C16.5 10.23 15.48 8.71 14 7.97V16.02C15.48 15.29 16.5 13.77 16.5 12ZM14 3.23V5.29C16.89 6.15 19 8.83 19 12C19 15.17 16.89 17.85 14 18.71V20.77C18.01 19.86 21 16.28 21 12C21 7.72 18.01 4.14 14 3.23Z" fill="currentColor" /> </svg> ) const UnmuteIcon = () => ( - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M4.34 2.93L2.93 4.34L7.29 8.7L7 9H3V15H7L12 20V13.41L16.18 17.59C15.69 17.96 15.16 18.27 14.58 18.5V20.58C15.94 20.22 17.15 19.56 18.13 18.67L19.66 20.2L21.07 18.79L4.34 2.93ZM10 15.17L7.83 13H5V11H7.83L10 8.83V15.17ZM19 12C19 12.82 18.85 13.61 18.59 14.34L20.12 15.87C20.68 14.7 21 13.39 21 12C21 7.72 18.01 4.14 14 3.23V5.29C16.89 6.15 19 8.83 19 12ZM12 4L10.12 5.88L12 7.76V4ZM16.5 12C16.5 10.23 15.48 8.71 14 7.97V10.18L16.45 12.63C16.48 12.43 16.5 12.22 16.5 12Z" fill="currentColor" /> </svg> ) const FullscreenIcon = () => ( - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M7 14H5V19H10V17H7V14ZM5 10H7V7H10V5H5V10ZM17 17H14V19H19V14H17V17ZM14 5V7H17V10H19V5H14Z" fill="currentColor" /> </svg> ) const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { + const { t } = useTranslation() const [isPlaying, setIsPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) @@ -52,6 +54,9 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null) const [isSmallSize, setIsSmallSize] = useState(false) const containerRef = useRef<HTMLDivElement>(null) + const playPauseLabel = t(isPlaying ? 'operation.pause' : 'operation.play', { ns: 'common' }) + const toggleMuteLabel = t('operation.toggleMute', { ns: 'common' }) + const toggleFullscreenLabel = t('operation.toggleFullscreen', { ns: 'common' }) useEffect(() => { const video = videoRef.current @@ -256,7 +261,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { </div> <div className={styles.controlsContent}> <div className={styles.leftControls}> - <button type="button" className={styles.playPauseButton} onClick={togglePlayPause} data-testid="video-play-pause-button"> + <button type="button" aria-label={playPauseLabel} className={styles.playPauseButton} onClick={togglePlayPause}> {isPlaying ? <PauseIcon /> : <PlayIcon />} </button> {!isSmallSize && ( @@ -270,7 +275,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { )} </div> <div className={styles.rightControls}> - <button type="button" className={styles.muteButton} onClick={toggleMute} data-testid="video-mute-button"> + <button type="button" aria-label={toggleMuteLabel} className={styles.muteButton} onClick={toggleMute}> {isMuted ? <UnmuteIcon /> : <MuteIcon />} </button> {!isSmallSize && ( @@ -295,7 +300,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { </div> </div> )} - <button type="button" className={styles.fullscreenButton} onClick={toggleFullscreen} data-testid="video-fullscreen-button"> + <button type="button" aria-label={toggleFullscreenLabel} className={styles.fullscreenButton} onClick={toggleFullscreen}> <FullscreenIcon /> </button> </div> diff --git a/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx b/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx index 20a6298419..6a72170607 100644 --- a/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx +++ b/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx @@ -21,6 +21,11 @@ describe('VideoPlayer', () => { } as DOMRect) } + const getPlayButton = () => screen.getByRole('button', { name: 'common.operation.play' }) + const getPauseButton = () => screen.getByRole('button', { name: 'common.operation.pause' }) + const getMuteButton = () => screen.getByRole('button', { name: 'common.operation.toggleMute' }) + const getFullscreenButton = () => screen.getByRole('button', { name: 'common.operation.toggleFullscreen' }) + beforeEach(() => { vi.clearAllMocks() vi.useRealTimers() @@ -98,12 +103,12 @@ describe('VideoPlayer', () => { it('should toggle play/pause on button click', async () => { const user = userEvent.setup() render(<VideoPlayer src={mockSrc} />) - const playPauseBtn = screen.getByTestId('video-play-pause-button') + const playPauseBtn = getPlayButton() await user.click(playPauseBtn) expect(window.HTMLVideoElement.prototype.play).toHaveBeenCalled() - await user.click(playPauseBtn) + await user.click(getPauseButton()) expect(window.HTMLVideoElement.prototype.pause).toHaveBeenCalled() }) @@ -111,7 +116,7 @@ describe('VideoPlayer', () => { const user = userEvent.setup() render(<VideoPlayer src={mockSrc} />) const video = screen.getByTestId('video-element') as HTMLVideoElement - const muteBtn = screen.getByTestId('video-mute-button') + const muteBtn = getMuteButton() // Ensure volume is positive before muting video.volume = 0.7 @@ -132,7 +137,7 @@ describe('VideoPlayer', () => { it('should toggle fullscreen on button click', async () => { const user = userEvent.setup() render(<VideoPlayer src={mockSrc} />) - const fullscreenBtn = screen.getByTestId('video-fullscreen-button') + const fullscreenBtn = getFullscreenButton() await user.click(fullscreenBtn) expect(window.HTMLVideoElement.prototype.requestFullscreen).toHaveBeenCalled() @@ -166,12 +171,12 @@ describe('VideoPlayer', () => { const user = userEvent.setup() render(<VideoPlayer src={mockSrc} />) const video = screen.getByTestId('video-element') - const playPauseBtn = screen.getByTestId('video-play-pause-button') + const playPauseBtn = getPlayButton() await user.click(playPauseBtn) fireEvent(video, new Event('ended')) - expect(playPauseBtn)!.toBeInTheDocument() + expect(getPlayButton())!.toBeInTheDocument() }) it('should show/hide controls on mouse move and timeout', () => { @@ -273,7 +278,7 @@ describe('VideoPlayer', () => { try { render(<VideoPlayer src={mockSrc} />) - const playPauseBtn = screen.getByTestId('video-play-pause-button') + const playPauseBtn = getPlayButton() await user.click(playPauseBtn) @@ -290,7 +295,7 @@ describe('VideoPlayer', () => { const user = userEvent.setup() render(<VideoPlayer src={mockSrc} />) const video = screen.getByTestId('video-element') as HTMLVideoElement - const muteBtn = screen.getByTestId('video-mute-button') + const muteBtn = getMuteButton() // First click mutes — this sets volume to 0 and muted to true await user.click(muteBtn) diff --git a/web/app/components/billing/annotation-full/modal.tsx b/web/app/components/billing/annotation-full/modal.tsx index c3c6aab2ce..00f38734d1 100644 --- a/web/app/components/billing/annotation-full/modal.tsx +++ b/web/app/components/billing/annotation-full/modal.tsx @@ -28,7 +28,7 @@ const AnnotationFullModal: FC<Props> = ({ }} > <DialogContent className="w-full overflow-hidden! border-none p-0! text-left align-middle"> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <GridMask wrapperClassName="rounded-lg" canvasClassName="rounded-lg" gradientClassName="rounded-lg"> <div className="mt-6 flex cursor-pointer flex-col rounded-lg border-2 border-solid border-transparent px-7 py-6 shadow-md transition-all duration-200 ease-in-out"> diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index 1f3655a9f8..b31db0a29f 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -133,7 +133,7 @@ describe('CustomPage', () => { expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() - await user.click(screen.getByText('billing.upgradeBtn.encourageShort')) + await user.click(screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' })) expect(setShowPricingModal).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/custom/custom-page/index.tsx b/web/app/components/custom/custom-page/index.tsx index cd85a91230..0362267a6f 100644 --- a/web/app/components/custom/custom-page/index.tsx +++ b/web/app/components/custom/custom-page/index.tsx @@ -20,7 +20,13 @@ const CustomPage = () => { <div className="title-xl-semi-bold">{t('upgradeTip.title', { ns: 'custom' })}</div> <div className="system-sm-regular">{t('upgradeTip.des', { ns: 'custom' })}</div> </div> - <div className="flex h-10 w-[120px] cursor-pointer items-center justify-center rounded-3xl bg-white system-md-semibold text-text-accent shadow-xs hover:opacity-95" onClick={() => setShowPricingModal()}>{t('upgradeBtn.encourageShort', { ns: 'billing' })}</div> + <button + type="button" + className="flex h-10 w-[120px] cursor-pointer items-center justify-center rounded-3xl border-none bg-white p-0 system-md-semibold text-text-accent shadow-xs hover:opacity-95" + onClick={() => setShowPricingModal()} + > + {t('upgradeBtn.encourageShort', { ns: 'billing' })} + </button> </div> )} <CustomWebAppBrand /> diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx index ee8b4eff1e..0688adf1c6 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx @@ -60,7 +60,7 @@ describe('StatusWithAction', () => { onAction={onAction} />, ) - expect(screen.getByText('Click me')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() }) it('should not render action button when onAction is not provided', () => { @@ -92,12 +92,11 @@ describe('StatusWithAction', () => { />, ) - fireEvent.click(screen.getByText('Click me')) + fireEvent.click(screen.getByRole('button', { name: 'Click me' })) expect(onAction).toHaveBeenCalledTimes(1) }) - it('should call onAction even when disabled (style only)', () => { - // Note: disabled prop only affects styling, not actual click behavior + it('should not call onAction when disabled', () => { const onAction = vi.fn() render( <StatusWithAction @@ -108,8 +107,8 @@ describe('StatusWithAction', () => { />, ) - fireEvent.click(screen.getByText('Click me')) - expect(onAction).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Click me' })) + expect(onAction).not.toHaveBeenCalled() }) it('should apply disabled styles when disabled prop is true', () => { @@ -122,9 +121,10 @@ describe('StatusWithAction', () => { />, ) - const actionButton = screen.getByText('Click me') + const actionButton = screen.getByRole('button', { name: 'Click me' }) expect(actionButton).toHaveClass('cursor-not-allowed') expect(actionButton).toHaveClass('text-text-disabled') + expect(actionButton).toBeDisabled() }) }) diff --git a/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx b/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx index 3c2536fa5e..08053aa7bf 100644 --- a/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx +++ b/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx @@ -58,10 +58,17 @@ const StatusAction: FC<Props> = ({ <div className="relative z-10 flex h-full items-center space-x-2"> <Icon className={cn('h-4 w-4', color)} /> <div className="text-[13px] font-normal text-text-secondary">{description}</div> - {onAction && ( + {onAction && actionText && ( <> <Divider type="vertical" className="h-4!" /> - <div onClick={onAction} className={cn('cursor-pointer text-[13px] font-semibold text-text-accent', disabled && 'cursor-not-allowed text-text-disabled')}>{actionText}</div> + <button + type="button" + disabled={disabled} + onClick={onAction} + className={cn('cursor-pointer border-none bg-transparent p-0 text-left text-[13px] font-semibold text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', disabled && 'cursor-not-allowed text-text-disabled')} + > + {actionText} + </button> </> )} </div> diff --git a/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx index cc60160595..12d44a3eec 100644 --- a/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx @@ -135,7 +135,7 @@ describe('ImageList', () => { const images = createMockImages(15) render(<ImageList images={images} size="md" limit={9} />) - const moreButton = screen.getByText(/\+6/) + const moreButton = screen.getByRole('button', { name: '+6' }) fireEvent.click(moreButton) // More button should disappear diff --git a/web/app/components/datasets/common/image-list/__tests__/more.spec.tsx b/web/app/components/datasets/common/image-list/__tests__/more.spec.tsx index cc6341694a..a192c7acc6 100644 --- a/web/app/components/datasets/common/image-list/__tests__/more.spec.tsx +++ b/web/app/components/datasets/common/image-list/__tests__/more.spec.tsx @@ -47,7 +47,7 @@ describe('More', () => { const onClick = vi.fn() render(<More count={5} onClick={onClick} />) - fireEvent.click(screen.getByText('+5')) + fireEvent.click(screen.getByRole('button', { name: '+5' })) expect(onClick).toHaveBeenCalledTimes(1) }) @@ -56,7 +56,7 @@ describe('More', () => { // Should not throw expect(() => { - fireEvent.click(screen.getByText('+5')) + fireEvent.click(screen.getByRole('button', { name: '+5' })) }).not.toThrow() }) @@ -70,7 +70,7 @@ describe('More', () => { </div>, ) - fireEvent.click(screen.getByText('+5')) + fireEvent.click(screen.getByRole('button', { name: '+5' })) expect(childClick).toHaveBeenCalled() expect(parentClick).not.toHaveBeenCalled() }) diff --git a/web/app/components/datasets/common/image-list/more.tsx b/web/app/components/datasets/common/image-list/more.tsx index 255e5e4d87..39f6367556 100644 --- a/web/app/components/datasets/common/image-list/more.tsx +++ b/web/app/components/datasets/common/image-list/more.tsx @@ -17,23 +17,29 @@ const More = ({ count, onClick }: MoreProps) => { return `${(num / 1000000).toFixed(1)}M` } - const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { + const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation() e.preventDefault() onClick?.() }, [onClick]) + const label = `+${formatNumber(count)}` + return ( - <div className="relative size-8 cursor-pointer p-[0.5px]" onClick={handleClick}> + <button + type="button" + className="relative block size-8 cursor-pointer border-none bg-transparent p-[0.5px] text-center" + onClick={handleClick} + > <div className="relative z-10 size-full rounded-md border-[1.5px] border-components-panel-bg bg-divider-regular"> <div className="flex size-full items-center justify-center"> <span className="system-xs-regular text-text-tertiary"> - {`+${formatNumber(count)}`} + {label} </span> </div> </div> <div className="absolute top-1 -right-0.5 z-0 h-6 w-1 rounded-r-md bg-divider-regular" /> - </div> + </button> ) } diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx index b5c73f3422..2021320616 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx @@ -1352,7 +1352,7 @@ describe('Uploader', () => { ) expect(screen.getByText('app.dslUploader.button'))!.toBeInTheDocument() - expect(screen.getByText('app.dslUploader.browse'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'app.dslUploader.browse' }))!.toBeInTheDocument() }) it('should render file info when file is selected', () => { @@ -1436,7 +1436,7 @@ describe('Uploader', () => { />, ) - const browseLink = screen.getByText('app.dslUploader.browse') + const browseLink = screen.getByRole('button', { name: 'app.dslUploader.browse' }) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement // Mock click on input @@ -1659,7 +1659,7 @@ describe('Uploader', () => { // After click, oncancel should be set }) - const browseLink = screen.getByText('app.dslUploader.browse') + const browseLink = screen.getByRole('button', { name: 'app.dslUploader.browse' }) fireEvent.click(browseLink) // selectHandle should have triggered click on input diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx index f4263ab439..bab1d505b7 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx @@ -50,7 +50,7 @@ describe('Uploader', () => { it('should render browse link when no file', () => { render(<Uploader {...defaultProps} />) - expect(screen.getByText(/dslUploader\.browse/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /dslUploader\.browse/i })).toBeInTheDocument() }) it('should render upload icon when no file', () => { @@ -108,7 +108,7 @@ describe('Uploader', () => { const input = document.getElementById('fileUploader') as HTMLInputElement const clickSpy = vi.spyOn(input, 'click') - const browseLink = screen.getByText(/dslUploader\.browse/i) + const browseLink = screen.getByRole('button', { name: /dslUploader\.browse/i }) fireEvent.click(browseLink) expect(clickSpy).toHaveBeenCalled() diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx index f0499c6965..9b4cf2c488 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx @@ -90,9 +90,13 @@ const Uploader: FC<Props> = ({ file, updateFile, className }) => { <RiUploadCloud2Line className="h-6 w-6 text-text-tertiary" /> <div className="text-text-tertiary"> {t('dslUploader.button', { ns: 'app' })} - <span className="cursor-pointer pl-1 text-text-accent" onClick={selectHandle}> + <button + type="button" + className="inline cursor-pointer border-none bg-transparent p-0 pl-1 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={selectHandle} + > {t('dslUploader.browse', { ns: 'app' })} - </span> + </button> </div> </div> {dragging && <div ref={dragRef} className="absolute top-0 left-0 h-full w-full" />} diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx index a9b91fb1bc..32b7b1d17d 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx @@ -148,14 +148,8 @@ describe('EmptyDatasetCreationModal', () => { const mockOnHide = vi.fn() render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />) - // Act - Wait for modal to be rendered, then find the close span - // The close span is located in the modalHeader div, next to the title - const titleElement = await screen.findByText('datasetCreation.stepOne.modal.title') - const headerDiv = titleElement.parentElement - const closeButton = headerDiv?.querySelector('span') - - expect(closeButton).toBeInTheDocument() - fireEvent.click(closeButton!) + const closeButton = await screen.findByRole('button', { name: /operation\.close$/ }) + fireEvent.click(closeButton) expect(mockOnHide).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx index 0de10a8ecd..caf84e31ac 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx @@ -57,7 +57,12 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => { <div className={s.modalHeader}> <div className={s.title}>{t('stepOne.modal.title', { ns: 'datasetCreation' })}</div> - <span className={s.close} onClick={onHide} /> + <button + type="button" + className={cn(s.close, 'border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden')} + aria-label={t('operation.close', { ns: 'common' })} + onClick={onHide} + /> </div> <div className={s.tip}>{t('stepOne.modal.tip', { ns: 'datasetCreation' })}</div> <div className={s.form}> diff --git a/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx b/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx index 6839a8cc87..c016807186 100644 --- a/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx @@ -73,9 +73,9 @@ describe('FilePreview', () => { }) it('should render close button with XMarkIcon', async () => { - const { container } = renderFilePreview() + renderFilePreview() - const closeButton = container.querySelector('.cursor-pointer') + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) expect(closeButton)!.toBeInTheDocument() const xMarkIcon = closeButton?.querySelector('svg') expect(xMarkIcon)!.toBeInTheDocument() @@ -269,20 +269,18 @@ describe('FilePreview', () => { describe('User Interactions', () => { it('should call hidePreview when close button is clicked', async () => { const hidePreview = vi.fn() - const { container } = renderFilePreview({ hidePreview }) + renderFilePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(hidePreview).toHaveBeenCalledTimes(1) }) it('should call hidePreview with event object when clicked', async () => { const hidePreview = vi.fn() - const { container } = renderFilePreview({ hidePreview }) + renderFilePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) // Assert - onClick receives the event object expect(hidePreview).toHaveBeenCalled() @@ -291,9 +289,9 @@ describe('FilePreview', () => { it('should handle multiple clicks on close button', async () => { const hidePreview = vi.fn() - const { container } = renderFilePreview({ hidePreview }) + renderFilePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) fireEvent.click(closeButton) fireEvent.click(closeButton) fireEvent.click(closeButton) diff --git a/web/app/components/datasets/create/file-preview/index.tsx b/web/app/components/datasets/create/file-preview/index.tsx index 2b4041eaf2..ae1cdd3dd1 100644 --- a/web/app/components/datasets/create/file-preview/index.tsx +++ b/web/app/components/datasets/create/file-preview/index.tsx @@ -50,9 +50,14 @@ const FilePreview = ({ <div className={cn(s.previewHeader)}> <div className={cn(s.title, 'title-md-semi-bold')}> <span>{t('stepOne.filePreview', { ns: 'datasetCreation' })}</span> - <div className="flex h-6 w-6 cursor-pointer items-center justify-center" onClick={hidePreview}> - <XMarkIcon className="h-4 w-4"></XMarkIcon> - </div> + <button + type="button" + className="flex h-6 w-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={hidePreview} + > + <XMarkIcon className="h-4 w-4" aria-hidden="true"></XMarkIcon> + </button> </div> <div className={cn(s.fileName, 'system-xs-medium')}> <span>{getFileName(file)}</span> diff --git a/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx index 572677ced7..d69a3b5513 100644 --- a/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx @@ -118,9 +118,9 @@ describe('NotionPagePreview', () => { }) it('should render close button with XMarkIcon', async () => { - const { container } = await renderNotionPagePreview() + await renderNotionPagePreview() - const closeButton = container.querySelector('.cursor-pointer') + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) expect(closeButton).toBeInTheDocument() const xMarkIcon = closeButton?.querySelector('svg') expect(xMarkIcon).toBeInTheDocument() @@ -348,19 +348,18 @@ describe('NotionPagePreview', () => { describe('User Interactions', () => { it('should call hidePreview when close button is clicked', async () => { const hidePreview = vi.fn() - const { container } = await renderNotionPagePreview({ hidePreview }) + await renderNotionPagePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(hidePreview).toHaveBeenCalledTimes(1) }) it('should handle multiple clicks on close button', async () => { const hidePreview = vi.fn() - const { container } = await renderNotionPagePreview({ hidePreview }) + await renderNotionPagePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) fireEvent.click(closeButton) fireEvent.click(closeButton) fireEvent.click(closeButton) diff --git a/web/app/components/datasets/create/notion-page-preview/index.tsx b/web/app/components/datasets/create/notion-page-preview/index.tsx index 116090413a..c2eed225c0 100644 --- a/web/app/components/datasets/create/notion-page-preview/index.tsx +++ b/web/app/components/datasets/create/notion-page-preview/index.tsx @@ -52,9 +52,14 @@ const NotionPagePreview = ({ <div className={cn(s.previewHeader)}> <div className={cn(s.title, 'title-md-semi-bold')}> <span>{t('stepOne.pagePreview', { ns: 'datasetCreation' })}</span> - <div className="flex h-6 w-6 cursor-pointer items-center justify-center" onClick={hidePreview}> - <XMarkIcon className="h-4 w-4"></XMarkIcon> - </div> + <button + type="button" + className="flex h-6 w-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={hidePreview} + > + <XMarkIcon className="h-4 w-4" aria-hidden="true"></XMarkIcon> + </button> </div> <div className={cn(s.fileName, 'system-xs-medium')}> <NotionIcon diff --git a/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx index 840c5cc0e5..5f0cd67fbf 100644 --- a/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx @@ -84,9 +84,8 @@ describe('StopEmbeddingModal', () => { it('should render buttons in correct order (cancel first, then confirm)', () => { renderStopEmbeddingModal({ show: true }) - // Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonCancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonConfirm' })).toBeInTheDocument() }) it('should render confirm button with primary variant styling', () => { @@ -290,48 +289,28 @@ describe('StopEmbeddingModal', () => { }) describe('Close Icon', () => { - it('should call onHide when close span is clicked', async () => { + it('should call onHide when close button is clicked', async () => { const onConfirm = vi.fn() const onHide = vi.fn() - const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) + renderStopEmbeddingModal({ onConfirm, onHide }) - // Act - Find the close span (it should be the span with onClick handler) - const spans = container.querySelectorAll('span') - const closeSpan = Array.from(spans).find(span => - span.className && span.getAttribute('class')?.includes('close'), - ) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) + }) - if (closeSpan) { - await act(async () => { - fireEvent.click(closeSpan) - }) - - expect(onHide).toHaveBeenCalledTimes(1) - } - else { - // If no close span found with class, just verify the modal renders - // If no close span found with class, just verify the modal renders - expect(screen.getByText('datasetCreation.stepThree.modelTitle'))!.toBeInTheDocument() - } + expect(onHide).toHaveBeenCalledTimes(1) }) - it('should not call onConfirm when close span is clicked', async () => { + it('should not call onConfirm when close button is clicked', async () => { const onConfirm = vi.fn() const onHide = vi.fn() - const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) + renderStopEmbeddingModal({ onConfirm, onHide }) - const spans = container.querySelectorAll('span') - const closeSpan = Array.from(spans).find(span => - span.className && span.getAttribute('class')?.includes('close'), - ) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) + }) - if (closeSpan) { - await act(async () => { - fireEvent.click(closeSpan) - }) - - expect(onConfirm).not.toHaveBeenCalled() - } + expect(onConfirm).not.toHaveBeenCalled() }) }) @@ -444,8 +423,8 @@ describe('StopEmbeddingModal', () => { it('should have buttons container with flex-row-reverse', () => { renderStopEmbeddingModal({ show: true }) - const buttons = screen.getAllByRole('button') - expect(buttons[0]!.closest('div'))!.toHaveClass('flex', 'flex-row-reverse') + const confirmButton = screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonConfirm' }) + expect(confirmButton.closest('div'))!.toHaveClass('flex', 'flex-row-reverse') }) it('should render title and content elements', () => { @@ -455,11 +434,11 @@ describe('StopEmbeddingModal', () => { expect(screen.getByText('datasetCreation.stepThree.modelContent'))!.toBeInTheDocument() }) - it('should render two buttons', () => { + it('should render two action buttons', () => { renderStopEmbeddingModal({ show: true }) - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonCancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonConfirm' })).toBeInTheDocument() }) }) @@ -563,8 +542,9 @@ describe('StopEmbeddingModal', () => { it('should have semantic button elements', () => { renderStopEmbeddingModal({ show: true }) - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) + expect(screen.getByRole('button', { name: /operation\.close$/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonCancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonConfirm' })).toBeInTheDocument() }) it('should have accessible text content', () => { diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.tsx b/web/app/components/datasets/create/stop-embedding-modal/index.tsx index 41faf58b09..a696f90585 100644 --- a/web/app/components/datasets/create/stop-embedding-modal/index.tsx +++ b/web/app/components/datasets/create/stop-embedding-modal/index.tsx @@ -41,7 +41,12 @@ const StopEmbeddingModal = ({ > <AlertDialogContent className={cn(s.modal, 'max-w-[480px]! overflow-hidden! border-none px-8 py-6 text-left align-middle shadow-xl')}> <div className={s.icon} /> - <span className={s.close} onClick={onHide} /> + <button + type="button" + className={cn(s.close, 'border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden')} + aria-label={t('operation.close', { ns: 'common' })} + onClick={onHide} + /> <AlertDialogTitle className={s.title}>{t('stepThree.modelTitle', { ns: 'datasetCreation' })}</AlertDialogTitle> <AlertDialogDescription className={s.content}>{t('stepThree.modelContent', { ns: 'datasetCreation' })}</AlertDialogDescription> <AlertDialogActions className="flex-row-reverse gap-0 p-0"> diff --git a/web/app/components/datasets/create/website/__tests__/preview.spec.tsx b/web/app/components/datasets/create/website/__tests__/preview.spec.tsx index 9fe447c95c..da8c270509 100644 --- a/web/app/components/datasets/create/website/__tests__/preview.spec.tsx +++ b/web/app/components/datasets/create/website/__tests__/preview.spec.tsx @@ -84,9 +84,7 @@ describe('WebsitePreview', () => { render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />) - // Assert - the close button container is a div with cursor-pointer - const closeButton = screen.getByText(/pagePreview/i).parentElement?.querySelector('.cursor-pointer') - expect(closeButton).toBeInTheDocument() + expect(screen.getByRole('button', { name: /operation\.close$/ })).toBeInTheDocument() }) }) @@ -95,11 +93,7 @@ describe('WebsitePreview', () => { const payload = createPayload() render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />) - // Act - find the close button div with cursor-pointer class - const closeButton = screen.getByText(/pagePreview/i) - .closest('[class*="title"]')! - .querySelector('.cursor-pointer') as HTMLElement - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(mockHidePreview).toHaveBeenCalledTimes(1) }) @@ -108,9 +102,7 @@ describe('WebsitePreview', () => { const payload = createPayload() render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />) - const closeButton = screen.getByText(/pagePreview/i) - .closest('[class*="title"]')! - .querySelector('.cursor-pointer') as HTMLElement + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) fireEvent.click(closeButton) fireEvent.click(closeButton) diff --git a/web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx index bcfcf39060..5922a11cce 100644 --- a/web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx @@ -54,7 +54,7 @@ describe('UrlInput', () => { render(<UrlInput {...props} />) // Assert - find button by data-testid when in loading state - const runButton = screen.getByTestId('url-input-run-button') + const runButton = screen.getByRole('button', { name: /run/i }) expect(runButton).toBeInTheDocument() // Button text should be empty when running expect(runButton).not.toHaveTextContent(/run/i) @@ -67,7 +67,7 @@ describe('UrlInput', () => { render(<UrlInput {...props} />) // Assert - find button by data-testid when in loading state - const runButton = screen.getByTestId('url-input-run-button') + const runButton = screen.getByRole('button', { name: /run/i }) expect(runButton).toBeInTheDocument() // Verify button is empty (loading state removes text) @@ -143,7 +143,7 @@ describe('UrlInput', () => { const props = createUrlInputProps({ onRun, isRunning: true }) render(<UrlInput {...props} />) - const runButton = screen.getByTestId('url-input-run-button') + const runButton = screen.getByRole('button', { name: /run/i }) fireEvent.click(runButton) expect(onRun).not.toHaveBeenCalled() @@ -161,7 +161,7 @@ describe('UrlInput', () => { rerender(<UrlInput isRunning={true} onRun={onRun} />) // Find and click the button by data-testid (loading state has no text) - const runButton = screen.getByTestId('url-input-run-button') + const runButton = screen.getByRole('button', { name: /run/i }) fireEvent.click(runButton) // Assert - onRun should not be called due to early return at line 28 @@ -173,7 +173,7 @@ describe('UrlInput', () => { const props = createUrlInputProps({ onRun, isRunning: true }) render(<UrlInput {...props} />) - const runButton = screen.getByTestId('url-input-run-button') + const runButton = screen.getByRole('button', { name: /run/i }) fireEvent.click(runButton) fireEvent.click(runButton) fireEvent.click(runButton) @@ -194,7 +194,7 @@ describe('UrlInput', () => { rerender(<UrlInput {...props} isRunning={true} />) // Assert - find button by data-testid and verify it's now in loading state - const runButton = screen.getByTestId('url-input-run-button') + const runButton = screen.getByRole('button', { name: /run/i }) expect(runButton).toBeInTheDocument() // When loading, the button text should be empty expect(runButton).not.toHaveTextContent(/run/i) diff --git a/web/app/components/datasets/create/website/jina-reader/base/url-input.tsx b/web/app/components/datasets/create/website/jina-reader/base/url-input.tsx index 6b78833ccf..77887c4301 100644 --- a/web/app/components/datasets/create/website/jina-reader/base/url-input.tsx +++ b/web/app/components/datasets/create/website/jina-reader/base/url-input.tsx @@ -42,7 +42,7 @@ const UrlInput: FC<Props> = ({ onClick={handleOnRun} className="ml-2" loading={isRunning} - data-testid="url-input-run-button" + aria-label={t(`${I18N_PREFIX}.run`, { ns: 'datasetCreation' })} > {!isRunning ? t(`${I18N_PREFIX}.run`, { ns: 'datasetCreation' }) : ''} </Button> diff --git a/web/app/components/datasets/create/website/preview.tsx b/web/app/components/datasets/create/website/preview.tsx index a46437325f..edbd72d9a1 100644 --- a/web/app/components/datasets/create/website/preview.tsx +++ b/web/app/components/datasets/create/website/preview.tsx @@ -22,9 +22,14 @@ const WebsitePreview = ({ <div className={cn(s.previewHeader)}> <div className={cn(s.title, 'title-md-semi-bold')}> <span>{t('stepOne.pagePreview', { ns: 'datasetCreation' })}</span> - <div className="flex h-6 w-6 cursor-pointer items-center justify-center" onClick={hidePreview}> - <XMarkIcon className="h-4 w-4"></XMarkIcon> - </div> + <button + type="button" + className="flex h-6 w-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={hidePreview} + > + <XMarkIcon className="h-4 w-4" aria-hidden="true"></XMarkIcon> + </button> </div> <div className="title-sm-semi-bold wrap-break-word text-text-primary"> {payload.title} diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index 7dc184aee4..d4bd828de6 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -192,13 +192,15 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele closeOperationsMenu() setShowModal(true) }, [closeOperationsMenu]) - const handleDownloadClick = useCallback((evt: React.MouseEvent<HTMLDivElement>) => { + const handleDownloadClick = useCallback((evt: React.MouseEvent<HTMLElement>) => { evt.preventDefault() evt.stopPropagation() evt.nativeEvent.stopImmediatePropagation?.() closeOperationsMenu() void handleDownload() }, [closeOperationsMenu, handleDownload]) + const menuActionClassName = cn(s.actionItem, 'border-none bg-transparent') + const menuDeleteActionClassName = cn(menuActionClassName, s.deleteActionItem, 'group') return ( <div className="flex items-center" onClick={e => e.stopPropagation()}> {isListScene && !embeddingAvailable && (<Switch checked={false} onCheckedChange={noop} disabled={true} size="md" />)} @@ -227,7 +229,7 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele aria-label={t('list.action.settings', { ns: 'datasetDocuments' })} className={cn('mr-2 cursor-pointer rounded-lg', !isListScene ? 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover' - : 'p-0.5 hover:bg-state-base-hover')} + : 'border-none bg-transparent p-0.5 hover:bg-state-base-hover')} onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)} > <span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-components-button-secondary-text" /> @@ -266,68 +268,68 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele <div className="w-full py-1"> {!archived && ( <> - <div className={s.actionItem} onClick={handleShowRename}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleShowRename}> <span aria-hidden className="i-ri-edit-line h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span> - </div> + </button> {data_source_type === DataSourceType.FILE && ( - <div className={s.actionItem} onClick={handleDownloadClick}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleDownloadClick}> <span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span> - </div> + </button> )} {['notion_import', DataSourceType.WEB].includes(data_source_type) && ( - <div className={s.actionItem} onClick={() => handleMenuOperation('sync')}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('sync')}> <span aria-hidden className="i-ri-loop-left-line h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.action.sync', { ns: 'datasetDocuments' })}</span> - </div> + </button> )} {IS_CE_EDITION && ( - <div className={s.actionItem} onClick={() => handleMenuOperation('summary')}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('summary')}> <span aria-hidden className="i-custom-vender-knowledge-search-lines-sparkle h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span> - </div> + </button> )} <Divider className="my-1" /> </> )} {archived && data_source_type === DataSourceType.FILE && ( <> - <div className={s.actionItem} onClick={handleDownloadClick}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleDownloadClick}> <span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span> - </div> + </button> <Divider className="my-1" /> </> )} {!archived && display_status?.toLowerCase() === 'indexing' && ( - <div className={s.actionItem} onClick={() => handleMenuOperation('pause')}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('pause')}> <span aria-hidden className="i-ri-pause-circle-line h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.action.pause', { ns: 'datasetDocuments' })}</span> - </div> + </button> )} {!archived && display_status?.toLowerCase() === 'paused' && ( - <div className={s.actionItem} onClick={() => handleMenuOperation('resume')}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('resume')}> <span aria-hidden className="i-ri-play-circle-line h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.action.resume', { ns: 'datasetDocuments' })}</span> - </div> + </button> )} {!archived && ( - <div className={s.actionItem} onClick={() => handleMenuOperation('archive')}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('archive')}> <span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.action.archive', { ns: 'datasetDocuments' })}</span> - </div> + </button> )} {archived && ( - <div className={s.actionItem} onClick={() => handleMenuOperation('un_archive')}> + <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('un_archive')}> <span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" /> <span className={s.actionName}>{t('list.action.unarchive', { ns: 'datasetDocuments' })}</span> - </div> + </button> )} - <div className={cn(s.actionItem, s.deleteActionItem, 'group')} onClick={handleDeleteClick}> + <button type="button" className={cn(menuDeleteActionClassName, 'text-left')} onClick={handleDeleteClick}> <span aria-hidden className="i-ri-delete-bin-line h-4 w-4 text-text-tertiary group-hover:text-text-destructive" /> <span className={cn(s.actionName, 'group-hover:text-text-destructive')}>{t('list.action.delete', { ns: 'datasetDocuments' })}</span> - </div> + </button> </div> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx index b0cbedd428..4ec1565090 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx @@ -337,7 +337,7 @@ describe('FileList', () => { render(<FileList {...props} />) // Act - Click the clear icon div (it contains RiCloseCircleFill icon) - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) @@ -351,7 +351,7 @@ describe('FileList', () => { fireEvent.change(input, { target: { value: 'some-search' } }) // Act - Find and click the clear icon - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx index dcb1922fe9..c4ce60274b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx @@ -320,7 +320,7 @@ describe('Header', () => { render(<Header {...props} />) // Act - Find and click the clear icon container - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearButton)!.toBeInTheDocument() fireEvent.click(clearButton!) @@ -332,7 +332,7 @@ describe('Header', () => { render(<Header {...props} />) // Act & Assert - Clear icon should not be visible - const clearIcon = screen.queryByTestId('input-clear') + const clearIcon = screen.queryByRole('button', { name: 'common.operation.clear' }) expect(clearIcon).not.toBeInTheDocument() }) @@ -341,7 +341,7 @@ describe('Header', () => { render(<Header {...props} />) // Act & Assert - Clear icon should be visible - const clearIcon = screen.getByTestId('input-clear') + const clearIcon = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearIcon)!.toBeInTheDocument() }) }) @@ -582,9 +582,9 @@ describe('Header', () => { const { rerender } = render(<Header {...props} />) // Act - Click clear, rerender, click again - fireEvent.click(screen.getByTestId('input-clear')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) rerender(<Header {...props} />) - fireEvent.click(screen.getByTestId('input-clear')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2) }) 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 900c12a416..e717475b38 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -311,19 +311,19 @@ describe('DocumentDetail', () => { describe('Navigation', () => { it('should navigate back when back button clicked', () => { render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) - fireEvent.click(screen.getByTestId('document-detail-back-button')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.back' })) expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents') }) it('should expose aria label for back button', () => { render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) - expect(screen.getByTestId('document-detail-back-button')).toHaveAttribute('aria-label') + expect(screen.getByRole('button', { name: 'common.operation.back' })).toHaveAttribute('aria-label') }) it('should preserve query params when navigating back', () => { mocks.state.searchParams = 'page=2&status=active' render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) - fireEvent.click(screen.getByTestId('document-detail-back-button')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.back' })) expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents?page=2&status=active') }) }) diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index f6ebf0d29d..c361c8be13 100644 --- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -201,13 +201,9 @@ describe('NewSegmentModal', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - const { container } = render(<NewSegmentModal {...defaultProps} onCancel={mockOnCancel} />) + render(<NewSegmentModal {...defaultProps} onCancel={mockOnCancel} />) - // Act - find and click close button (RiCloseLine icon wrapper) - const closeButtons = container.querySelectorAll('.cursor-pointer') - // The close button is the second cursor-pointer element - if (closeButtons.length > 1) - fireEvent.click(closeButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) @@ -350,12 +346,9 @@ describe('NewSegmentModal', () => { }) it('should call toggleFullScreen when expand button is clicked', () => { - const { container } = render(<NewSegmentModal {...defaultProps} />) + render(<NewSegmentModal {...defaultProps} />) - // Act - click the expand button (first cursor-pointer) - const expandButtons = container.querySelectorAll('.cursor-pointer') - if (expandButtons.length > 0) - fireEvent.click(expandButtons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.zoomIn' })) expect(mockToggleFullScreen).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx index 0cd2a31491..a4d7871719 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx @@ -74,7 +74,7 @@ describe('CSVUploader', () => { render(<CSVUploader {...defaultProps} />) expect(screen.getByText(/list\.batchModal\.csvUploadTitle/i)).toBeInTheDocument() - expect(screen.getByText(/list\.batchModal\.browse/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /list\.batchModal\.browse/i })).toBeInTheDocument() }) it('should render hidden file input', () => { @@ -139,11 +139,32 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(fileInput, 'click') - fireEvent.click(screen.getByText(/list\.batchModal\.browse/i)) + fireEvent.click(screen.getByRole('button', { name: /list\.batchModal\.browse/i })) expect(clickSpy).toHaveBeenCalled() }) + it('should clear the selected file when delete is clicked', () => { + const mockUpdateFile = vi.fn() + const mockFile: FileItem = { + fileID: 'file-1', + file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, + progress: 100, + } + const { container } = render(<CSVUploader {...defaultProps} file={mockFile} updateFile={mockUpdateFile} />) + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(fileInput, 'value', { + configurable: true, + value: 'C:\\fakepath\\test.csv', + writable: true, + }) + + fireEvent.click(screen.getByRole('button', { name: /operation\.delete$/ })) + + expect(fileInput.value).toBe('') + expect(mockUpdateFile).toHaveBeenCalledWith() + }) + it('should call updateFile when file is selected', async () => { const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx index 0e86c057cc..15d6d803d4 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx @@ -175,7 +175,13 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => { <CSVIcon className="shrink-0" /> <div className="text-text-secondary"> {t('list.batchModal.csvUploadTitle', { ns: 'datasetDocuments' })} - <span className="cursor-pointer text-text-accent" onClick={selectHandle}>{t('list.batchModal.browse', { ns: 'datasetDocuments' })}</span> + <button + type="button" + className="inline cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={selectHandle} + > + {t('list.batchModal.browse', { ns: 'datasetDocuments' })} + </button> </div> </div> {dragging && <div ref={dragRef} className="absolute top-0 left-0 h-full w-full" />} @@ -197,9 +203,14 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => { )} <Button onClick={selectHandle}>{t('stepOne.uploader.change', { ns: 'datasetCreation' })}</Button> <div className="mx-2 h-4 w-px bg-text-secondary" /> - <div className="cursor-pointer p-2" onClick={removeFile}> - <RiDeleteBinLine className="h-4 w-4 text-text-secondary" /> - </div> + <button + type="button" + className="cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.delete', { ns: 'common' })} + onClick={removeFile} + > + <RiDeleteBinLine className="h-4 w-4 text-text-secondary" aria-hidden="true" /> + </button> </div> </div> )} diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx index b3bec1b0b8..a7de0258c0 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx @@ -129,23 +129,19 @@ describe('ChildSegmentDetail', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - const { container } = render( + render( <ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />, ) - const closeButtons = container.querySelectorAll('.cursor-pointer') - if (closeButtons.length > 1) - fireEvent.click(closeButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - const { container } = render(<ChildSegmentDetail {...defaultProps} />) + render(<ChildSegmentDetail {...defaultProps} />) - const expandButtons = container.querySelectorAll('.cursor-pointer') - if (expandButtons.length > 0) - fireEvent.click(expandButtons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.zoomIn' })) expect(mockToggleFullScreen).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index d993ff849e..1fa92b5b0d 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -141,23 +141,19 @@ describe('NewChildSegmentModal', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - const { container } = render( + render( <NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />, ) - const closeButtons = container.querySelectorAll('.cursor-pointer') - if (closeButtons.length > 1) - fireEvent.click(closeButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - const { container } = render(<NewChildSegmentModal {...defaultProps} />) + render(<NewChildSegmentModal {...defaultProps} />) - const expandButtons = container.querySelectorAll('.cursor-pointer') - if (expandButtons.length > 0) - fireEvent.click(expandButtons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.zoomIn' })) expect(mockToggleFullScreen).toHaveBeenCalled() }) 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 1f10053596..8ac57759cd 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 @@ -278,21 +278,17 @@ describe('SegmentDetail', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - const { container } = render(<SegmentDetail {...defaultProps} onCancel={mockOnCancel} />) + render(<SegmentDetail {...defaultProps} onCancel={mockOnCancel} />) - const closeButtons = container.querySelectorAll('.cursor-pointer') - if (closeButtons.length > 1) - fireEvent.click(closeButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - const { container } = render(<SegmentDetail {...defaultProps} />) + render(<SegmentDetail {...defaultProps} />) - const expandButtons = container.querySelectorAll('.cursor-pointer') - if (expandButtons.length > 0) - fireEvent.click(expandButtons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.zoomIn' })) expect(mockToggleFullScreen).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx index 88767d628f..c4b3fc89f4 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx @@ -99,12 +99,24 @@ const ChildSegmentDetail: FC<IChildSegmentDetailProps> = ({ <Divider type="vertical" className="mr-2 ml-4 h-3.5 bg-divider-regular" /> </> )} - <div className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={toggleFullScreen}> - {fullScreen ? <RiCollapseDiagonalLine className="h-4 w-4 text-text-tertiary" /> : <RiExpandDiagonalLine className="h-4 w-4 text-text-tertiary" />} - </div> - <div className="flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={onCancel}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t(fullScreen ? 'operation.zoomOut' : 'operation.zoomIn', { ns: 'common' })} + className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" + onClick={toggleFullScreen} + > + {fullScreen + ? <RiCollapseDiagonalLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + : <RiExpandDiagonalLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" />} + </button> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" + onClick={onCancel} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> </div> <div className={cn('flex w-full grow', fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3')}> diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx index 9c170f3a32..15cd617aaa 100644 --- a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx @@ -69,7 +69,7 @@ describe('MenuBar', () => { it('should call onInputChange with empty string when input is cleared', () => { render(<MenuBar {...defaultProps} inputValue="some text" />) - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) fireEvent.click(clearButton) expect(defaultProps.onInputChange).toHaveBeenCalledWith('') }) diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx index 386757a5b2..572cf65bf7 100644 --- a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx +++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx @@ -111,12 +111,22 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({ <Divider type="vertical" className="mr-2 ml-4 h-3.5 bg-divider-regular" /> </> )} - <div className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={toggleFullScreen}> - <RiExpandDiagonalLine className="h-4 w-4 text-text-tertiary" /> - </div> - <div className="flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={handleCancel.bind(null, 'esc')}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.zoomIn', { ns: 'common' })} + className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" + onClick={toggleFullScreen} + > + <RiExpandDiagonalLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" + onClick={handleCancel.bind(null, 'esc')} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> </div> <div className={cn('flex w-full grow', fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3')}> diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx index fd431af95d..6df95ddaba 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx @@ -221,8 +221,8 @@ describe('SegmentCard', () => { />, ) - expect(screen.getByTestId('segment-edit-button')).toBeInTheDocument() - expect(screen.getByTestId('segment-delete-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.edit' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.delete' })).toBeInTheDocument() expect(screen.getByRole('switch')).toBeInTheDocument() }) @@ -270,7 +270,7 @@ describe('SegmentCard', () => { />, ) - const deleteButton = screen.getByTestId('segment-delete-button') + const deleteButton = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(deleteButton) await waitFor(() => { @@ -290,7 +290,7 @@ describe('SegmentCard', () => { />, ) - const deleteButton = screen.getByTestId('segment-delete-button') + const deleteButton = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(deleteButton) await waitFor(() => { @@ -365,7 +365,7 @@ describe('SegmentCard', () => { />, ) - const editButton = screen.getByTestId('segment-edit-button') + const editButton = screen.getByRole('button', { name: 'common.operation.edit' }) fireEvent.click(editButton) expect(onClickEdit).toHaveBeenCalledTimes(1) @@ -385,7 +385,7 @@ describe('SegmentCard', () => { />, ) - const deleteButton = screen.getByTestId('segment-delete-button') + const deleteButton = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(deleteButton) await waitFor(() => { @@ -437,7 +437,7 @@ describe('SegmentCard', () => { />, ) - const editButton = screen.getByTestId('segment-edit-button') + const editButton = screen.getByRole('button', { name: 'common.operation.edit' }) fireEvent.click(editButton) expect(onClickEdit).toHaveBeenCalledTimes(1) diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx index 865ffbce15..c30d123052 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx @@ -187,15 +187,14 @@ const SegmentCard: FC<ISegmentCardProps> = ({ render={( <button type="button" - aria-label="Edit" - data-testid="segment-edit-button" - className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover" + aria-label={t('operation.edit', { ns: 'common' })} + className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent p-0 hover:bg-state-base-hover" onClick={(e) => { e.stopPropagation() onClickEdit?.() }} > - <RiEditLine className="h-4 w-4 text-text-tertiary" /> + <RiEditLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> </button> )} /> @@ -206,15 +205,14 @@ const SegmentCard: FC<ISegmentCardProps> = ({ render={( <button type="button" - aria-label="Delete" - data-testid="segment-delete-button" - className="group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover" + aria-label={t('operation.delete', { ns: 'common' })} + className="group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent p-0 hover:bg-state-destructive-hover" onClick={(e) => { e.stopPropagation() setShowModal(true) }} > - <RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover/delete:text-text-destructive" /> + <RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover/delete:text-text-destructive" aria-hidden="true" /> </button> )} /> @@ -269,7 +267,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({ ? ( <button type="button" - className="mt-0.5 mb-2 system-xs-semibold-uppercase text-text-accent" + className="mt-0.5 mb-2 border-none bg-transparent p-0 text-left system-xs-semibold-uppercase text-text-accent" onClick={() => onClick?.()} > {t('operation.viewMore', { ns: 'common' })} 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 fad94819b0..0713289ef4 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx @@ -151,16 +151,26 @@ export function SegmentDetail({ <Divider type="vertical" className="mr-2 ml-4 h-3.5 bg-divider-regular" /> </> )} - <div className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={toggleFullScreen}> + <button + type="button" + aria-label={t(fullScreen ? 'operation.zoomOut' : 'operation.zoomIn', { ns: 'common' })} + className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" + onClick={toggleFullScreen} + > { fullScreen - ? <RiCollapseDiagonalLine className="h-4 w-4 text-text-tertiary" /> - : <RiExpandDiagonalLine className="h-4 w-4 text-text-tertiary" /> + ? <RiCollapseDiagonalLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + : <RiExpandDiagonalLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> } - </div> - <div className="flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={onCancel}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + </button> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" + onClick={onCancel} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> </div> <div className={cn( diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 43fc99851d..732d7ffb28 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -200,11 +200,10 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { <div className="flex min-h-16 flex-wrap items-center justify-between border-b border-b-divider-subtle py-2.5 pr-4 pl-3"> <button type="button" - data-testid="document-detail-back-button" aria-label={backButtonLabel} title={backButtonLabel} onClick={backToPrev} - className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-components-button-tertiary-bg" + className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 hover:bg-components-button-tertiary-bg focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" > <span aria-hidden="true" diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx index 55295579f0..d09077846e 100644 --- a/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx @@ -133,7 +133,7 @@ describe('DocumentTypeDisplay', () => { const onClick = vi.fn() render(<DocumentTypeDisplay displayType="book" showChangeLink={true} onChangeClick={onClick} />) - fireEvent.click(screen.getByText(/operation\.change/)) + fireEvent.click(screen.getByRole('button', { name: /operation\.change/ })) expect(onClick).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx index 5dfa02aa80..9cce66822b 100644 --- a/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx +++ b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx @@ -122,9 +122,13 @@ export const DocumentTypeDisplay: FC<DocumentTypeDisplayProps> = ({ {showChangeLink && ( <div className="ml-1 inline-flex items-center gap-1"> · - <div onClick={onChangeClick} className="cursor-pointer hover:text-text-accent"> + <button + type="button" + className="inline cursor-pointer border-none bg-transparent p-0 text-left hover:text-text-accent" + onClick={onChangeClick} + > {t('operation.change', { ns: 'common' })} - </div> + </button> </div> )} </> diff --git a/web/app/components/datasets/documents/detail/new-segment.tsx b/web/app/components/datasets/documents/detail/new-segment.tsx index 0426bb9584..5bf5641735 100644 --- a/web/app/components/datasets/documents/detail/new-segment.tsx +++ b/web/app/components/datasets/documents/detail/new-segment.tsx @@ -142,12 +142,22 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({ <Divider type="vertical" className="mr-2 ml-4 h-3.5 bg-divider-regular" /> </> )} - <div className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={toggleFullScreen}> - <RiExpandDiagonalLine className="h-4 w-4 text-text-tertiary" /> - </div> - <div className="flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={handleCancel.bind(null, 'esc')}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.zoomIn', { ns: 'common' })} + className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" + onClick={toggleFullScreen} + > + <RiExpandDiagonalLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" + onClick={handleCancel.bind(null, 'esc')} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> </div> <div className={cn('flex grow', fullScreen ? 'w-full flex-row justify-center gap-x-8 px-6 pt-6' : 'flex-col gap-y-1 px-4 py-3')}> diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 0783af8569..b31641a076 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -106,7 +106,7 @@ describe('SegmentAdd', () => { />, ) - fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) + fireEvent.click(screen.getByRole('button', { name: /list\.batchModal\.ok/i })) expect(mockClearImportStatus).toHaveBeenCalledTimes(1) }) @@ -121,7 +121,7 @@ describe('SegmentAdd', () => { />, ) - fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) + fireEvent.click(screen.getByRole('button', { name: /list\.batchModal\.ok/i })) expect(mockClearImportStatus).toHaveBeenCalledTimes(1) }) 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 da4a0109c0..43b30f7906 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -80,7 +80,13 @@ export function SegmentAdd({ <span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span> </div> <div className="m-1 inline-flex items-center"> - <span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span> + <button + type="button" + className="cursor-pointer rounded-md border-none bg-transparent px-1.5 py-1 text-left system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={clearImportStatus} + > + {t('list.batchModal.ok', { ns: 'datasetDocuments' })} + </button> </div> <div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40" /> </div> @@ -92,7 +98,13 @@ export function SegmentAdd({ <span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span> </div> <div className="m-1 inline-flex items-center"> - <span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span> + <button + type="button" + className="cursor-pointer rounded-md border-none bg-transparent px-1.5 py-1 text-left system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={clearImportStatus} + > + {t('list.batchModal.ok', { ns: 'datasetDocuments' })} + </button> </div> <div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40" /> </div> @@ -111,7 +123,7 @@ export function SegmentAdd({ > <button type="button" - className={`inline-flex items-center rounded-l-lg border-r border-r-divider-subtle px-2.5 py-2 + className={`inline-flex items-center rounded-l-lg border-0 border-r border-r-divider-subtle bg-transparent px-2.5 py-2 text-left hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`} onClick={handleAddClick} disabled={embedding} @@ -126,7 +138,7 @@ export function SegmentAdd({ aria-label={t('list.action.batchAdd', { ns: 'datasetDocuments' })} disabled={embedding} className={cn( - `rounded-l-none rounded-r-lg border-0 p-2 backdrop-blur-[5px] + `rounded-l-none rounded-r-lg border-0 bg-transparent p-2 backdrop-blur-[5px] hover:bg-state-base-hover disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent`, isBatchMenuOpen && 'bg-state-base-hover', )} diff --git a/web/app/components/datasets/documents/style.module.css b/web/app/components/datasets/documents/style.module.css index 6df8d0f5f8..dccc964942 100644 --- a/web/app/components/datasets/documents/style.module.css +++ b/web/app/components/datasets/documents/style.module.css @@ -17,7 +17,7 @@ @apply !p-2 !border-[0.5px] !border-components-button-secondary-border !bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 hover:!border-components-button-secondary-border-hover hover:!bg-components-button-secondary-bg-hover; } .actionItem { - @apply h-9 py-2 px-3 mx-1 flex items-center gap-2 hover:bg-state-base-hover rounded-lg cursor-pointer; + @apply h-9 py-2 px-3 mx-1 flex items-center gap-2 rounded-lg border-none bg-transparent text-left hover:bg-state-base-hover cursor-pointer; } .deleteActionItem { @apply hover:!bg-state-destructive-hover; diff --git a/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx index aa64962aa6..366732cbbc 100644 --- a/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx @@ -22,8 +22,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, variant }: { children: React.ReactNode, onClick: () => void, variant?: string }) => ( - <button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}> + Button: ({ children, onClick }: { children: React.ReactNode, onClick: () => void, variant?: string }) => ( + <button onClick={onClick}> {children} </button> ), @@ -104,13 +104,19 @@ describe('ModifyRetrievalModal', () => { it('should call onHide when cancel button clicked', () => { render(<ModifyRetrievalModal {...defaultProps} />) - fireEvent.click(screen.getByTestId('cancel-button')) + fireEvent.click(screen.getByRole('button', { name: /operation\.cancel$/ })) + expect(defaultProps.onHide).toHaveBeenCalled() + }) + + it('should call onHide when close button clicked', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(defaultProps.onHide).toHaveBeenCalled() }) it('should call onSave with retrieval config when save clicked', () => { render(<ModifyRetrievalModal {...defaultProps} />) - fireEvent.click(screen.getByTestId('save-button')) + fireEvent.click(screen.getByRole('button', { name: /operation\.save$/ })) expect(defaultProps.onSave).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx index b4d017d0df..90e52bf271 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx @@ -127,7 +127,7 @@ describe('ChunkDetailModal', () => { it('should call onHide when close button is clicked', () => { render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) - fireEvent.click(screen.getByTestId('modal-close-button')) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(onHide).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx index 44a7dc2c89..425066dbb6 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx @@ -61,8 +61,7 @@ describe('ResultItemFooter', () => { />, ) - const openButton = screen.getByText(/open/i) - fireEvent.click(openButton) + fireEvent.click(screen.getByRole('button', { name: /open/i })) expect(mockShowDetailModal).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx b/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx index 6f789a81ce..2e3c1f95db 100644 --- a/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx +++ b/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx @@ -61,7 +61,6 @@ const ChunkDetailModal = ({ > <DialogContent className={cn('max-h-none overflow-hidden! border-none p-6 text-left align-middle', isParentChildRetrieval ? 'w-[1200px] max-w-none! min-w-[1200px]!' : 'w-[800px] max-w-none! min-w-[800px]!')}> <DialogCloseButton - data-testid="modal-close-button" onClick={(e) => { e.stopPropagation() onHide() diff --git a/web/app/components/datasets/hit-testing/components/result-item-external.tsx b/web/app/components/datasets/hit-testing/components/result-item-external.tsx index 9f68e054d1..4f7f65b8a2 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-external.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-external.tsx @@ -46,7 +46,7 @@ const ResultItemExternal: FC<Props> = ({ payload, positionId }) => { }} > <DialogContent className="w-full min-w-[800px]! overflow-hidden! border-none text-left align-middle"> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> {t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })} </DialogTitle> diff --git a/web/app/components/datasets/hit-testing/components/result-item-footer.tsx b/web/app/components/datasets/hit-testing/components/result-item-footer.tsx index 5b1198fcc1..bb1b7c172d 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-footer.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-footer.tsx @@ -28,13 +28,14 @@ const ResultItemFooter: FC<Props> = ({ {docTitle} </span> </div> - <div - className="flex cursor-pointer items-center space-x-1 text-text-tertiary" + <button + type="button" + className="flex cursor-pointer items-center space-x-1 border-none bg-transparent p-0 text-left text-text-tertiary" onClick={showDetailModal} > <div className="text-xs uppercase">{t(`${i18nPrefix}open`, { ns: 'datasetHitTesting' })}</div> - <RiArrowRightUpLine className="size-3.5" /> - </div> + <RiArrowRightUpLine className="size-3.5" aria-hidden /> + </button> </div> ) } diff --git a/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx b/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx index 98bcab2755..02103e02c8 100644 --- a/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx +++ b/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx @@ -85,9 +85,14 @@ const ModifyRetrievalModal: FC<Props> = ({ indexMethod, value, isShow, onHide, o </div> </div> <div className="flex"> - <div onClick={onHide} className="flex h-8 w-8 cursor-pointer items-center justify-center"> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + className="flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onHide} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> </div> diff --git a/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx index 2684278777..c42186c375 100644 --- a/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx +++ b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx @@ -20,13 +20,13 @@ vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({ handleClickTrigger: () => {}, }) return ( - <div data-testid="date-picker-wrapper"> + <div role="group" aria-label="Date picker"> {trigger} - <button data-testid="select-date" onClick={() => onChange(value || null)}> + <button onClick={() => onChange(value || null)}> Select Date </button> - <button data-testid="clear-date" onClick={() => onClear()}> - Clear + <button onClick={() => onClear()}> + Clear Date </button> </div> ) @@ -49,21 +49,20 @@ describe('WrappedDatePicker', () => { it('should render without crashing', () => { const handleChange = vi.fn() render(<WrappedDatePicker onChange={handleChange} />) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should render placeholder text when no value', () => { const handleChange = vi.fn() render(<WrappedDatePicker onChange={handleChange} />) - // When no value, should show placeholder from i18n - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.chooseTime' })).toBeInTheDocument() }) it('should render formatted date when value is provided', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should render calendar icon', () => { @@ -76,24 +75,22 @@ describe('WrappedDatePicker', () => { it('should render select date button', () => { const handleChange = vi.fn() render(<WrappedDatePicker onChange={handleChange} />) - expect(screen.getByTestId('select-date')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select Date' })).toBeInTheDocument() }) it('should render clear date button', () => { const handleChange = vi.fn() render(<WrappedDatePicker onChange={handleChange} />) - expect(screen.getByTestId('clear-date')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Clear Date' })).toBeInTheDocument() }) it('should render close icon for clearing', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - const { container } = render( + render( <WrappedDatePicker value={timestamp} onChange={handleChange} />, ) - // RiCloseCircleFill should be rendered - const closeIcon = container.querySelectorAll('svg') - expect(closeIcon.length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument() }) }) @@ -110,14 +107,14 @@ describe('WrappedDatePicker', () => { it('should accept undefined value', () => { const handleChange = vi.fn() render(<WrappedDatePicker value={undefined} onChange={handleChange} />) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should accept number value', () => { const handleChange = vi.fn() const timestamp = 1609459200 // 2021-01-01 render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) }) @@ -127,7 +124,7 @@ describe('WrappedDatePicker', () => { const timestamp = Math.floor(Date.now() / 1000) render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) - fireEvent.click(screen.getByTestId('select-date')) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) expect(handleChange).toHaveBeenCalled() }) @@ -137,7 +134,7 @@ describe('WrappedDatePicker', () => { const timestamp = Math.floor(Date.now() / 1000) render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) - fireEvent.click(screen.getByTestId('clear-date')) + fireEvent.click(screen.getByRole('button', { name: 'Clear Date' })) expect(handleChange).toHaveBeenCalledWith(null) }) @@ -145,16 +142,13 @@ describe('WrappedDatePicker', () => { it('should call onChange with null when close icon is clicked directly', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - const { container } = render( + render( <WrappedDatePicker value={timestamp} onChange={handleChange} />, ) - // Find the RiCloseCircleFill icon (it has specific classes) - const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled') - if (closeIcon) { - fireEvent.click(closeIcon) - expect(handleChange).toHaveBeenCalledWith(null) - } + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) + + expect(handleChange).toHaveBeenCalledWith(null) }) it('should show close button on hover when value exists', () => { @@ -180,7 +174,7 @@ describe('WrappedDatePicker', () => { if (trigger) fireEvent.click(trigger) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) }) @@ -224,15 +218,14 @@ describe('WrappedDatePicker', () => { it('should handle timestamp of 0', () => { const handleChange = vi.fn() render(<WrappedDatePicker value={0} onChange={handleChange} />) - // 0 is falsy but is a valid timestamp (epoch) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should handle very large timestamp', () => { const handleChange = vi.fn() const farFuture = 4102444800 // 2100-01-01 render(<WrappedDatePicker value={farFuture} onChange={handleChange} />) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should handle switching between no value and value', () => { @@ -241,12 +234,12 @@ describe('WrappedDatePicker', () => { <WrappedDatePicker onChange={handleChange} />, ) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() const timestamp = Math.floor(Date.now() / 1000) rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should handle clearing date multiple times', () => { @@ -254,9 +247,9 @@ describe('WrappedDatePicker', () => { const timestamp = Math.floor(Date.now() / 1000) render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) - fireEvent.click(screen.getByTestId('clear-date')) - fireEvent.click(screen.getByTestId('clear-date')) - fireEvent.click(screen.getByTestId('clear-date')) + fireEvent.click(screen.getByRole('button', { name: 'Clear Date' })) + fireEvent.click(screen.getByRole('button', { name: 'Clear Date' })) + fireEvent.click(screen.getByRole('button', { name: 'Clear Date' })) expect(handleChange).toHaveBeenCalledTimes(3) }) @@ -266,9 +259,9 @@ describe('WrappedDatePicker', () => { const timestamp = Math.floor(Date.now() / 1000) render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) - fireEvent.click(screen.getByTestId('select-date')) - fireEvent.click(screen.getByTestId('select-date')) - fireEvent.click(screen.getByTestId('select-date')) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) expect(handleChange).toHaveBeenCalledTimes(3) }) @@ -277,10 +270,8 @@ describe('WrappedDatePicker', () => { const handleChange = vi.fn() render(<WrappedDatePicker onChange={handleChange} />) - // The mock triggers onChange with the value prop - fireEvent.click(screen.getByTestId('select-date')) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) - // onChange should have been called expect(handleChange).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/metadata/base/date-picker.tsx b/web/app/components/datasets/metadata/base/date-picker.tsx index ed583b8411..b76c2e8060 100644 --- a/web/app/components/datasets/metadata/base/date-picker.tsx +++ b/web/app/components/datasets/metadata/base/date-picker.tsx @@ -35,29 +35,49 @@ const WrappedDatePicker = ({ const renderTrigger = useCallback(({ handleClickTrigger, }: TriggerProps) => { + const hasValue = Boolean(value) + const triggerText = value ? formatTimestamp(value, t('metadata.dateTimeFormat', { ns: 'datasetDocuments' })) : t('metadata.chooseTime', { ns: 'dataset' }) + return ( - <div onClick={handleClickTrigger} className={cn('group flex items-center rounded-md bg-components-input-bg-normal', className)}> - <div - className={cn( - 'grow', - value ? 'text-text-secondary' : 'text-text-tertiary', - )} + <div className={cn('group flex items-center rounded-md bg-components-input-bg-normal', className)}> + <button + type="button" + className="flex min-w-0 grow items-center border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleClickTrigger} > - {value ? formatTimestamp(value, t('metadata.dateTimeFormat', { ns: 'datasetDocuments' })) : t('metadata.chooseTime', { ns: 'dataset' })} - </div> - <RiCloseCircleFill - className={cn( - 'hidden h-4 w-4 cursor-pointer group-hover:block hover:text-components-input-text-filled', - value && 'text-text-quaternary', - )} - onClick={() => handleDateChange()} - /> - <RiCalendarLine - className={cn( - 'block h-4 w-4 shrink-0 group-hover:hidden', - value ? 'text-text-quaternary' : 'text-text-tertiary', - )} - /> + <span + className={cn( + 'grow', + hasValue ? 'text-text-secondary' : 'text-text-tertiary', + )} + > + {triggerText} + </span> + <RiCalendarLine + aria-hidden="true" + className={cn( + 'block h-4 w-4 shrink-0', + hasValue ? 'text-text-quaternary group-hover:hidden' : 'text-text-tertiary', + )} + /> + </button> + {hasValue + ? ( + <button + type="button" + aria-label={t('operation.clear', { ns: 'common' })} + className={cn( + 'hidden h-4 w-4 cursor-pointer rounded-full border-none bg-transparent p-0 text-text-quaternary group-hover:block hover:text-components-input-text-filled focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + )} + onClick={(event) => { + event.stopPropagation() + handleDateChange() + }} + > + <RiCloseCircleFill className="h-4 w-4" aria-hidden="true" /> + </button> + ) + : null} </div> ) }, [className, value, formatTimestamp, t, handleDateChange]) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx index 39c8c9effc..3bb83e79d5 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import EditedBeacon from '../edited-beacon' @@ -97,16 +97,8 @@ describe('EditedBeacon', () => { // Hover to show reset button fireEvent.mouseEnter(wrapper) - await waitFor(() => { - const resetButton = container.querySelector('.bg-text-accent-secondary') - expect(resetButton).toBeInTheDocument() - }) - - // Find and click the reset button (the clickable element with onClick) - const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary') - if (clickableElement) { - fireEvent.click(clickableElement) - } + const resetButton = await screen.findByRole('button', { name: 'common.operation.reset' }) + fireEvent.click(resetButton) expect(handleReset).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx index 25f1e19f8d..da531146af 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx @@ -25,9 +25,14 @@ const EditedBeacon: FC<Props> = ({ <Tooltip> <TooltipTrigger render={( - <div className="flex size-4 items-center justify-center rounded-full bg-text-accent-secondary" onClick={onReset}> - <RiResetLeftLine className="size-[10px] text-text-primary-on-surface" /> - </div> + <button + type="button" + aria-label={t('operation.reset', { ns: 'common' })} + className="flex size-4 items-center justify-center rounded-full border-none bg-text-accent-secondary p-0" + onClick={onReset} + > + <RiResetLeftLine className="size-[10px] text-text-primary-on-surface" aria-hidden="true" /> + </button> )} /> <TooltipContent> diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx index a8b5875541..abc5cb7fcc 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx @@ -98,7 +98,7 @@ const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSav }} > <DialogContent className="w-full !max-w-[640px] overflow-hidden! border-none text-left align-middle"> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> {t(`${i18nPrefix}.editMetadata`, { ns: 'dataset' })} </DialogTitle> diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx index da77a50419..1eb063c437 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx @@ -50,7 +50,7 @@ describe('CreateContent', () => { it('should render close button', () => { const handleSave = vi.fn() renderCreateContent({ onSave: handleSave }) - expect(screen.getByTestId('modal-close-btn')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) }) @@ -162,7 +162,7 @@ describe('CreateContent', () => { const handleClose = vi.fn() renderCreateContent({ onSave: handleSave, onClose: handleClose }) - fireEvent.click(screen.getByTestId('modal-close-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(handleClose).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index f05bd53b7f..f10f9a72a7 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -86,6 +86,10 @@ describe('DatasetMetadataDrawer', () => { vi.clearAllMocks() }) + const clickFirstMetadataAction = (name: string) => { + fireEvent.click(screen.getAllByRole('button', { name })[0]!) + } + describe('Rendering', () => { it('should render without crashing', async () => { render(<DatasetMetadataDrawer {...defaultProps} />) @@ -143,7 +147,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - fireEvent.click(screen.getByTestId('close-icon')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(onClose).toHaveBeenCalledTimes(1) }) @@ -247,21 +251,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find user metadata items with group/item class (these have edit/delete icons) - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - expect(items.length).toBe(2) // 2 user metadata items - - // Find the hidden container with edit/delete icons - const actionsContainer = items[0]!.querySelector('.hidden.items-center') - expect(actionsContainer).toBeTruthy() - - // Find and click the first SVG (edit icon) - if (actionsContainer) { - const svgs = actionsContainer.querySelectorAll('svg') - expect(svgs.length).toBeGreaterThan(0) - fireEvent.click(svgs[0]!) - } + clickFirstMetadataAction('common.operation.edit') // Wait for rename modal (contains input) await waitFor(() => { @@ -278,14 +268,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click edit icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const actionsContainer = items[0]!.querySelector('.hidden.items-center') - if (actionsContainer) { - const svgs = actionsContainer.querySelectorAll('svg') - fireEvent.click(svgs[0]!) - } + clickFirstMetadataAction('common.operation.edit') // Change name and save await waitFor(() => { @@ -319,14 +302,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click edit icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const actionsContainer = items[0]!.querySelector('.hidden.items-center') - if (actionsContainer) { - const svgs = actionsContainer.querySelectorAll('svg') - fireEvent.click(svgs[0]!) - } + clickFirstMetadataAction('common.operation.edit') // Wait for modal and click cancel await waitFor(() => { @@ -355,14 +331,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click edit icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const actionsContainer = items[0]!.querySelector('.hidden.items-center') - if (actionsContainer) { - const svgs = actionsContainer.querySelectorAll('svg') - fireEvent.click(svgs[0]!) - } + clickFirstMetadataAction('common.operation.edit') // Wait for rename modal await waitFor(() => { @@ -387,19 +356,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find user metadata items - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - - // Find the delete container - const deleteContainer = items[0]!.querySelector('.hover\\:text-text-destructive') - expect(deleteContainer).toBeTruthy() - - if (deleteContainer) { - const deleteIcon = deleteContainer.querySelector('svg') - if (deleteIcon) - fireEvent.click(deleteIcon) - } + clickFirstMetadataAction('common.operation.remove') // Confirm dialog should appear await waitFor(() => { @@ -419,15 +376,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click delete icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const deleteContainer = items[0]!.querySelector('.hover\\:text-text-destructive') - if (deleteContainer) { - const deleteIcon = deleteContainer.querySelector('svg') - if (deleteIcon) - fireEvent.click(deleteIcon) - } + clickFirstMetadataAction('common.operation.remove') // Wait for confirm dialog await waitFor(() => { @@ -465,15 +414,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click delete icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const deleteContainer = items[0]!.querySelector('.hover\\:text-text-destructive') - if (deleteContainer) { - const deleteIcon = deleteContainer.querySelector('svg') - if (deleteIcon) - fireEvent.click(deleteIcon) - } + clickFirstMetadataAction('common.operation.remove') // Wait for confirm dialog await waitFor(() => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx index c1406d1233..70432ebf9d 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx @@ -68,8 +68,7 @@ describe('SelectMetadata', () => { onManage={vi.fn()} />, ) - // New action button should be present (from i18n) - expect(screen.getByText(/new/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })).toBeInTheDocument() }) it('should render manage action button', () => { @@ -81,8 +80,7 @@ describe('SelectMetadata', () => { onManage={vi.fn()} />, ) - // Manage action button should be present (from i18n) - expect(screen.getByText(/manage/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })).toBeInTheDocument() }) it('should display type for each item', () => { @@ -187,7 +185,7 @@ describe('SelectMetadata', () => { />, ) - fireEvent.click(screen.getByText('field_one')) + fireEvent.click(screen.getByRole('button', { name: /field_one/i })) expect(handleSelect).toHaveBeenCalledWith({ id: '1', @@ -207,9 +205,7 @@ describe('SelectMetadata', () => { />, ) - // Find and click the new action button - const newButton = screen.getByText(/new/i) - fireEvent.click(newButton.closest('div') || newButton) + fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })) expect(handleNew).toHaveBeenCalled() }) @@ -225,9 +221,7 @@ describe('SelectMetadata', () => { />, ) - // Find and click the manage action button - const manageButton = screen.getByText(/manage/i) - fireEvent.click(manageButton.closest('div') || manageButton) + fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })) expect(handleManage).toHaveBeenCalled() }) @@ -255,8 +249,8 @@ describe('SelectMetadata', () => { onManage={vi.fn()} />, ) - expect(screen.getByText(/new/i)).toBeInTheDocument() - expect(screen.getByText(/manage/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx index b0824d14a9..691d1512bc 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx @@ -50,10 +50,10 @@ const CreateContent: FC<Props> = ({ {hasBack && ( <button type="button" - className="relative left-[-4px] mb-1 flex cursor-pointer items-center space-x-1 py-1 text-text-accent" + className="relative left-[-4px] mb-1 flex cursor-pointer items-center space-x-1 border-none bg-transparent px-0 py-1 text-left text-text-accent" onClick={onBack} > - <span className="i-ri-arrow-left-line size-4" /> + <span className="i-ri-arrow-left-line size-4" aria-hidden="true" /> <span className="system-xs-semibold-uppercase">{t(`${i18nPrefix}.back`, { ns: 'dataset' })}</span> </button> )} @@ -65,10 +65,10 @@ const CreateContent: FC<Props> = ({ <button type="button" aria-label={t('operation.close', { ns: 'common' })} - className="cursor-pointer p-1.5 text-text-tertiary" + className="cursor-pointer border-none bg-transparent p-1.5 text-text-tertiary" onClick={onClose} > - <span className="i-ri-close-line size-4" data-testid="modal-close-btn" /> + <span className="i-ri-close-line size-4" aria-hidden="true" /> </button> )} </div> diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx index b6597e5f51..8553eddacf 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx @@ -70,7 +70,7 @@ const Item: FC<ItemProps> = ({ onRename?.() }, [onRename]) - const deleteBtnRef = useRef<HTMLDivElement>(null) + const deleteBtnRef = useRef<HTMLButtonElement>(null) const isDeleteHovering = useHover(deleteBtnRef) const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, @@ -107,10 +107,23 @@ const Item: FC<ItemProps> = ({ </div> )} <div className="ml-2 hidden items-center space-x-1 text-text-tertiary group-hover/item:flex"> - <RiEditLine className="size-4 cursor-pointer" onClick={handleRename} /> - <div ref={deleteBtnRef} className="hover:text-text-destructive"> - <RiDeleteBinLine className="size-4 cursor-pointer" onClick={showDeleteConfirm} /> - </div> + <button + type="button" + aria-label={t('operation.edit', { ns: 'common' })} + className="cursor-pointer rounded-md border-none bg-transparent p-0.5 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleRename} + > + <RiEditLine className="size-4" aria-hidden="true" /> + </button> + <button + type="button" + ref={deleteBtnRef} + aria-label={t('operation.remove', { ns: 'common' })} + className="cursor-pointer rounded-md border-none bg-transparent p-0.5 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" + onClick={showDeleteConfirm} + > + <RiDeleteBinLine className="size-4" aria-hidden="true" /> + </button> </div> <AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}> <AlertDialogContent> @@ -205,7 +218,6 @@ const DatasetMetadataDrawer: FC<Props> = ({ <DrawerCloseButton aria-label={t('operation.close', { ns: 'common' })} className="h-6 w-6 rounded-md" - data-testid="close-icon" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6"> diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx b/web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx index 4d86ddf28e..2886601170 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx @@ -45,9 +45,10 @@ const SelectMetadata: FC<Props> = ({ {list.map((item) => { const Icon = getIcon(item.type) return ( - <div + <button + type="button" key={item.id} - className="mx-1 flex h-6 cursor-pointer items-center justify-between rounded-md px-3 hover:bg-state-base-hover" + className="mx-1 flex h-6 cursor-pointer items-center justify-between rounded-md border-none bg-transparent px-3 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => onSelect({ id: item.id, name: item.name, @@ -55,27 +56,35 @@ const SelectMetadata: FC<Props> = ({ })} > <div className="flex h-full w-0 grow items-center text-text-secondary"> - <Icon className="mr-[5px] size-3.5 shrink-0" /> + <Icon className="mr-[5px] size-3.5 shrink-0" aria-hidden="true" /> <div className="w-0 grow truncate system-sm-medium">{item.name}</div> </div> <div className="ml-1 shrink-0 system-xs-regular text-text-tertiary"> {item.type} </div> - </div> + </button> ) })} </div> <div className="mt-1 flex justify-between border-t border-divider-subtle p-1"> - <div className="flex h-6 cursor-pointer items-center space-x-1 rounded-md px-3 text-text-secondary hover:bg-state-base-hover" onClick={onNew}> - <RiAddLine className="size-3.5" /> + <button + type="button" + className="flex h-6 cursor-pointer items-center space-x-1 rounded-md border-none bg-transparent px-3 text-left text-text-secondary hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onNew} + > + <RiAddLine className="size-3.5" aria-hidden="true" /> <div className="system-sm-medium">{t(`${i18nPrefix}.newAction`, { ns: 'dataset' })}</div> - </div> + </button> <div className="flex h-6 items-center text-text-secondary"> <div className="mr-[3px] h-3 w-px bg-divider-regular"></div> - <div className="flex h-full cursor-pointer items-center rounded-md px-1.5 hover:bg-state-base-hover" onClick={onManage}> + <button + type="button" + className="flex h-full cursor-pointer items-center rounded-md border-none bg-transparent px-1.5 text-left text-text-secondary hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onManage} + > <div className="mr-1 system-sm-medium">{t(`${i18nPrefix}.manageAction`, { ns: 'dataset' })}</div> - <RiArrowRightUpLine className="size-3.5" /> - </div> + <RiArrowRightUpLine className="size-3.5" aria-hidden="true" /> + </button> </div> </div> </div> diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index c4dc89cafc..bcae6de66f 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -41,7 +41,7 @@ vi.mock('@/hooks/use-timestamp', () => ({ // Mock AddMetadataButton vi.mock('../../add-metadata-button', () => ({ - default: () => <button data-testid="add-metadata-btn">Add Metadata</button>, + default: () => <button>Add Metadata</button>, })) // Mock InputCombined @@ -61,9 +61,9 @@ vi.mock('../../metadata-dataset/select-metadata-modal', () => ({ default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => ( <div data-testid="select-metadata-modal"> {trigger} - <button data-testid="select-action" onClick={() => onSelect({ id: '1', name: 'test', type: DataType.string, value: null })}>Select</button> - <button data-testid="save-action" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>Save</button> - <button data-testid="manage-action" onClick={onManage}>Manage</button> + <button onClick={() => onSelect({ id: '1', name: 'test', type: DataType.string, value: null })}>Select</button> + <button onClick={() => onSave({ name: 'new_field', type: DataType.string })}>Save</button> + <button onClick={onManage}>Manage</button> </div> ), })) @@ -145,14 +145,14 @@ describe('InfoGroup', () => { render( <InfoGroup dataSetId="ds-1" list={mockList} isEdit />, ) - expect(screen.getByTestId('add-metadata-btn'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add Metadata' }))!.toBeInTheDocument() }) it('should not render add metadata button when isEdit is false', () => { render( <InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />, ) - expect(screen.queryByTestId('add-metadata-btn')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add Metadata' })).not.toBeInTheDocument() }) it('should render input combined for each item in edit mode', () => { @@ -164,11 +164,10 @@ describe('InfoGroup', () => { }) it('should render delete icons in edit mode', () => { - const { container } = render( + render( <InfoGroup dataSetId="ds-1" list={mockList} isEdit />, ) - const deleteIcons = container.querySelectorAll('.cursor-pointer svg') - expect(deleteIcons.length).toBeGreaterThan(0) + expect(screen.getAllByRole('button', { name: 'common.operation.remove' })).toHaveLength(3) }) }) @@ -187,14 +186,11 @@ describe('InfoGroup', () => { it('should call onDelete when delete icon is clicked', () => { const handleDelete = vi.fn() - const { container } = render( + render( <InfoGroup dataSetId="ds-1" list={mockList} isEdit onDelete={handleDelete} />, ) - // Find delete icons (RiDeleteBinLine SVGs inside cursor-pointer divs) - const deleteButtons = container.querySelectorAll('svg.size-4') - if (deleteButtons.length > 0) - fireEvent.click(deleteButtons[0]!) + fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[0]!) expect(handleDelete).toHaveBeenCalled() }) @@ -205,7 +201,7 @@ describe('InfoGroup', () => { <InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />, ) - fireEvent.click(screen.getByTestId('select-action')) + fireEvent.click(screen.getByRole('button', { name: 'Select' })) expect(handleSelect).toHaveBeenCalledWith({ id: '1', @@ -221,7 +217,7 @@ describe('InfoGroup', () => { <InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />, ) - fireEvent.click(screen.getByTestId('save-action')) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) expect(handleAdd).toHaveBeenCalledWith({ name: 'new_field', @@ -234,11 +230,9 @@ describe('InfoGroup', () => { <InfoGroup dataSetId="ds-1" list={mockList} isEdit />, ) - fireEvent.click(screen.getByTestId('manage-action')) + fireEvent.click(screen.getByRole('button', { name: 'Manage' })) - // The onManage callback triggers the navigation - // The onManage callback triggers the navigation - expect(screen.getByTestId('manage-action'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Manage' }))!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx index 5d174b2534..0368aa18b6 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx +++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx @@ -99,9 +99,14 @@ const InfoGroup: FC<Props> = ({ value={item.value} onChange={value => onChange?.({ ...item, value })} /> - <div className="shrink-0 cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"> - <RiDeleteBinLine className="size-4" onClick={() => onDelete?.(item)} /> - </div> + <button + type="button" + aria-label={t('operation.remove', { ns: 'common' })} + className="shrink-0 cursor-pointer rounded-md border-none bg-transparent p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => onDelete?.(item)} + > + <RiDeleteBinLine className="size-4" aria-hidden="true" /> + </button> </div> ) : (<div className="py-1 system-xs-regular text-text-secondary">{(item.value && item.type === DataType.time) ? formatTimestamp((item.value as number), t('metadata.dateTimeFormat', { ns: 'datasetDocuments' })) : item.value}</div>)} diff --git a/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx index 4f8a421a52..ac4075fa5b 100644 --- a/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx @@ -167,11 +167,7 @@ describe('RenameDatasetModal', () => { it('should render close icon button', () => { render(<RenameDatasetModal {...defaultProps} />) - // The modal renders with title and other elements - // The close functionality is tested in user interactions - // The modal renders with title and other elements - // The close functionality is tested in user interactions - expect(screen.getByText('datasetSettings.title'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: /operation\.close$/ }))!.toBeInTheDocument() }) it('should render form labels', () => { @@ -296,14 +292,10 @@ describe('RenameDatasetModal', () => { }) it('should call onClose when close icon is clicked', () => { - // This test is covered by the cancel button test - // The close icon functionality works the same way as cancel button const handleClose = vi.fn() render(<RenameDatasetModal {...defaultProps} onClose={handleClose} />) - // Use the cancel button to verify close callback works - const cancelButton = screen.getByText('common.operation.cancel') - fireEvent.click(cancelButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(handleClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/rename-modal/index.tsx b/web/app/components/datasets/rename-modal/index.tsx index ae8f9f5d6a..d2750ae21b 100644 --- a/web/app/components/datasets/rename-modal/index.tsx +++ b/web/app/components/datasets/rename-modal/index.tsx @@ -93,9 +93,14 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset <div className="flex items-center justify-between pb-2"> <div className="text-xl leading-[30px] font-medium text-text-primary">{t('title', { ns: 'datasetSettings' })}</div> - <div className="cursor-pointer p-2" onClick={onClose}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + className="cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onClose} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> <div> <div className={cn('flex flex-col py-4')}> diff --git a/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx index 355946402b..84d5ae6455 100644 --- a/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx @@ -287,7 +287,7 @@ describe('PermissionSelector', () => { fireEvent.change(searchInput, { target: { value: 'test' } }) expect(searchInput)!.toHaveValue('test') - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) fireEvent.click(clearButton) // After clicking clear, input should be empty diff --git a/web/app/components/develop/__tests__/ApiServer.spec.tsx b/web/app/components/develop/__tests__/ApiServer.spec.tsx index 1364513ce8..a805a3d2e0 100644 --- a/web/app/components/develop/__tests__/ApiServer.spec.tsx +++ b/web/app/components/develop/__tests__/ApiServer.spec.tsx @@ -5,7 +5,13 @@ import ApiServer from '../ApiServer' vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( - isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>Close Modal</button></div> : null + isShow + ? ( + <div role="dialog" aria-label="Secret key"> + <button type="button" onClick={onClose}>Close Modal</button> + </div> + ) + : null ), })) @@ -81,7 +87,7 @@ describe('ApiServer', () => { await user.click(apiKeyButton) }) - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Secret key' })).toBeInTheDocument() }) it('should close modal when close button is clicked', async () => { @@ -93,14 +99,14 @@ describe('ApiServer', () => { await user.click(apiKeyButton) }) - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Secret key' })).toBeInTheDocument() - const closeButton = screen.getByText('Close Modal') + const closeButton = screen.getByRole('button', { name: 'Close Modal' }) await act(async () => { await user.click(closeButton) }) - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: 'Secret key' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx index e7a0ebb880..accd106305 100644 --- a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx @@ -158,8 +158,9 @@ describe('InputCopy', () => { it('should have cursor-pointer on clickable area', async () => { await renderAndFlush(<InputCopy value="test" />) const valueText = screen.getByText('test') - const clickableArea = valueText.closest('div[class*="cursor-pointer"]') + const clickableArea = valueText.closest('button') expect(clickableArea).toBeInTheDocument() + expect(clickableArea?.className).toContain('cursor-pointer') }) }) @@ -188,8 +189,9 @@ describe('InputCopy', () => { it('should have truncate class for long values', async () => { await renderAndFlush(<InputCopy value="very-long-api-key-value-that-might-overflow" />) const valueText = screen.getByText('very-long-api-key-value-that-might-overflow') - const container = valueText.closest('div[class*="truncate"]') + const container = valueText.closest('button') expect(container).toBeInTheDocument() + expect(container?.className).toContain('truncate') }) it('should have text-secondary color on value', async () => { @@ -201,8 +203,9 @@ describe('InputCopy', () => { it('should have absolute positioning for overlay', async () => { await renderAndFlush(<InputCopy value="test" />) const valueText = screen.getByText('test') - const container = valueText.closest('div[class*="absolute"]') + const container = valueText.closest('button') expect(container).toBeInTheDocument() + expect(container?.className).toContain('absolute') }) }) diff --git a/web/app/components/develop/secret-key/input-copy.tsx b/web/app/components/develop/secret-key/input-copy.tsx index 28a6aa4cc5..741cdd9bf5 100644 --- a/web/app/components/develop/secret-key/input-copy.tsx +++ b/web/app/components/develop/secret-key/input-copy.tsx @@ -42,18 +42,11 @@ const InputCopy = ({ <div className="flex h-5 grow items-center"> {children} <div className="relative h-full grow text-[13px]"> - <div - className="r-0 absolute top-0 left-0 w-full cursor-pointer truncate pr-2 pl-2" - role="button" + <button + type="button" + className="r-0 absolute top-0 left-0 w-full cursor-pointer truncate border-none bg-transparent py-0 pr-2 pl-2 text-left" aria-label={copyLabel} - tabIndex={0} onClick={handleCopy} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - handleCopy() - } - }} > <Tooltip> <TooltipTrigger @@ -63,7 +56,7 @@ const InputCopy = ({ {copyLabel} </TooltipContent> </Tooltip> - </div> + </button> </div> <div className="h-4 w-px shrink-0 bg-divider-regular" /> <div className="mx-1"><CopyFeedback content={value} /></div> 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 7b5c02be4c..7c9bdb7b23 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -279,7 +279,7 @@ describe('AppList', () => { }) expect(screen.queryByText('Alpha')).not.toBeInTheDocument() - fireEvent.click(screen.getByTestId('input-clear')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) await act(async () => { await vi.advanceTimersByTimeAsync(500) }) diff --git a/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx index 6335678a19..8fc2cb530e 100644 --- a/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx @@ -172,7 +172,7 @@ describe('TryApp (chat.tsx)', () => { />, ) - expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'share.chat.resetChat' })).not.toBeInTheDocument() }) it('renders reset button when conversation exists', () => { @@ -193,7 +193,7 @@ describe('TryApp (chat.tsx)', () => { />, ) - expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'share.chat.resetChat' })).toBeInTheDocument() }) it('calls handleNewConversation when reset button is clicked', () => { @@ -214,7 +214,7 @@ describe('TryApp (chat.tsx)', () => { />, ) - fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getByRole('button', { name: 'share.chat.resetChat' })) expect(mockRemoveConversationIdInfo).toHaveBeenCalledWith('test-app-id') expect(mockHandleNewConversation).toHaveBeenCalled() diff --git a/web/app/components/explore/try-app/app/chat.tsx b/web/app/components/explore/try-app/app/chat.tsx index 0c319b4c21..c1c404e49e 100644 --- a/web/app/components/explore/try-app/app/chat.tsx +++ b/web/app/components/explore/try-app/app/chat.tsx @@ -81,8 +81,12 @@ const TryApp: FC<Props> = ({ <Tooltip> <TooltipTrigger render={( - <ActionButton size="l" onClick={handleNewConversation}> - <RiResetLeftLine className="h-[18px] w-[18px]" /> + <ActionButton + size="l" + aria-label={t('chat.resetChat', { ns: 'share' })} + onClick={handleNewConversation} + > + <RiResetLeftLine className="h-[18px] w-[18px]" aria-hidden="true" /> </ActionButton> )} /> diff --git a/web/app/components/header/__tests__/maintenance-notice.spec.tsx b/web/app/components/header/__tests__/maintenance-notice.spec.tsx index 82c66a05b8..bab8c014fc 100644 --- a/web/app/components/header/__tests__/maintenance-notice.spec.tsx +++ b/web/app/components/header/__tests__/maintenance-notice.spec.tsx @@ -5,7 +5,7 @@ import { NOTICE_I18N } from '@/i18n-config/language' import MaintenanceNotice from '../maintenance-notice' vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({ - X: ({ onClick }: { onClick?: () => void }) => <button type="button" aria-label="close notice" onClick={onClick} />, + X: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />, })) vi.mock( @@ -78,7 +78,7 @@ describe('MaintenanceNotice', () => { render(<MaintenanceNotice />) expect(screen.getByText('Notice Title')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /close notice/i })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(screen.queryByText('Notice Title')).not.toBeInTheDocument() expect(localStorage.getItem('hide-maintenance-notice')).toBe('1') @@ -88,7 +88,7 @@ describe('MaintenanceNotice', () => { setNoticeHref('https://dify.ai/notice') render(<MaintenanceNotice />) - const desc = screen.getByText('Notice Description') + const desc = screen.getByRole('button', { name: 'Notice Description' }) fireEvent.click(desc) expect(windowOpenSpy).toHaveBeenCalledWith( diff --git a/web/app/components/header/account-about/__tests__/index.spec.tsx b/web/app/components/header/account-about/__tests__/index.spec.tsx index 8ec7d8800d..cc9508e50f 100644 --- a/web/app/components/header/account-about/__tests__/index.spec.tsx +++ b/web/app/components/header/account-about/__tests__/index.spec.tsx @@ -90,15 +90,9 @@ describe('AccountAbout', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />) - // Modal content renders into a portal, so we need to use document. - const closeButton = document.querySelector('div.absolute.cursor-pointer') - if (!closeButton) - throw new Error('Close button not found') + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) - fireEvent.click(closeButton) - - // Assert expect(mockOnCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/header/account-about/index.tsx b/web/app/components/header/account-about/index.tsx index ba3eba5993..a47b24a743 100644 --- a/web/app/components/header/account-about/index.tsx +++ b/web/app/components/header/account-about/index.tsx @@ -36,9 +36,14 @@ export default function AccountAbout({ <DialogContent className="w-[calc(100vw-2rem)]! max-w-[480px]! overflow-hidden! border-none px-6! py-4! text-left align-middle"> <div className="relative"> - <div className="absolute top-0 right-0 flex h-8 w-8 cursor-pointer items-center justify-center" onClick={onCancel}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + className="absolute top-0 right-0 flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onCancel} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <div className="flex flex-col items-center gap-4 py-8"> {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( diff --git a/web/app/components/header/account-setting/collapse/__tests__/index.spec.tsx b/web/app/components/header/account-setting/collapse/__tests__/index.spec.tsx index d39f70d0a9..4cce58d83a 100644 --- a/web/app/components/header/account-setting/collapse/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/collapse/__tests__/index.spec.tsx @@ -68,12 +68,12 @@ describe('Collapse', () => { expect(screen.queryByTestId('item-1')).not.toBeInTheDocument() // Click to open - fireEvent.click(screen.getByText('Test Title')) + fireEvent.click(screen.getByRole('button', { name: 'Test Title' })) expect(screen.getByTestId('item-1')).toBeInTheDocument() expect(screen.getByTestId('item-2')).toBeInTheDocument() // Click to close - fireEvent.click(screen.getByText('Test Title')) + fireEvent.click(screen.getByRole('button', { name: 'Test Title' })) expect(screen.queryByTestId('item-1')).not.toBeInTheDocument() }) @@ -89,7 +89,7 @@ describe('Collapse', () => { ) // Act - fireEvent.click(screen.getByText('Test Title')) + fireEvent.click(screen.getByRole('button', { name: 'Test Title' })) const item1 = screen.getByTestId('item-1') fireEvent.click(item1) @@ -109,7 +109,7 @@ describe('Collapse', () => { ) // Act - fireEvent.click(screen.getByText('Test Title')) + fireEvent.click(screen.getByRole('button', { name: 'Test Title' })) const item1 = screen.getByTestId('item-1') fireEvent.click(item1) diff --git a/web/app/components/header/account-setting/collapse/index.tsx b/web/app/components/header/account-setting/collapse/index.tsx index f7cf1edcc8..78003bad3a 100644 --- a/web/app/components/header/account-setting/collapse/index.tsx +++ b/web/app/components/header/account-setting/collapse/index.tsx @@ -26,22 +26,31 @@ const Collapse = ({ return ( <div className={cn('overflow-hidden rounded-xl bg-background-section-burn', wrapperClassName)}> - <div className="flex cursor-pointer items-center justify-between px-3 py-2 text-xs leading-[18px] font-medium text-text-secondary" onClick={toggle}> + <button + type="button" + className="flex w-full cursor-pointer items-center justify-between border-none bg-transparent px-3 py-2 text-left text-xs leading-[18px] font-medium text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={toggle} + > {title} { open - ? <ChevronDownIcon className="h-3 w-3 text-components-button-tertiary-text" /> - : <ChevronRightIcon className="h-3 w-3 text-components-button-tertiary-text" /> + ? <ChevronDownIcon className="h-3 w-3 text-components-button-tertiary-text" aria-hidden="true" /> + : <ChevronRightIcon className="h-3 w-3 text-components-button-tertiary-text" aria-hidden="true" /> } - </div> + </button> { open && ( <div className="mx-1 mb-1 rounded-lg border-t border-divider-subtle bg-components-panel-on-panel-item-bg py-1"> { items.map(item => ( - <div key={item.key} onClick={() => onSelect?.(item)}> + <button + key={item.key} + type="button" + className="block w-full border-none bg-transparent p-0 text-left" + onClick={() => onSelect?.(item)} + > {renderItem(item)} - </div> + </button> )) } </div> diff --git a/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx b/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx index d952559fbf..9deff0cc1f 100644 --- a/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx @@ -48,10 +48,15 @@ const InstallFromMarketplace = ({ <div className="mb-2"> <Divider className="mt-4! h-px" /> <div className="flex items-center justify-between"> - <div className="flex cursor-pointer items-center gap-1 system-md-semibold text-text-primary" onClick={() => setCollapse(!collapse)}> - <RiArrowDownSLine className={cn('h-4 w-4', collapse && '-rotate-90')} /> + <button + type="button" + aria-expanded={!collapse} + className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left system-md-semibold text-text-primary" + onClick={() => setCollapse(!collapse)} + > + <RiArrowDownSLine className={cn('h-4 w-4', collapse && '-rotate-90')} aria-hidden="true" /> {t('modelProvider.installDataSourceProvider', { ns: 'common' })} - </div> + </button> <div className="mb-2 flex items-center pt-2"> <span className="pr-1 system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span> <Link target="_blank" href={getMarketplaceUrl('', { theme })} className="inline-flex items-center system-sm-medium text-text-accent"> diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index c09f32ea83..a6d490ff77 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -200,7 +200,7 @@ describe('MembersPage', () => { renderMembersPage() - await user.click(screen.getByTestId('edit-workspace-pencil')) + await user.click(screen.getByRole('button', { name: /account\.editWorkspaceInfo/i })) expect(screen.getByText('Edit Workspace Modal'))!.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Close Edit Workspace' })) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx index db7b69f7cb..1b85319613 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx @@ -11,6 +11,8 @@ const toastMocks = vi.hoisted(() => ({ mockNotify: vi.fn(), })) +const getSaveButton = () => screen.getByRole('button', { name: /operation\.(save|saving)/i }) + vi.mock('@/context/app-context') vi.mock('@/service/common') vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -83,7 +85,7 @@ describe('EditWorkspaceModal', () => { const input = screen.getByLabelText(/account\.workspaceName/i) await user.clear(input) await user.type(input, 'Renamed Workspace') - await user.click(screen.getByTestId('edit-workspace-save')) + await user.click(getSaveButton()) await waitFor(() => { expect(updateWorkspaceInfo).toHaveBeenCalledWith({ @@ -106,7 +108,7 @@ describe('EditWorkspaceModal', () => { const input = screen.getByLabelText(/account\.workspaceName/i) await user.clear(input) await user.type(input, 'Broken Workspace') - await user.click(screen.getByTestId('edit-workspace-save')) + await user.click(getSaveButton()) await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ @@ -118,7 +120,7 @@ describe('EditWorkspaceModal', () => { it('should disable save button when there are no changes', async () => { renderModal() - expect(screen.getByTestId('edit-workspace-save')).toBeDisabled() + expect(getSaveButton()).toBeDisabled() }) it('should disable save button and show error when the name is empty', async () => { @@ -129,7 +131,7 @@ describe('EditWorkspaceModal', () => { const input = screen.getByLabelText(/account\.workspaceName/i) await user.clear(input) - expect(screen.getByTestId('edit-workspace-save')).toBeDisabled() + expect(getSaveButton()).toBeDisabled() expect(input).toHaveAttribute('aria-invalid', 'true') expect(screen.getByTestId('edit-workspace-error')).toBeInTheDocument() }) @@ -137,7 +139,7 @@ describe('EditWorkspaceModal', () => { it('should not submit when the form is submitted while save is disabled', async () => { renderModal() - const saveButton = screen.getByTestId('edit-workspace-save') + const saveButton = getSaveButton() const form = saveButton.closest('form') expect(saveButton).toBeDisabled() @@ -157,14 +159,14 @@ describe('EditWorkspaceModal', () => { renderModal() - expect(screen.getByTestId('edit-workspace-save')).toBeDisabled() + expect(getSaveButton()).toBeDisabled() }) it('should call onCancel when close icon is clicked', async () => { const user = userEvent.setup() renderModal() - await user.click(screen.getByTestId('edit-workspace-close')) + await user.click(screen.getByRole('button', { name: /Close|operation.close/ })) expect(mockOnCancel).toHaveBeenCalled() }) @@ -172,7 +174,7 @@ describe('EditWorkspaceModal', () => { const user = userEvent.setup() renderModal() - await user.click(screen.getByTestId('edit-workspace-cancel')) + await user.click(screen.getByRole('button', { name: /operation\.cancel/i })) expect(mockOnCancel).toHaveBeenCalled() }) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx index 0f6a6fb8d6..60fcf8c094 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx @@ -61,7 +61,7 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { }} > <DialogContent backdropProps={{ forceRender: true }} className="overflow-visible"> - <DialogCloseButton data-testid="edit-workspace-close" /> + <DialogCloseButton /> <form className="flex flex-col" @@ -102,10 +102,10 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { </div> <div className="sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-end gap-x-2 bg-components-panel-bg px-2 pt-4"> - <Button size="large" type="button" data-testid="edit-workspace-cancel" onClick={onCancel}> + <Button size="large" type="button" onClick={onCancel}> {t('operation.cancel', { ns: 'common' })} </Button> - <Button size="large" type="submit" variant="primary" data-testid="edit-workspace-save" disabled={isSaveDisabled} loading={isSubmitting}> + <Button size="large" type="submit" variant="primary" disabled={isSaveDisabled} loading={isSubmitting}> {t(isSubmitting ? 'operation.saving' : 'operation.save', { ns: 'common' })} </Button> </div> diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index cf25a2446a..da4440eb66 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -63,17 +63,19 @@ const MembersPage = () => { <Tooltip> <TooltipTrigger render={( - <div - className="cursor-pointer rounded-md p-1 hover:bg-black/5" + <button + type="button" + aria-label={t('account.editWorkspaceInfo', { ns: 'common' })} + className="cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-black/5" onClick={() => { setEditWorkspaceModalVisible(true) }} > - <div - data-testid="edit-workspace-pencil" + <span + aria-hidden="true" className="i-ri-pencil-line h-4 w-4 text-text-tertiary" /> - </div> + </button> )} /> <TooltipContent> diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx index 9ed99614e4..9b262a3f31 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx @@ -136,7 +136,7 @@ describe('InviteModal', () => { const user = userEvent.setup() renderModal() - await user.click(screen.getByTestId('invite-modal-close')) + await user.click(screen.getByRole('button', { name: /Close|operation.close/ })) expect(mockOnCancel).toHaveBeenCalled() }) @@ -161,7 +161,7 @@ describe('InviteModal', () => { expect(screen.getByText('user@example.com')).toBeInTheDocument() - const removeBtn = screen.getByTestId('remove-email-btn') + const removeBtn = screen.getByRole('button', { name: /operation\.remove.*user@example\.com/i }) await user.click(removeBtn) expect(screen.queryByText('user@example.com')).not.toBeInTheDocument() diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx index af2c64179b..1284a7abb1 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import { vi } from 'vitest' @@ -17,6 +17,10 @@ const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => { return <RoleSelector value={role} onChange={setRole} /> } +const getTrigger = () => screen.getByRole('button', { name: /members\.invitedAsRole/i }) +const getRoleDialog = () => screen.getByRole('dialog') +const getRoleOption = (role: string) => within(getRoleDialog()).getByRole('button', { name: new RegExp(`common\\.members\\.${role}`, 'i') }) + describe('RoleSelector', () => { beforeEach(() => { vi.clearAllMocks() @@ -36,16 +40,16 @@ describe('RoleSelector', () => { const user = userEvent.setup() render(<RoleSelectorWrapper />) - const trigger = screen.getByTestId('role-selector-trigger') + const trigger = getTrigger() // Open await user.click(trigger) - expect(screen.getByTestId('role-option-normal')).toBeInTheDocument() + expect(getRoleOption('normal')).toBeInTheDocument() // Close await user.click(trigger) await waitFor(() => { - expect(screen.queryByTestId('role-option-normal')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) }) @@ -53,28 +57,27 @@ describe('RoleSelector', () => { const user = userEvent.setup() render(<RoleSelectorWrapper initialRole="editor" />) - await user.click(screen.getByTestId('role-selector-trigger')) + await user.click(getTrigger()) - const editorOption = screen.getByTestId('role-option-editor') - expect(editorOption.querySelector('[data-testid="role-option-check"]')).toBeInTheDocument() + expect(getRoleOption('editor')).toHaveAttribute('aria-pressed', 'true') }) it.each([ - ['normal', 'role-option-normal', 'common.members.normal'], - ['editor', 'role-option-editor', 'common.members.editor'], - ['admin', 'role-option-admin', 'common.members.admin'], - ['dataset_operator', 'role-option-dataset_operator', 'common.members.datasetOperator'], - ])('should update selected role after user chooses %s', async (_roleKey, testId) => { + ['normal'], + ['editor'], + ['admin'], + ['datasetOperator'], + ])('should update selected role after user chooses %s', async (roleKey) => { const user = userEvent.setup() render(<RoleSelectorWrapper initialRole="normal" />) - await user.click(screen.getByTestId('role-selector-trigger')) - await user.click(screen.getByTestId(testId)) + await user.click(getTrigger()) + await user.click(getRoleOption(roleKey)) // Verify dropdown closed await waitFor(() => { - expect(screen.queryByTestId(testId)).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) // Verify trigger text updated (using translation key pattern from global mock) @@ -90,9 +93,9 @@ describe('RoleSelector', () => { render(<RoleSelectorWrapper />) - await user.click(screen.getByTestId('role-selector-trigger')) + await user.click(getTrigger()) - expect(screen.queryByTestId('role-option-dataset_operator')).not.toBeInTheDocument() - expect(screen.getByTestId('role-option-normal')).toBeInTheDocument() + expect(within(getRoleDialog()).queryByRole('button', { name: /common\.members\.datasetOperator/i })).not.toBeInTheDocument() + expect(getRoleOption('normal')).toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 426d0491a2..254dff491c 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -87,7 +87,7 @@ const InviteModal = ({ backdropProps={{ forceRender: true }} className="w-[400px] overflow-visible px-8 py-6" > - <DialogCloseButton data-testid="invite-modal-close" className="top-6 right-8" /> + <DialogCloseButton className="top-6 right-8" /> <div className="mb-2 pr-8"> <DialogTitle className="text-xl font-semibold text-text-primary"> {t('members.inviteTeamMember', { ns: 'common' })} @@ -122,13 +122,15 @@ const InviteModal = ({ getLabel={(email, index, removeEmail) => ( <div data-tag key={index} className={cn('bg-components-button-secondary-bg!')}> <div data-tag-item>{email}</div> - <span - data-testid="remove-email-btn" + <button + type="button" data-tag-handle + aria-label={`${t('operation.remove', { ns: 'common' })} ${email}`} + className="border-none bg-transparent p-0 text-inherit" onClick={() => removeEmail(index)} > × - </span> + </button> </div> )} placeholder={t('members.emailPlaceholder', { ns: 'common' }) || ''} diff --git a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx index 9c44e8b93d..f22be94ddf 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx @@ -48,9 +48,10 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { popupClassName="w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg" > <div className="p-1"> - <div - data-testid="role-option-normal" - className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover" + <button + type="button" + aria-pressed={value === 'normal'} + className="w-full cursor-pointer rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover" onClick={() => { onChange('normal') setOpen(false) @@ -61,15 +62,16 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { <div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div> {value === 'normal' && ( <div - data-testid="role-option-check" + aria-hidden="true" className="absolute top-0.5 left-0 i-custom-vender-line-general-check h-4 w-4 text-text-accent" /> )} </div> - </div> - <div - data-testid="role-option-editor" - className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover" + </button> + <button + type="button" + aria-pressed={value === 'editor'} + className="w-full cursor-pointer rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover" onClick={() => { onChange('editor') setOpen(false) @@ -80,15 +82,16 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { <div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div> {value === 'editor' && ( <div - data-testid="role-option-check" + aria-hidden="true" className="absolute top-0.5 left-0 i-custom-vender-line-general-check h-4 w-4 text-text-accent" /> )} </div> - </div> - <div - data-testid="role-option-admin" - className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover" + </button> + <button + type="button" + aria-pressed={value === 'admin'} + className="w-full cursor-pointer rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover" onClick={() => { onChange('admin') setOpen(false) @@ -99,16 +102,17 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { <div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div> {value === 'admin' && ( <div - data-testid="role-option-check" + aria-hidden="true" className="absolute top-0.5 left-0 i-custom-vender-line-general-check h-4 w-4 text-text-accent" /> )} </div> - </div> + </button> {datasetOperatorEnabled && ( - <div - data-testid="role-option-dataset_operator" - className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover" + <button + type="button" + aria-pressed={value === 'dataset_operator'} + className="w-full cursor-pointer rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover" onClick={() => { onChange('dataset_operator') setOpen(false) @@ -119,12 +123,12 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { <div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div> {value === 'dataset_operator' && ( <div - data-testid="role-option-check" + aria-hidden="true" className="absolute top-0.5 left-0 i-custom-vender-line-general-check h-4 w-4 text-text-accent" /> )} </div> - </div> + </button> )} </div> </PopoverContent> diff --git a/web/app/components/header/account-setting/members-page/invited-modal/__tests__/invitation-link.spec.tsx b/web/app/components/header/account-setting/members-page/invited-modal/__tests__/invitation-link.spec.tsx index 06761da8cb..ddc06ab6e9 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/__tests__/invitation-link.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/__tests__/invitation-link.spec.tsx @@ -28,7 +28,7 @@ describe('InvitationLink', () => { render(<InvitationLink value={value} />) - const copyBtn = screen.getByTestId('invitation-link-copy') + const copyBtn = screen.getByRole('button', { name: 'appApi.copy' }) await user.click(copyBtn) expect(copy).toHaveBeenCalledWith('http://localhost:3000/invite/123') @@ -45,7 +45,7 @@ describe('InvitationLink', () => { render(<InvitationLink value={absoluteValue} />) - await user.click(screen.getByTestId('invitation-link-url')) + await user.click(screen.getByRole('button', { name: 'https://dify.ai/invite/123' })) expect(copy).toHaveBeenCalledWith('https://dify.ai/invite/123') }) @@ -54,7 +54,7 @@ describe('InvitationLink', () => { vi.useFakeTimers() render(<InvitationLink value={value} />) - const url = screen.getByTestId('invitation-link-url') + const url = screen.getByRole('button', { name: '/invite/123' }) // Initial state check - PopupContent should be "copy" // Since we mock i18next to return the key, we check for 'appApi.copy' diff --git a/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx index c49a1ea599..098c87cc99 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx @@ -2,9 +2,9 @@ import type { SuccessInvitationResult } from '.' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import copy from 'copy-to-clipboard' -import { t } from 'i18next' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import s from './index.module.css' type IInvitationLinkProps = { @@ -14,6 +14,7 @@ type IInvitationLinkProps = { const InvitationLink = ({ value, }: IInvitationLinkProps) => { + const { t } = useTranslation() const [isCopied, setIsCopied] = useState(false) const copyHandle = useCallback(() => { @@ -35,12 +36,20 @@ const InvitationLink = ({ }, [isCopied]) return ( - <div className="flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover" data-testid="invitation-link-container"> + <div className="flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover"> <div className="flex h-5 grow items-center"> <div className="relative h-full grow text-[13px]"> <Tooltip> <TooltipTrigger - render={<div className="absolute top-0 right-0 left-0 w-full cursor-pointer truncate pr-2 pl-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>} + render={( + <button + type="button" + className="absolute top-0 right-0 left-0 block w-full cursor-pointer truncate border-none bg-transparent p-0 pr-2 pl-2 text-left text-text-primary" + onClick={copyHandle} + > + {value.url} + </button> + )} /> <TooltipContent> {isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })} @@ -52,8 +61,12 @@ const InvitationLink = ({ <TooltipTrigger render={( <div className="shrink-0 px-0.5"> - <div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy"> - </div> + <button + type="button" + aria-label={t('copy', { ns: 'appApi' })} + className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg border-none bg-transparent p-0 hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} + onClick={copyHandle} + /> </div> )} /> diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx index 56a237d741..07371740bd 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx @@ -90,7 +90,7 @@ describe('TransferOwnershipModal', () => { } const goToTransferStep = async (user: ReturnType<typeof userEvent.setup>) => { - await user.click(screen.getByTestId('transfer-modal-send-code')) + await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) const input = await screen.findByTestId('transfer-modal-code-input') await user.type(input, '123456') await user.click(screen.getByTestId('transfer-modal-continue')) @@ -126,7 +126,7 @@ describe('TransferOwnershipModal', () => { renderModal() // Trigger the email send (which starts the timer) await act(async () => { - fireEvent.click(screen.getByTestId('transfer-modal-send-code')) + fireEvent.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) }) // Step Verify shows up @@ -144,7 +144,7 @@ describe('TransferOwnershipModal', () => { }) expect(screen.queryByText(/members\.transferModal\.resendCount/i)).not.toBeInTheDocument() - const resendBtn = screen.getByTestId('transfer-modal-resend') + const resendBtn = screen.getByRole('button', { name: /members\.transferModal\.resend/i }) await act(async () => { fireEvent.click(resendBtn) }) @@ -187,7 +187,7 @@ describe('TransferOwnershipModal', () => { const user = userEvent.setup() vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error')) renderModal() - await user.click(screen.getByTestId('transfer-modal-send-code')) + await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) // The base service layer surfaces the real backend error. The modal itself // must NOT show an additional toast (e.g. "Error sending verification code: undefined"). @@ -196,7 +196,7 @@ describe('TransferOwnershipModal', () => { }) expect(mockNotify).not.toHaveBeenCalled() // Should remain on the start step instead of advancing to the verify step. - expect(screen.getByTestId('transfer-modal-send-code')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })).toBeInTheDocument() }) it('should show error when ownership transfer fails', async () => { @@ -223,7 +223,7 @@ describe('TransferOwnershipModal', () => { } as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>) renderModal() - await user.click(screen.getByTestId('transfer-modal-send-code')) + await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) // Should advance to verify step even with null data await waitFor(() => { @@ -236,13 +236,13 @@ describe('TransferOwnershipModal', () => { vi.mocked(sendOwnerEmail).mockRejectedValue(null) renderModal() - await user.click(screen.getByTestId('transfer-modal-send-code')) + await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) await waitFor(() => { expect(sendOwnerEmail).toHaveBeenCalled() }) expect(mockNotify).not.toHaveBeenCalled() - expect(screen.getByTestId('transfer-modal-send-code')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })).toBeInTheDocument() }) it('should show fallback error prefix when verifyOwnerEmail throws null', async () => { @@ -281,7 +281,7 @@ describe('TransferOwnershipModal', () => { it('should close when close button is clicked', async () => { const user = userEvent.setup() renderModal() - await user.click(screen.getByTestId('transfer-modal-close')) + await user.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(mockOnClose).toHaveBeenCalled() }) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index cadc2dc967..6dab5ab93a 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -105,9 +105,14 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { return ( <Dialog open={show}> <DialogContent className="w-[420px]"> - <div data-testid="transfer-modal-close" className="absolute top-5 right-5 cursor-pointer p-1.5" onClick={onClose}> - <div className="i-ri-close-line h-5 w-5 text-text-tertiary" /> - </div> + <button + type="button" + className="absolute top-5 right-5 cursor-pointer border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onClose} + > + <span className="i-ri-close-line h-5 w-5 text-text-tertiary" aria-hidden="true" /> + </button> {step === STEP.start && ( <> <div className="pb-3 title-2xl-semi-bold text-text-primary">{t('members.transferModal.title', { ns: 'common' })}</div> @@ -120,7 +125,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { </div> <div className="pt-3"></div> <div className="space-y-2"> - <Button data-testid="transfer-modal-send-code" className="w-full!" variant="primary" onClick={sendCodeToOriginEmail}> + <Button className="w-full!" variant="primary" onClick={sendCodeToOriginEmail}> {t('members.transferModal.sendVerifyCode', { ns: 'common' })} </Button> <Button data-testid="transfer-modal-cancel" className="w-full!" onClick={onClose}> @@ -154,9 +159,13 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { <span>{t('members.transferModal.resendTip', { ns: 'common' })}</span> {time > 0 && (<span>{t('members.transferModal.resendCount', { ns: 'common', count: time })}</span>)} {!time && ( - <span data-testid="transfer-modal-resend" onClick={sendCodeToOriginEmail} className="cursor-pointer system-xs-medium text-text-accent-secondary"> + <button + type="button" + onClick={sendCodeToOriginEmail} + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-accent-secondary" + > {t('members.transferModal.resend', { ns: 'common' })} - </span> + </button> )} </div> </> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx index 7c996515b2..0b920008b4 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx @@ -113,7 +113,7 @@ describe('ProviderAddedCard', () => { mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] }) renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />) - const showModelsBtn = screen.getByTestId('show-models-button') + const showModelsBtn = screen.getByRole('button', { name: /modelProvider\.showModels/i }) fireEvent.click(showModelsBtn) await waitFor(() => { @@ -127,7 +127,7 @@ describe('ProviderAddedCard', () => { await waitFor(() => expect(screen.queryByTestId('model-list')).not.toBeInTheDocument()) // Explicitly re-find and click to re-open - fireEvent.click(screen.getByTestId('show-models-button')) + fireEvent.click(screen.getByRole('button', { name: /modelProvider\.showModels/i })) expect(await screen.findByTestId('model-list')).toBeInTheDocument() expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(2) // Re-open fetches again with default stale/gc behavior @@ -147,7 +147,7 @@ describe('ProviderAddedCard', () => { mockFetchModelProviderModels.mockReturnValue(promise) renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />) - const showModelsBtn = screen.getByTestId('show-models-button') + const showModelsBtn = screen.getByRole('button', { name: /modelProvider\.showModels/i }) // First call sets loading to true fireEvent.click(showModelsBtn) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx index c0bbab04f3..3439206785 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx @@ -127,8 +127,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ {(showModelProvider || !notConfigured) && ( <button type="button" - data-testid="show-models-button" - className="flex h-6 items-center rounded-lg pr-1.5 pl-1 hover:bg-components-button-ghost-bg-hover" + className="flex h-6 items-center rounded-lg border-none bg-transparent pr-1.5 pl-1 text-left hover:bg-components-button-ghost-bg-hover" aria-label={t('modelProvider.showModels', { ns: 'common' })} onClick={handleOpenModelList} > @@ -137,7 +136,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ ? t('modelProvider.modelsNum', { ns: 'common', num: modelList.length }) : t('modelProvider.showModels', { ns: 'common' }) } - {!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />} + {!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" aria-hidden="true" />} { loading && ( <div className="ml-0.5 i-ri-loader-2-line h-3 w-3 animate-spin" /> diff --git a/web/app/components/header/maintenance-notice.tsx b/web/app/components/header/maintenance-notice.tsx index 4df7108177..2c60477584 100644 --- a/web/app/components/header/maintenance-notice.tsx +++ b/web/app/components/header/maintenance-notice.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { X } from '@/app/components/base/icons/src/vender/line/general' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { NOTICE_I18N } from '@/i18n-config/language' const MaintenanceNotice = () => { + const { t } = useTranslation() const locale = useLanguage() const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1') @@ -27,10 +29,25 @@ const MaintenanceNotice = () => { <div className="mr-2 flex h-[22px] shrink-0 items-center rounded-xl bg-[#F79009] px-2 text-[11px] font-medium text-white">{titleByLocale[locale]}</div> { (NOTICE_I18N.href && NOTICE_I18N.href !== '#') - ? <div className="grow cursor-pointer text-xs font-medium text-gray-700" onClick={handleJumpNotice}>{descByLocale[locale]}</div> + ? ( + <button + type="button" + className="grow cursor-pointer border-none bg-transparent p-0 text-left text-xs font-medium text-gray-700" + onClick={handleJumpNotice} + > + {descByLocale[locale]} + </button> + ) : <div className="grow text-xs font-medium text-gray-700">{descByLocale[locale]}</div> } - <X className="h-4 w-4 shrink-0 cursor-pointer text-gray-500" onClick={handleCloseNotice} /> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="h-4 w-4 shrink-0 cursor-pointer border-none bg-transparent p-0 text-gray-500" + onClick={handleCloseNotice} + > + <X className="h-4 w-4" aria-hidden="true" /> + </button> </div> ) } diff --git a/web/app/components/plugins/install-plugin/install-bundle/index.tsx b/web/app/components/plugins/install-plugin/install-bundle/index.tsx index 1cf2202dae..5a7cf2e5f5 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/index.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/index.tsx @@ -58,7 +58,7 @@ const InstallBundle: FC<Props> = ({ }} > <DialogContent className={cn('relative w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> <div className="self-stretch title-2xl-semi-bold text-text-primary"> diff --git a/web/app/components/plugins/install-plugin/install-from-github/index.tsx b/web/app/components/plugins/install-plugin/install-from-github/index.tsx index ca2b28d2ef..9808b87627 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/index.tsx @@ -170,7 +170,7 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on <DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, `shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0`))} > - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> <div className="flex grow flex-col items-start gap-1"> diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx index e7696d455a..b2a780dbdb 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx @@ -94,7 +94,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({ }} > <DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> <div className="self-stretch title-2xl-semi-bold text-text-primary"> diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx index f2bcc14fda..523009abe1 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx @@ -78,7 +78,7 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({ }} > <DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> <div className="self-stretch title-2xl-semi-bold text-text-primary"> diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx index f00df35d0c..dc05641a08 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx @@ -104,7 +104,7 @@ describe('AddOAuthButton', () => { it('should open OAuth settings modal when settings icon clicked', () => { render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />) - fireEvent.click(screen.getByTestId('oauth-settings-button')) + fireEvent.click(screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i })) expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument() expect(mockOAuthClientSettingsProps.at(-1)?.open).toBe(true) @@ -113,7 +113,7 @@ describe('AddOAuthButton', () => { it('should close OAuth settings modal', () => { render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />) - fireEvent.click(screen.getByTestId('oauth-settings-button')) + fireEvent.click(screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i })) fireEvent.click(screen.getByTestId('oauth-settings-close')) expect(screen.queryByTestId('oauth-settings-modal')).not.toBeInTheDocument() @@ -223,7 +223,7 @@ describe('AddOAuthButton', () => { />, ) - fireEvent.click(screen.getByTestId('oauth-settings-button')) + fireEvent.click(screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i })) const settingsProps = mockOAuthClientSettingsProps.at(-1) expect(settingsProps?.editValues).toMatchObject({ 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 0cc374d113..6987d6435b 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 @@ -231,7 +231,7 @@ describe('ApiKeyModal', () => { const mockOnClose = vi.fn() render(<ApiKeyModal pluginPayload={basePayload} onClose={mockOnClose} />) - fireEvent.click(screen.getByTestId('modal-close')) + fireEvent.click(screen.getByRole('button', { name: /Close|operation.close/ })) expect(mockOnClose).toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx index cba5c60654..3601d95a6b 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx @@ -440,7 +440,7 @@ describe('AddOAuthButton', () => { { wrapper: createWrapper() }, ) - expect(screen.getByRole('button').className).toContain('custom-class') + expect(screen.getByText('use oauth').closest('.custom-class')).toBeInTheDocument() }) it('should use oAuthData prop when provided', () => { @@ -580,8 +580,7 @@ describe('AddOAuthButton', () => { render(<AddOAuthButton pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) - // Click the settings icon using data-testid for reliable selection - const settingsButton = screen.getByTestId('oauth-settings-button') + const settingsButton = screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i }) fireEvent.click(settingsButton) await waitFor(() => { @@ -669,11 +668,7 @@ describe('AddOAuthButton', () => { render(<AddOAuthButton pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) - // Open settings by clicking the gear icon - const button = screen.getByRole('button') - const gearIconContainer = button.querySelector('[class*="shrink-0"][class*="w-8"]') - if (gearIconContainer) - fireEvent.click(gearIconContainer) + fireEvent.click(screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i })) await waitFor(() => { expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() @@ -706,11 +701,7 @@ describe('AddOAuthButton', () => { render(<AddOAuthButton pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) - // Click the settings icon - const button = screen.getByRole('button') - const gearIconContainer = button.querySelector('[class*="shrink-0"][class*="w-8"]') - if (gearIconContainer) - fireEvent.click(gearIconContainer) + fireEvent.click(screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i })) await waitFor(() => { // OAuthClientSettings modal should open 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 7509090be3..1a0e63c45e 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 @@ -205,7 +205,7 @@ describe('OAuthClientSettings', () => { />, ) - fireEvent.click(screen.getByTestId('modal-close')) + fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/i })) expect(mockOnClose).toHaveBeenCalled() }) @@ -213,7 +213,7 @@ describe('OAuthClientSettings', () => { const mockOnClose = vi.fn() render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />) - fireEvent.click(screen.getByTestId('modal-close')) + fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/i })) await waitFor(() => { expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx index 41ef893db8..b2326d8ed0 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx @@ -187,19 +187,15 @@ const AddOAuthButton = ({ <> { isConfigured && ( - <Button - variant={buttonVariant} - className={cn( - 'w-full px-0 py-0 hover:bg-components-button-primary-bg', - className, - )} - disabled={disabled} - onClick={handleOAuth} - > - <div className={cn( - 'flex h-full w-0 grow items-center justify-center rounded-l-lg pl-0.5 hover:bg-components-button-primary-bg-hover', - buttonLeftClassName, - )} + <div className={cn('flex w-full', className)}> + <Button + variant={buttonVariant} + className={cn( + 'h-8 min-w-0 flex-1 rounded-r-none px-0 py-0 hover:bg-components-button-primary-bg-hover', + buttonLeftClassName, + )} + disabled={disabled} + onClick={handleOAuth} > <div className="truncate" @@ -219,27 +215,28 @@ const AddOAuthButton = ({ </Badge> ) } - </div> + </Button> <div className={cn( - 'h-4 w-px shrink-0 bg-text-primary-on-surface opacity-[0.15]', + 'h-4 w-px shrink-0 self-center bg-text-primary-on-surface opacity-[0.15]', dividerClassName, )} > </div> - <div - data-testid="oauth-settings-button" + <Button + variant={buttonVariant} + aria-label={t('auth.oauthClientSettings', { ns: 'plugin' })} className={cn( - 'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover', + 'h-8 w-8 shrink-0 rounded-l-none px-0 py-0 hover:bg-components-button-primary-bg-hover', buttonRightClassName, )} - onClick={(e) => { - e.stopPropagation() + disabled={disabled} + onClick={() => { openOAuthSettings() }} > - <span className="i-ri-equalizer-2-line h-4 w-4" /> - </div> - </Button> + <span className="i-ri-equalizer-2-line h-4 w-4" aria-hidden="true" /> + </Button> + </div> ) } { 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 3a7e495a77..90e44cc5ec 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 @@ -152,7 +152,6 @@ const ApiKeyModal = ({ {t('auth.useApiAuthDesc', { ns: 'plugin' })} </div> <DialogCloseButton - data-testid="modal-close" className="top-5 right-5 h-8 w-8 rounded-lg" /> </div> 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 a3bd35a865..9f4c5cc046 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 @@ -150,7 +150,6 @@ const OAuthClientSettings = ({ {t('auth.oauthClientSettings', { ns: 'plugin' })} </DialogTitle> <DialogCloseButton - data-testid="modal-x-close" className="top-5 right-5 h-8 w-8 rounded-lg" /> </div> @@ -182,7 +181,6 @@ const OAuthClientSettings = ({ </div> <div className="flex items-center"> <Button - data-testid="modal-close" variant="secondary" onClick={() => handleOpenChange(false)} disabled={isDisabled} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx index af52ec14f0..2f050f2747 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx @@ -379,7 +379,7 @@ describe('CommonCreateModal', () => { const mockOnClose = vi.fn() render(<CommonCreateModal {...defaultProps} onClose={mockOnClose} />) - fireEvent.click(screen.getByTestId('modal-close')) + fireEvent.click(screen.getByRole('button', { name: /Close|operation.close/ })) expect(mockOnClose).toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx index f15a933f18..201f521b48 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx @@ -98,7 +98,6 @@ function CommonCreateModalContent({ onClose, createType, builder }: Omit<Props, </DialogTitle> <DialogCloseButton className="top-5 right-5 h-8 w-8 rounded-lg [&>span]:h-5 [&>span]:w-5" - data-testid="modal-close" onClick={onClose} /> </div> diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx index 3c8b8c6aa7..189e4af2a5 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx @@ -68,7 +68,7 @@ export const OAuthClientSettingsModal = ({ open, oauthConfig, onOpenChange, show <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> {title} </DialogTitle> - <DialogCloseButton data-testid="modal-close" className="top-5 right-5 h-8 w-8 rounded-lg" /> + <DialogCloseButton className="top-5 right-5 h-8 w-8 rounded-lg" /> </div> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> <div className="mb-2 system-sm-medium text-text-secondary"> diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx index e1544f6649..dcf07e66a2 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx @@ -76,6 +76,11 @@ vi.mock('../../../store', () => ({ selector({ detail: mockPluginStoreDetail }), })) +const getCancelButton = () => screen.getByRole('button', { name: /common\.operation\.cancel/i }) +const getConfirmButton = () => screen.getByRole('button', { name: /common\.operation\.(save|saving)|pluginTrigger\.modal\.common\.verify/i }) +const getBackButton = () => screen.getByRole('button', { name: /pluginTrigger\.modal\.common\.back/i }) +const queryBackButton = () => screen.queryByRole('button', { name: /pluginTrigger\.modal\.common\.back/i }) + const mockRefetch = vi.fn() vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), @@ -401,7 +406,7 @@ describe('Edit Modal Components', () => { describe('Confirm Button Text', () => { it('should show "save" when not updating', () => { render(<ManualEditModal {...createProps()} />) - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) }) @@ -409,21 +414,21 @@ describe('Edit Modal Components', () => { it('should call onClose when cancel button is clicked', () => { const onClose = vi.fn() render(<ManualEditModal {...createProps({ onClose })} />) - fireEvent.click(screen.getByTestId('modal-cancel-button')) + fireEvent.click(getCancelButton()) expect(onClose).toHaveBeenCalledTimes(1) }) it('should call onClose when close button is clicked', () => { const onClose = vi.fn() render(<ManualEditModal {...createProps({ onClose })} />) - fireEvent.click(screen.getByTestId('modal-close-button')) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(onClose).toHaveBeenCalledTimes(1) }) it('should call updateSubscription when confirm is clicked with valid form', () => { formValuesMap.set('main', { values: { subscription_name: 'New Name' }, isCheckValidated: true }) render(<ManualEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ subscriptionId: 'test-subscription-id', name: 'New Name' }), expect.any(Object), @@ -433,7 +438,7 @@ describe('Edit Modal Components', () => { it('should not call updateSubscription when form validation fails', () => { formValuesMap.set('main', { values: {}, isCheckValidated: false }) render(<ManualEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).not.toHaveBeenCalled() }) }) @@ -446,7 +451,7 @@ describe('Edit Modal Components', () => { isCheckValidated: true, }) render(<ManualEditModal {...createProps({ subscription })} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ properties: undefined }), expect.any(Object), @@ -460,7 +465,7 @@ describe('Edit Modal Components', () => { isCheckValidated: true, }) render(<ManualEditModal {...createProps({ subscription })} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ properties: { custom: 'new' } }), expect.any(Object), @@ -474,7 +479,7 @@ describe('Edit Modal Components', () => { mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess()) const onClose = vi.fn() render(<ManualEditModal {...createProps({ onClose })} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) }) @@ -486,7 +491,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Custom error'))) render(<ManualEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -499,7 +504,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 'Object error' })) render(<ManualEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -512,7 +517,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({})) render(<ManualEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -525,7 +530,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(null)) render(<ManualEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -538,7 +543,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 123 })) render(<ManualEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -551,7 +556,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: '' })) render(<ManualEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -591,7 +596,7 @@ describe('Edit Modal Components', () => { it('should show saving text when isUpdating is true', () => { mockIsUpdating = true render(<ManualEditModal {...createProps()} />) - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.saving') + expect(getConfirmButton()).toHaveTextContent('common.operation.saving') }) }) }) @@ -697,7 +702,7 @@ describe('Edit Modal Components', () => { })} />, ) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ parameters: undefined }), expect.any(Object), @@ -718,7 +723,7 @@ describe('Edit Modal Components', () => { })} />, ) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ parameters: { channel: 'new' } }), expect.any(Object), @@ -732,7 +737,7 @@ describe('Edit Modal Components', () => { mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess()) const onClose = vi.fn() render(<OAuthEditModal {...createProps({ onClose })} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) }) @@ -743,7 +748,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Failed'))) render(<OAuthEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) @@ -753,7 +758,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 123 })) render(<OAuthEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -766,7 +771,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: '' })) render(<OAuthEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -780,7 +785,7 @@ describe('Edit Modal Components', () => { it('should not call updateSubscription when form validation fails', () => { formValuesMap.set('main', { values: {}, isCheckValidated: false }) render(<OAuthEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).not.toHaveBeenCalled() }) }) @@ -831,7 +836,7 @@ describe('Edit Modal Components', () => { it('should show saving text when isUpdating is true', () => { mockIsUpdating = true render(<OAuthEditModal {...createProps()} />) - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.saving') + expect(getConfirmButton()).toHaveTextContent('common.operation.saving') }) }) }) @@ -879,12 +884,12 @@ describe('Edit Modal Components', () => { it('should show verify button text in credentials step', () => { render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('pluginTrigger.modal.common.verify') + expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.verify') }) it('should not show extra button (back) in credentials step', () => { render(<ApiKeyEditModal {...createProps()} />) - expect(screen.queryByTestId('modal-extra-button')).not.toBeInTheDocument() + expect(queryBackButton()).not.toBeInTheDocument() }) it('should render ReadmeEntrance when pluginDetail is provided', () => { @@ -917,7 +922,7 @@ describe('Edit Modal Components', () => { it('should call verifyCredentials when confirm clicked in credentials step', () => { formValuesMap.set('credentials', { values: { api_key: 'test-key' }, isCheckValidated: true }) render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockVerifyCredentials).toHaveBeenCalledWith( expect.objectContaining({ provider: 'test-provider', @@ -931,7 +936,7 @@ describe('Edit Modal Components', () => { it('should not call verifyCredentials when form validation fails', () => { formValuesMap.set('credentials', { values: {}, isCheckValidated: false }) render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockVerifyCredentials).not.toHaveBeenCalled() }) @@ -939,7 +944,7 @@ describe('Edit Modal Components', () => { formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true }) mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', @@ -947,7 +952,7 @@ describe('Edit Modal Components', () => { })) }) // Should now be in step 2 - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) it('should show error toast on verification failure', async () => { @@ -955,7 +960,7 @@ describe('Edit Modal Components', () => { mockParsePluginErrorMessage.mockResolvedValue('Invalid API key') mockVerifyCredentials.mockImplementation((_p, cb) => cb.onError(new Error('Invalid'))) render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -969,7 +974,7 @@ describe('Edit Modal Components', () => { mockParsePluginErrorMessage.mockResolvedValue(null) mockVerifyCredentials.mockImplementation((_p, cb) => cb.onError(new Error('Invalid'))) render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -985,13 +990,13 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) // Verify credentials - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) // Update subscription - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ credentials: undefined }), expect.any(Object), @@ -1008,24 +1013,24 @@ describe('Edit Modal Components', () => { it('should show save button text in configuration step', async () => { render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) }) it('should show extra button (back) in configuration step', async () => { render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-extra-button')).toBeInTheDocument() - expect(screen.getByTestId('modal-extra-button')).toHaveTextContent('pluginTrigger.modal.common.back') + expect(getBackButton()).toBeInTheDocument() + expect(getBackButton()).toHaveTextContent('pluginTrigger.modal.common.back') }) }) it('should not show EncryptedBottom in configuration step', async () => { render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(screen.queryByTestId('modal-bottom-slot')).not.toBeInTheDocument() }) @@ -1033,7 +1038,7 @@ describe('Edit Modal Components', () => { it('should render basic form fields in step 2', async () => { render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument() expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() @@ -1045,7 +1050,7 @@ describe('Edit Modal Components', () => { createSchemaField('param1'), ] render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(screen.getByTestId('form-field-param1')).toBeInTheDocument() }) @@ -1063,19 +1068,19 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) // Go to step 2 - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-extra-button')).toBeInTheDocument() + expect(getBackButton()).toBeInTheDocument() }) // Click back - fireEvent.click(screen.getByTestId('modal-extra-button')) + fireEvent.click(getBackButton()) // Should be back in step 1 await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('pluginTrigger.modal.common.verify') + expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.verify') }) - expect(screen.queryByTestId('modal-extra-button')).not.toBeInTheDocument() + expect(queryBackButton()).not.toBeInTheDocument() }) it('should go back to credentials step when clicking step indicator', async () => { @@ -1084,9 +1089,9 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) // Go to step 2 - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) // Find and click the step indicator (first step text should be clickable in step 2) @@ -1095,7 +1100,7 @@ describe('Edit Modal Components', () => { // Should be back in step 1 await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('pluginTrigger.modal.common.verify') + expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.verify') }) }) }) @@ -1112,13 +1117,13 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) // Step 1: Verify - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) // Step 2: Update - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ subscriptionId: 'test-subscription-id', @@ -1133,12 +1138,12 @@ describe('Edit Modal Components', () => { formValuesMap.set('basic', { values: {}, isCheckValidated: false }) render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).not.toHaveBeenCalled() }) @@ -1148,12 +1153,12 @@ describe('Edit Modal Components', () => { const onClose = vi.fn() render(<ApiKeyEditModal {...createProps({ onClose })} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', @@ -1170,12 +1175,12 @@ describe('Edit Modal Components', () => { mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Failed'))) render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', @@ -1208,12 +1213,12 @@ describe('Edit Modal Components', () => { />, ) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ parameters: undefined }), expect.any(Object), @@ -1233,12 +1238,12 @@ describe('Edit Modal Components', () => { />, ) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( expect.objectContaining({ parameters: { param1: 'new_value' } }), expect.any(Object), @@ -1285,9 +1290,9 @@ describe('Edit Modal Components', () => { ] render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) expect(screen.getByTestId('form-field-channel')).toHaveAttribute('data-has-dynamic-select', 'true') @@ -1307,9 +1312,9 @@ describe('Edit Modal Components', () => { ] render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) expect(screen.getByTestId('form-field-enabled')).toHaveAttribute( @@ -1384,12 +1389,12 @@ describe('Edit Modal Components', () => { formValuesMap.set('parameters', { values: {}, isCheckValidated: false }) render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save') + expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).not.toHaveBeenCalled() }) }) @@ -1415,7 +1420,7 @@ describe('Edit Modal Components', () => { createSchemaField('secret_param', 'password'), ] render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(screen.getByTestId('form-field-secret_param')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput) }) @@ -1426,7 +1431,7 @@ describe('Edit Modal Components', () => { createSchemaField('api_secret', 'secret'), ] render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput) }) @@ -1437,7 +1442,7 @@ describe('Edit Modal Components', () => { createSchemaField('count', 'integer'), ] render(<ApiKeyEditModal {...createProps()} />) - fireEvent.click(screen.getByTestId('modal-confirm-button')) + fireEvent.click(getConfirmButton()) await waitFor(() => { expect(screen.getByTestId('form-field-count')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx index 8341d716d6..14d7dc30ad 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx @@ -313,7 +313,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> {title} </DialogTitle> - <DialogCloseButton data-testid="modal-close-button" className="top-5 right-5 h-8 w-8 rounded-lg" /> + <DialogCloseButton className="top-5 right-5 h-8 w-8 rounded-lg" /> </div> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> {pluginDetail && ( @@ -362,7 +362,6 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) {currentStep === EditStep.EditConfiguration && ( <> <Button - data-testid="modal-extra-button" variant="secondary" onClick={handleBack} disabled={isDisabled} @@ -373,14 +372,12 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) </> )} <Button - data-testid="modal-cancel-button" onClick={onClose} disabled={isDisabled} > {t('operation.cancel', { ns: 'common' })} </Button> <Button - data-testid="modal-confirm-button" className="ml-2" variant="primary" onClick={handleConfirm} 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 a7b5c9f2c0..d70fcbfdef 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 @@ -154,7 +154,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> {title} </DialogTitle> - <DialogCloseButton data-testid="modal-close-button" className="top-5 right-5 h-8 w-8 rounded-lg" /> + <DialogCloseButton className="top-5 right-5 h-8 w-8 rounded-lg" /> </div> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> {pluginDetail && ( @@ -171,14 +171,12 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) <div /> <div className="flex items-center"> <Button - data-testid="modal-cancel-button" onClick={onClose} disabled={isUpdating} > {t('operation.cancel', { ns: 'common' })} </Button> <Button - data-testid="modal-confirm-button" className="ml-2" variant="primary" onClick={handleConfirm} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx index 1416e02ac0..afbdbe56b3 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx @@ -168,7 +168,7 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) = <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> {title} </DialogTitle> - <DialogCloseButton data-testid="modal-close-button" className="top-5 right-5 h-8 w-8 rounded-lg" /> + <DialogCloseButton className="top-5 right-5 h-8 w-8 rounded-lg" /> </div> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> {pluginDetail && ( @@ -185,14 +185,12 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) = <div /> <div className="flex items-center"> <Button - data-testid="modal-cancel-button" onClick={onClose} disabled={isUpdating} > {t('operation.cancel', { ns: 'common' })} </Button> <Button - data-testid="modal-confirm-button" className="ml-2" variant="primary" onClick={handleConfirm} diff --git a/web/app/components/plugins/plugin-mutation-model/index.tsx b/web/app/components/plugins/plugin-mutation-model/index.tsx index 339ec9edb5..c8f666326a 100644 --- a/web/app/components/plugins/plugin-mutation-model/index.tsx +++ b/web/app/components/plugins/plugin-mutation-model/index.tsx @@ -41,7 +41,7 @@ const PluginMutationModal: FC<Props> = ({ }} > <DialogContent className="w-full min-w-[560px] overflow-hidden! border-none text-left align-middle"> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> {modelTitle} </DialogTitle> diff --git a/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx index 067cea0a42..59d041f50c 100644 --- a/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx @@ -465,7 +465,7 @@ describe('PluginPage Component', () => { it('should open settings modal when settings button is clicked', async () => { render(<PluginPageWithContext {...createDefaultProps()} />) - fireEvent.click(screen.getByTestId('plugin-settings-button')) + fireEvent.click(screen.getByRole('button', { name: /plugin\.privilege\.title/i })) await waitFor(() => { expect(screen.getByTestId('reference-setting-modal')).toBeInTheDocument() @@ -476,7 +476,7 @@ describe('PluginPage Component', () => { render(<PluginPageWithContext {...createDefaultProps()} />) // Open modal - fireEvent.click(screen.getByTestId('plugin-settings-button')) + fireEvent.click(screen.getByRole('button', { name: /plugin\.privilege\.title/i })) await waitFor(() => { expect(screen.getByTestId('reference-setting-modal')).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index d40a91e2b0..acd25836ab 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -222,11 +222,11 @@ const PluginPage = ({ <TooltipTrigger render={( <Button - data-testid="plugin-settings-button" + aria-label={t('privilege.title', { ns: 'plugin' })} className="group h-full w-full p-2 text-components-button-secondary-text" onClick={setShowPluginSettingModal} > - <RiEqualizer2Line className="h-4 w-4" /> + <RiEqualizer2Line className="h-4 w-4" aria-hidden="true" /> </Button> )} /> diff --git a/web/app/components/plugins/plugin-page/plugin-info.tsx b/web/app/components/plugins/plugin-page/plugin-info.tsx index 29a137afca..6c836731cf 100644 --- a/web/app/components/plugins/plugin-page/plugin-info.tsx +++ b/web/app/components/plugins/plugin-page/plugin-info.tsx @@ -31,7 +31,7 @@ const PlugInfo: FC<Props> = ({ }} > <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle"> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> {t(`${i18nPrefix}.title`, { ns: 'plugin' })} </DialogTitle> diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx index c39147e83f..0a61802792 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx @@ -1104,7 +1104,7 @@ describe('auto-update-setting', () => { // Assert expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":2}')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.operation.clearAll')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })).toBeInTheDocument() }) it('should render select button', () => { @@ -1143,7 +1143,7 @@ describe('auto-update-setting', () => { onChange={onChange} />, ) - fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) // Assert expect(onChange).toHaveBeenCalledWith([]) @@ -1350,10 +1350,10 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click time picker trigger - fireEvent.click(screen.getByTestId('time-picker').querySelector('[data-testid="time-input"]')!.parentElement!) + fireEvent.click(screen.getByRole('button', { name: /GMT-5/ })) // Set time - fireEvent.click(screen.getByTestId('time-picker-set')) + fireEvent.click(screen.getByRole('button', { name: 'Set 10:30' })) // Assert expect(onChange).toHaveBeenCalled() @@ -1368,10 +1368,10 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click time picker trigger - fireEvent.click(screen.getByTestId('time-picker').querySelector('[data-testid="time-input"]')!.parentElement!) + fireEvent.click(screen.getByRole('button', { name: /GMT-5/ })) // Clear time - fireEvent.click(screen.getByTestId('time-picker-clear')) + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) // Assert expect(onChange).toHaveBeenCalled() @@ -1390,7 +1390,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click clear all - fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) // Assert expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ @@ -1411,7 +1411,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click clear all - fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) // Assert expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ @@ -1426,7 +1426,6 @@ describe('auto-update-setting', () => { // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) - // Assert - timezone Trans component is rendered expect(screen.getByText('autoUpdate.changeTimezone')).toBeInTheDocument() }) }) @@ -1459,7 +1458,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Trigger a change (clear plugins) - fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) // Assert - other values should be preserved expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx index 99d7a5fdc5..a9ffe76ae6 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx @@ -60,7 +60,7 @@ describe('PluginsPicker', () => { expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":2}')).toBeInTheDocument() expect(screen.getByTestId('plugins-selected')).toHaveTextContent('dify/plugin-1,dify/plugin-2') - fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) expect(onChange).toHaveBeenCalledWith([]) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx index 5710eb7a66..33049f4b1b 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx @@ -33,7 +33,13 @@ const SettingTimeZone: FC<{ }) => { const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) return ( - <span className="cursor-pointer body-xs-regular text-text-accent" onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })}>{children}</span> + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left body-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })} + > + {children} + </button> ) } const AutoUpdateSetting: FC<Props> = ({ @@ -104,8 +110,9 @@ const AutoUpdateSetting: FC<Props> = ({ const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => { return ( - <div - className="group flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2 hover:bg-state-base-hover-alt" + <button + type="button" + className="group flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg border-none bg-components-input-bg-normal px-2 text-left hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onClick} > <div className="flex w-0 grow items-center gap-x-1"> @@ -117,7 +124,7 @@ const AutoUpdateSetting: FC<Props> = ({ {inputElem} </div> <div className="system-sm-regular text-text-tertiary">{convertTimezoneToOffsetStr(timezone)}</div> - </div> + </button> ) }, [timezone]) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx index 1bb4caeb3f..ef25c35592 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx @@ -39,7 +39,13 @@ const PluginsPicker: FC<Props> = ({ ? ( <div className="flex justify-between text-text-tertiary"> <div className="system-xs-medium">{t(`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`, { ns: 'plugin', num: value.length })}</div> - <div className="cursor-pointer system-xs-medium" onClick={handleClear}>{t(`${i18nPrefix}.operation.clearAll`, { ns: 'plugin' })}</div> + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleClear} + > + {t(`${i18nPrefix}.operation.clearAll`, { ns: 'plugin' })} + </button> </div> ) : ( diff --git a/web/app/components/plugins/reference-setting-modal/index.tsx b/web/app/components/plugins/reference-setting-modal/index.tsx index 9bc0aaf7d6..e7d15f5402 100644 --- a/web/app/components/plugins/reference-setting-modal/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/index.tsx @@ -61,7 +61,7 @@ const PluginSettingModal: FC<Props> = ({ }} > <DialogContent className="w-[620px] max-w-[620px] overflow-hidden! border-none p-0! text-left align-middle"> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <div className="shadows-shadow-xl flex w-full flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg"> <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 30b503899e..2f21811f86 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -729,7 +729,7 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should call onCancel when close icon is clicked', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.click(screen.getByTestId('publish-modal-close-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx index e1a4af0410..4a795ba516 100644 --- a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx @@ -122,7 +122,7 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should call onCancel when close button clicked', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.click(screen.getByTestId('publish-modal-close-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx index d8feea44c6..d824ea52fb 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx @@ -153,7 +153,7 @@ describe('InputFieldEditorPanel', () => { renderWithProviders(<InputFieldEditorPanel {...props} />) - const closeButton = screen.getByRole('button', { name: '' }) + const closeButton = screen.getByRole('button', { name: /Close|operation.close/ }) expect(closeButton).toBeInTheDocument() }) @@ -270,7 +270,7 @@ describe('InputFieldEditorPanel', () => { const props = createInputFieldEditorProps({ onClose }) renderWithProviders(<InputFieldEditorPanel {...props} />) - fireEvent.click(screen.getByTestId('input-field-editor-close-btn')) + fireEvent.click(screen.getByRole('button', { name: /Close|operation.close/ })) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx index 9dd943f969..438fef8d7b 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx @@ -55,7 +55,7 @@ describe('ShowAllSettings', () => { } render(<ShowAllSettingsHarness />) - fireEvent.click(screen.getByText('appDebug.variableConfig.showAllSettings')) + fireEvent.click(screen.getByRole('button', { name: /appDebug\.variableConfig\.showAllSettings/ })) expect(handleShowAllSettings).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx index d881188446..4eb290450a 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx @@ -24,7 +24,11 @@ const ShowAllSettings = ({ const hiddenFieldNames = useHiddenFieldNames(type) return ( - <div className="flex cursor-pointer items-center gap-x-4" onClick={handleShowAllSettings}> + <button + type="button" + className="flex w-full cursor-pointer items-center gap-x-4 border-none bg-transparent p-0 text-left" + onClick={handleShowAllSettings} + > <div className="flex grow flex-col"> <span className="flex min-h-6 items-center system-sm-medium text-text-secondary"> {t('variableConfig.showAllSettings', { ns: 'appDebug' })} @@ -33,8 +37,8 @@ const ShowAllSettings = ({ {hiddenFieldNames} </span> </div> - <RiArrowRightSLine className="h-4 w-4 shrink-0 text-text-secondary" /> - </div> + <RiArrowRightSLine className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" /> + </button> ) }, }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/index.tsx index 6bd21866db..5b7f565c09 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/index.tsx @@ -49,11 +49,11 @@ const InputFieldEditorPanel = ({ </div> <button type="button" - data-testid="input-field-editor-close-btn" - className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center border-none bg-transparent p-0" onClick={onClose} > - <RiCloseLine className="size-4 text-text-tertiary" /> + <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> </button> <InputFieldForm initialData={formData} diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx index 84c700e273..b87b097c60 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx @@ -1574,7 +1574,7 @@ describe('handleSubmitField', () => { />, ) - fireEvent.click(screen.getByTestId('field-list-add-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) expect(mockToggleInputFieldEditPanel).toHaveBeenCalled() const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1798,7 +1798,7 @@ describe('handleSubmitField', () => { />, ) - fireEvent.click(screen.getByTestId('field-list-add-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1822,7 +1822,7 @@ describe('handleSubmitField', () => { />, ) - fireEvent.click(screen.getByTestId('field-list-add-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] expect(editorProps).toHaveProperty('onClose') @@ -1853,7 +1853,7 @@ describe('Duplicate Variable Name Handling', () => { />, ) - fireEvent.click(screen.getByTestId('field-list-add-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1939,7 +1939,7 @@ describe('Integration Tests', () => { ) fireEvent.mouseEnter(container.querySelector('.handle')!) - fireEvent.click(screen.getByTestId('field-list-add-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) expect(mockToggleInputFieldEditPanel).toHaveBeenCalled() const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.tsx index 64b05f762d..0e7400d302 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.tsx @@ -3,6 +3,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { RiAddLine } from '@remixicon/react' import * as React from 'react' import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm' import FieldListContainer from './field-list-container' @@ -27,6 +28,7 @@ const FieldList = ({ labelClassName, allVariableNames, }: FieldListProps) => { + const { t } = useTranslation() const onInputFieldsChange = useCallback((value: InputVar[]) => { handleInputFieldsChange(nodeId, value) }, [handleInputFieldsChange, nodeId]) @@ -53,12 +55,12 @@ const FieldList = ({ {LabelRightContent} </div> <ActionButton - data-testid="field-list-add-btn" + aria-label={t('operation.add', { ns: 'common' })} onClick={() => handleOpenInputFieldEditor()} disabled={readonly} className={cn(readonly && 'cursor-not-allowed')} > - <RiAddLine className="h-4 w-4 text-text-tertiary" /> + <RiAddLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> </ActionButton> </div> <FieldListContainer diff --git a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx index eeb6337847..a7f06197b5 100644 --- a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx +++ b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx @@ -81,13 +81,14 @@ const PublishAsKnowledgePipelineModal = ({ <div className="relative flex items-center p-6 pr-14 pb-3 title-2xl-semi-bold text-text-primary"> {t('common.publishAs', { ns: 'pipeline' })} - <div - data-testid="publish-modal-close-btn" - className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center" + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center border-none bg-transparent p-0" onClick={onCancel} > - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> <div className="px-6 py-3"> <div className="mb-5 flex"> diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx index da75e03b5c..7a597c911b 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx @@ -50,9 +50,14 @@ const UpdateDSLModal = ({ <div className="mb-3 flex items-center justify-between"> <div className="title-2xl-semi-bold text-text-primary">{t('common.importDSL', { ns: 'workflow' })}</div> - <div className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center" onClick={onCancel}> - <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> - </div> + <button + type="button" + className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onCancel} + > + <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" /> + </button> </div> <div className="relative mb-2 flex grow gap-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs"> <div className="absolute top-0 left-0 h-full w-full bg-toast-warning-bg opacity-40" /> diff --git a/web/app/components/share/text-generation/__tests__/text-generation-result-panel.spec.tsx b/web/app/components/share/text-generation/__tests__/text-generation-result-panel.spec.tsx index e0d361eefb..b54534eb3c 100644 --- a/web/app/components/share/text-generation/__tests__/text-generation-result-panel.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/text-generation-result-panel.spec.tsx @@ -151,10 +151,10 @@ describe('TextGenerationResultPanel', () => { values: baseProps.exportRes, })) expect(screen.getByText('share.generation.batchFailed.info:{"num":1}'))!.toBeInTheDocument() - expect(screen.getByText('share.generation.batchFailed.retry'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'share.generation.batchFailed.retry' }))!.toBeInTheDocument() expect(screen.getByRole('status', { name: 'appApi.loading' }))!.toBeInTheDocument() - fireEvent.click(screen.getByText('share.generation.batchFailed.retry')) + fireEvent.click(screen.getByRole('button', { name: 'share.generation.batchFailed.retry' })) expect(handleRetryAllFailedTask).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/share/text-generation/info-modal.tsx b/web/app/components/share/text-generation/info-modal.tsx index f3b2bef5ad..b851610661 100644 --- a/web/app/components/share/text-generation/info-modal.tsx +++ b/web/app/components/share/text-generation/info-modal.tsx @@ -25,7 +25,7 @@ const InfoModal = ({ }} > <DialogContent className="w-full max-w-[400px] min-w-[400px] overflow-hidden! border-none p-0! text-left align-middle"> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <div className={cn('flex flex-col items-center gap-4 px-4 pt-10 pb-8')}> <AppIcon diff --git a/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx b/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx index 628bf9bd85..40a2bdbaaf 100644 --- a/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx @@ -219,7 +219,7 @@ describe('RunOnce', () => { await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) - fireEvent.click(screen.getByTestId('run-button')) + fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' })) expect(onSend).toHaveBeenCalledTimes(1) }) @@ -233,7 +233,7 @@ describe('RunOnce', () => { await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) - const stopButton = screen.getByTestId('stop-button') + const stopButton = screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }) fireEvent.click(stopButton) expect(onStop).toHaveBeenCalledTimes(1) }) @@ -247,7 +247,7 @@ describe('RunOnce', () => { await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) - const stopButton = screen.getByTestId('stop-button') + const stopButton = screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }) expect(stopButton)!.toBeDisabled() }) diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 96b223e61b..285ca803a8 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -251,7 +251,6 @@ const RunOnce: FC<IRunOnceProps> = ({ variant={isRunning ? 'secondary' : 'primary'} disabled={isRunning && runControl?.isStopping} onClick={handlePrimaryClick} - data-testid={isRunning ? 'stop-button' : 'run-button'} > {isRunning ? ( diff --git a/web/app/components/share/text-generation/text-generation-result-panel.tsx b/web/app/components/share/text-generation/text-generation-result-panel.tsx index bcd38e563a..67681a123a 100644 --- a/web/app/components/share/text-generation/text-generation-result-panel.tsx +++ b/web/app/components/share/text-generation/text-generation-result-panel.tsx @@ -184,7 +184,13 @@ const TextGenerationResultPanel: FC<TextGenerationResultPanelProps> = ({ <span aria-hidden className="i-ri-error-warning-fill h-4 w-4 text-text-destructive" /> <div className="system-sm-medium text-text-secondary">{t('generation.batchFailed.info', { ns: 'share', num: allFailedTaskList.length })}</div> <div className="h-3.5 w-px bg-divider-regular"></div> - <div onClick={handleRetryAllFailedTask} className="cursor-pointer system-sm-semibold-uppercase text-text-accent">{t('generation.batchFailed.retry', { ns: 'share' })}</div> + <button + type="button" + className="inline cursor-pointer border-none bg-transparent p-0 text-left system-sm-semibold-uppercase text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleRetryAllFailedTask} + > + {t('generation.batchFailed.retry', { ns: 'share' })} + </button> </div> )} </div> diff --git a/web/app/components/signin/__tests__/countdown.spec.tsx b/web/app/components/signin/__tests__/countdown.spec.tsx index 7d5e847b72..4ac0b437bb 100644 --- a/web/app/components/signin/__tests__/countdown.spec.tsx +++ b/web/app/components/signin/__tests__/countdown.spec.tsx @@ -31,7 +31,7 @@ describe('Countdown', () => { localStorage.setItem(COUNT_DOWN_KEY, '0') render(<Countdown />) - expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'login.checkCode.resend' })).toBeInTheDocument() expect(screen.queryByText('s')).not.toBeInTheDocument() }) @@ -39,7 +39,7 @@ describe('Countdown', () => { localStorage.setItem(COUNT_DOWN_KEY, '1000') render(<Countdown />) - expect(screen.queryByText('login.checkCode.resend')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'login.checkCode.resend' })).not.toBeInTheDocument() }) }) @@ -79,7 +79,7 @@ describe('Countdown', () => { render(<Countdown onResend={onResend} />) - const resendLink = screen.getByText('login.checkCode.resend') + const resendLink = screen.getByRole('button', { name: 'login.checkCode.resend' }) fireEvent.click(resendLink) expect(onResend).toHaveBeenCalledTimes(1) @@ -90,7 +90,7 @@ describe('Countdown', () => { render(<Countdown />) - const resendLink = screen.getByText('login.checkCode.resend') + const resendLink = screen.getByRole('button', { name: 'login.checkCode.resend' }) fireEvent.click(resendLink) expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, String(COUNT_DOWN_TIME_MS)) @@ -101,7 +101,7 @@ describe('Countdown', () => { render(<Countdown />) - const resendLink = screen.getByText('login.checkCode.resend') + const resendLink = screen.getByRole('button', { name: 'login.checkCode.resend' }) expect(() => fireEvent.click(resendLink)).not.toThrow() }) }) @@ -127,14 +127,14 @@ describe('Countdown', () => { localStorage.setItem(COUNT_DOWN_KEY, '0') render(<Countdown />) - expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'login.checkCode.resend' })).toBeInTheDocument() }) it('should handle negative time values', () => { localStorage.setItem(COUNT_DOWN_KEY, '-1000') render(<Countdown />) - expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'login.checkCode.resend' })).toBeInTheDocument() }) it('should round time display correctly', () => { @@ -160,7 +160,7 @@ describe('Countdown', () => { render(<Countdown onResend={onResend} />) - expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'login.checkCode.resend' })).toBeInTheDocument() }) it('should render correctly without any props', () => { diff --git a/web/app/components/signin/countdown.tsx b/web/app/components/signin/countdown.tsx index 651a2fb430..627a1eea36 100644 --- a/web/app/components/signin/countdown.tsx +++ b/web/app/components/signin/countdown.tsx @@ -41,7 +41,15 @@ export default function Countdown({ onResend }: CountdownProps) { </span> )} { - time <= 0 && <span className="cursor-pointer system-xs-medium text-text-accent-secondary" onClick={resend}>{t('checkCode.resend', { ns: 'login' })}</span> + time <= 0 && ( + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-accent-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={resend} + > + {t('checkCode.resend', { ns: 'login' })} + </button> + ) } </p> ) diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx index 6661c26083..30c2854fa3 100644 --- a/web/app/components/tools/__tests__/provider-list.spec.tsx +++ b/web/app/components/tools/__tests__/provider-list.spec.tsx @@ -306,7 +306,7 @@ describe('ProviderList', () => { const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'Google' } }) expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument() - fireEvent.click(screen.getByTestId('input-clear')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument() }) diff --git a/web/app/components/tools/labels/filter.tsx b/web/app/components/tools/labels/filter.tsx index 00fa47ace4..029d9889e5 100644 --- a/web/app/components/tools/labels/filter.tsx +++ b/web/app/components/tools/labels/filter.tsx @@ -77,11 +77,10 @@ const LabelFilter: FC<LabelFilterProps> = ({ <button type="button" aria-label={t('operation.clear', { ns: 'common' })} - className="group/clear absolute top-1/2 right-2 -translate-y-1/2 p-px" - data-testid="label-filter-clear-button" + className="group/clear absolute top-1/2 right-2 -translate-y-1/2 border-none bg-transparent p-px" onClick={() => onChange([])} > - <XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" /> + <XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" aria-hidden="true" /> </button> )} <PopoverContent @@ -104,11 +103,11 @@ const LabelFilter: FC<LabelFilterProps> = ({ <button key={label.name} type="button" - className="flex w-full items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 text-left select-none hover:bg-state-base-hover" + className="flex w-full items-center gap-2 rounded-lg border-none bg-transparent py-[6px] pr-2 pl-3 text-left select-none hover:bg-state-base-hover" onClick={() => selectLabel(label)} > <div title={label.label} className="grow truncate text-sm leading-5 text-text-secondary">{label.label}</div> - {value.includes(label.name) && <Check className="h-4 w-4 shrink-0 text-text-accent" />} + {value.includes(label.name) && <Check className="h-4 w-4 shrink-0 text-text-accent" aria-hidden="true" />} </button> ))} {!filteredLabelList.length && ( diff --git a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx index c496dd9adf..a3bf645ba7 100644 --- a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx @@ -113,8 +113,7 @@ describe('MCPServerModal', () => { it('should render close icon', () => { render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() }) - const closeButton = document.querySelector('.cursor-pointer svg') - expect(closeButton)!.toBeInTheDocument() + expect(screen.getByRole('button', { name: /operation\.close/ }))!.toBeInTheDocument() }) }) @@ -175,11 +174,8 @@ describe('MCPServerModal', () => { const onHide = vi.fn() render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() }) - const closeButton = document.querySelector('.cursor-pointer') - if (closeButton) { - fireEvent.click(closeButton) - expect(onHide).toHaveBeenCalled() - } + fireEvent.click(screen.getByRole('button', { name: /operation\.close/ })) + expect(onHide).toHaveBeenCalled() }) it('should call onHide when the dialog requests close', () => { diff --git a/web/app/components/tools/mcp/__tests__/modal.spec.tsx b/web/app/components/tools/mcp/__tests__/modal.spec.tsx index a57f88ecad..435d06b75f 100644 --- a/web/app/components/tools/mcp/__tests__/modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/modal.spec.tsx @@ -227,16 +227,8 @@ describe('MCPModal', () => { const onHide = vi.fn() render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() }) - // Find the close button by its parent div with cursor-pointer class - const closeButtons = document.querySelectorAll('.cursor-pointer') - const closeButton = Array.from(closeButtons).find(el => - el.querySelector('svg'), - ) - - if (closeButton) { - fireEvent.click(closeButton) - expect(onHide).toHaveBeenCalled() - } + fireEvent.click(screen.getByRole('button', { name: /operation\.close/ })) + expect(onHide).toHaveBeenCalled() }) it('should have confirm button disabled when form is empty', () => { diff --git a/web/app/components/tools/mcp/mcp-server-modal.tsx b/web/app/components/tools/mcp/mcp-server-modal.tsx index cfb33f3839..e575988067 100644 --- a/web/app/components/tools/mcp/mcp-server-modal.tsx +++ b/web/app/components/tools/mcp/mcp-server-modal.tsx @@ -135,9 +135,14 @@ const MCPServerModal = ({ }} > <DialogContent className="w-[calc(100vw-2rem)] max-w-[520px]! overflow-hidden! border-none p-0! text-left align-middle transition-all duration-100 ease-in"> - <div className="absolute top-5 right-5 z-10 cursor-pointer p-1.5" onClick={onHide}> - <RiCloseLine className="h-5 w-5 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-5 right-5 z-10 cursor-pointer border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onHide} + > + <RiCloseLine className="h-5 w-5 text-text-tertiary" aria-hidden="true" /> + </button> <div className="relative p-6 pb-3 title-2xl-semi-bold text-xl text-text-primary"> {!data ? t('mcp.server.modal.addTitle', { ns: 'tools' }) : t('mcp.server.modal.editTitle', { ns: 'tools' })} </div> diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index 79179ae3dc..8df2d1f635 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -129,9 +129,14 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ return ( <> - <div className="absolute top-5 right-5 z-10 cursor-pointer p-1.5" onClick={onHide}> - <RiCloseLine className="h-5 w-5 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-5 right-5 z-10 cursor-pointer border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onHide} + > + <RiCloseLine className="h-5 w-5 text-text-tertiary" aria-hidden="true" /> + </button> <div className="relative pb-3 title-2xl-semi-bold text-xl text-text-primary"> {!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })} </div> diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx index d8babd2955..9db9852e0b 100644 --- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx @@ -968,7 +968,7 @@ describe('WorkflowToolDrawer', () => { // Act render(<WorkflowToolDrawer {...props} />) - await user.click(screen.getByTestId('drawer-close')) + await user.click(screen.getByRole('button', { name: /Close|operation.close/ })) // Assert expect(onHide).toHaveBeenCalledTimes(1) diff --git a/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx index 6535564a32..e81d1789dd 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx @@ -110,10 +110,7 @@ describe('ConfirmModal', () => { const onClose = vi.fn() renderComponent({ onClose }) - // Act - Find the close button and click it - const closeButton = document.querySelector('.cursor-pointer') - expect(closeButton).toBeInTheDocument() // Ensure the button is found before clicking - await user.click(closeButton!) + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) // Assert expect(onClose).toHaveBeenCalledTimes(1) @@ -243,10 +240,9 @@ describe('ConfirmModal', () => { renderComponent() // Assert - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) - expect(buttons[0]).toHaveTextContent('common.operation.cancel') - expect(buttons[1]).toHaveTextContent('common.operation.confirm') + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument() }) it('should have proper text hierarchy', () => { diff --git a/web/app/components/tools/workflow-tool/confirm-modal/index.tsx b/web/app/components/tools/workflow-tool/confirm-modal/index.tsx index 4f17862c1a..08d3253535 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/index.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/index.tsx @@ -21,9 +21,14 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => { backdropProps={{ forceRender: true }} className={cn('w-[600px]! max-w-[600px]! p-8!')} > - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}> - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2" + onClick={onClose} + > + <span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" /> + </button> <div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl"> <AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" /> </div> diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index c582980a49..dfa64b1506 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -113,7 +113,6 @@ const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: Workfl {title} </DrawerTitle> <DrawerCloseButton - data-testid="drawer-close" className="h-6 w-6 rounded-md" aria-label={closeLabel} /> diff --git a/web/app/components/tools/workflow-tool/method-selector.tsx b/web/app/components/tools/workflow-tool/method-selector.tsx index 07c630dacf..2122eb9fae 100644 --- a/web/app/components/tools/workflow-tool/method-selector.tsx +++ b/web/app/components/tools/workflow-tool/method-selector.tsx @@ -54,24 +54,32 @@ const MethodSelector: FC<MethodSelectorProps> = ({ > <div className="relative w-[320px]"> <div className="p-1"> - <div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => handleSelect('llm')}> + <button + type="button" + className="block w-full cursor-pointer rounded-lg border-none bg-transparent py-2.5 pr-2 pl-3 text-left hover:bg-components-panel-on-panel-item-bg-hover" + onClick={() => handleSelect('llm')} + > <div className="flex items-center gap-1"> <div className="h-4 w-4 shrink-0"> - {value === 'llm' && <Check className="h-4 w-4 shrink-0 text-text-accent" />} + {value === 'llm' && <Check className="h-4 w-4 shrink-0 text-text-accent" aria-hidden />} </div> <div className="text-[13px] leading-[18px] font-medium text-text-secondary">{t('createTool.toolInput.methodParameter', { ns: 'tools' })}</div> </div> <div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t('createTool.toolInput.methodParameterTip', { ns: 'tools' })}</div> - </div> - <div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => handleSelect('form')}> + </button> + <button + type="button" + className="block w-full cursor-pointer rounded-lg border-none bg-transparent py-2.5 pr-2 pl-3 text-left hover:bg-components-panel-on-panel-item-bg-hover" + onClick={() => handleSelect('form')} + > <div className="flex items-center gap-1"> <div className="h-4 w-4 shrink-0"> - {value === 'form' && <Check className="h-4 w-4 shrink-0 text-text-accent" />} + {value === 'form' && <Check className="h-4 w-4 shrink-0 text-text-accent" aria-hidden />} </div> <div className="text-[13px] leading-[18px] font-medium text-text-secondary">{t('createTool.toolInput.methodSetting', { ns: 'tools' })}</div> </div> <div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t('createTool.toolInput.methodSettingTip', { ns: 'tools' })}</div> - </div> + </button> </div> </div> </PopoverContent> diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 1106cfcb75..1d1f375412 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -120,24 +120,24 @@ describe('SelectionContextmenu', () => { }) await waitFor(() => { - expect(screen.getByTestId('selection-contextmenu-item-copy')).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() }) - fireEvent.click(screen.getByTestId('selection-contextmenu-item-copy')) + fireEvent.click(screen.getByRole('menuitem', { name: /common.copy/ })) expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1) expect(store.getState().selectionMenu).toBeUndefined() act(() => { store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) }) - fireEvent.click(screen.getByTestId('selection-contextmenu-item-duplicate')) + fireEvent.click(screen.getByRole('menuitem', { name: /common.duplicate/ })) expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1) expect(store.getState().selectionMenu).toBeUndefined() act(() => { store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) }) - fireEvent.click(screen.getByTestId('selection-contextmenu-item-delete')) + fireEvent.click(screen.getByRole('menuitem', { name: /operation.delete/ })) expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1) expect(store.getState().selectionMenu).toBeUndefined() }) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx index 684c700648..f461c70927 100644 --- a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx +++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx @@ -135,6 +135,15 @@ describe('UpdateDSLModal', () => { expect(onCancel).toHaveBeenCalledTimes(1) }) + it('should call cancel handler when the close button is clicked', () => { + const onCancel = vi.fn() + renderModal({ ...defaultProps, onCancel }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + it('should import a valid file and emit workflow update payload', async () => { renderModal() diff --git a/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx b/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx index cdf2e2afd4..f956b3d99f 100644 --- a/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx @@ -89,7 +89,7 @@ describe('IndexBar', () => { />, ) - await user.click(screen.getByText('A')) + await user.click(screen.getByRole('button', { name: 'A' })) expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }) }) diff --git a/web/app/components/workflow/block-selector/index-bar.tsx b/web/app/components/workflow/block-selector/index-bar.tsx index 7565c087c3..d983519539 100644 --- a/web/app/components/workflow/block-selector/index-bar.tsx +++ b/web/app/components/workflow/block-selector/index-bar.tsx @@ -89,9 +89,14 @@ const IndexBar: FC<IndexBarProps> = ({ letters, itemRefs, className }) => { <div className={cn('index-bar sticky top-[20px] flex h-full w-6 flex-col items-center justify-center text-xs font-medium text-text-quaternary', className)}> <div className={cn('absolute top-0 left-0 h-full w-px bg-[linear-gradient(270deg,rgba(255,255,255,0)_0%,rgba(16,24,40,0.08)_30%,rgba(16,24,40,0.08)_50%,rgba(16,24,40,0.08)_70.5%,rgba(255,255,255,0)_100%)]')}></div> {letters.map(letter => ( - <div className="cursor-pointer hover:text-text-secondary" key={letter} onClick={() => handleIndexClick(letter)}> + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left hover:text-text-secondary" + key={letter} + onClick={() => handleIndexClick(letter)} + > {letter} - </div> + </button> ))} </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form.tsx index 55732ff1cb..e073011321 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form.tsx @@ -5,6 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { produce } from 'immer' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { RETRIEVAL_OUTPUT_STRUCT } from '@/app/components/workflow/constants' import { InputVarType } from '@/app/components/workflow/types' import FormItem from './form-item' @@ -24,6 +25,7 @@ const Form: FC<Props> = ({ values, onChange, }) => { + const { t } = useTranslation() const mapKeysWithSameValueSelector = useMemo(() => { const keysWithSameValueSelector = (key: string) => { const targetValueSelector = inputs.find( @@ -80,9 +82,14 @@ const Form: FC<Props> = ({ <div className="mb-1 flex items-center justify-between"> <div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">{label}</div> {isArrayLikeType && !isIteratorItemFile && ( - <div className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={handleAddContext} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={`${t('operation.add', { ns: 'common' })} ${label}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddContext} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> )} </div> )} diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx index 2844f683c0..6ed63f361e 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx @@ -157,7 +157,7 @@ describe('VarReferencePicker branches', () => { value: ['node-a', 'answer'], }) - fireEvent.click(screen.getByTestId('var-reference-picker-clear')) + fireEvent.click(screen.getByRole('button', { name: /Clear|operation.clear/ })) expect(onChange).toHaveBeenCalledWith([], VarKindType.variable) }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx index b915201ca4..d01f68784d 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx @@ -115,7 +115,7 @@ describe('VarReferencePicker', () => { expect(screen.getByText('Source Node')).toBeInTheDocument() expect(screen.getByText('answer')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('var-reference-picker-clear')) + fireEvent.click(screen.getByRole('button', { name: /Clear|operation.clear/ })) expect(onChange).toHaveBeenCalledWith('', 'constant') }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx index a01d2a0387..422cf9cf5a 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx @@ -96,7 +96,7 @@ describe('VarReferencePickerTrigger', () => { fireEvent.click(screen.getByText('Source Node'), { ctrlKey: true }) expect(handleVariableJump).toHaveBeenCalledWith('node-a') - fireEvent.click(screen.getByTestId('var-reference-picker-clear')) + fireEvent.click(screen.getByRole('button', { name: /Clear|operation.clear/ })) expect(handleClearVar).toHaveBeenCalledTimes(1) }) @@ -131,7 +131,7 @@ describe('VarReferencePickerTrigger', () => { varName: 'answer', }) - expect(screen.getByTestId('add-button'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.add' }))!.toBeInTheDocument() }) it('should stay inert in readonly mode and show value type placeholder badge', () => { diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx index 3c9c517e55..7d87d69ac5 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx @@ -11,6 +11,7 @@ import { PopoverTrigger } from '@langgenius/dify-ui/popover' import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiArrowDownSLine, RiCloseLine, RiErrorWarningFill, RiLoader4Line, RiMoreLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { Line3 } from '@/app/components/base/icons/src/public/common' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' @@ -114,6 +115,7 @@ const VarReferencePickerTrigger: FC<Props> = ({ varName, variableCategory, }) => { + const { t } = useTranslation() const handleTriggerReadonlyClick = (e: React.MouseEvent<HTMLElement>) => { if (!readonly) return @@ -252,9 +254,14 @@ const VarReferencePickerTrigger: FC<Props> = ({ {isAddBtnTrigger ? ( <div> - <div className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={() => {}} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.add', { ns: 'common' })} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => {}} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> ) : ( @@ -303,13 +310,14 @@ const VarReferencePickerTrigger: FC<Props> = ({ ) : resolvedVariablePicker} {(hasValue && !readonly && !isInTable && !isJustShowValue) && ( - <div - className="group invisible absolute top-[50%] right-1 h-5 translate-y-[-50%] cursor-pointer rounded-md p-1 group-hover/wrap:visible hover:bg-state-base-hover" + <button + type="button" + aria-label={t('operation.clear', { ns: 'common' })} + className="group invisible absolute top-[50%] right-1 h-5 translate-y-[-50%] cursor-pointer rounded-md border-none bg-transparent p-1 group-hover/wrap:visible hover:bg-state-base-hover" onClick={handleClearVar} - data-testid="var-reference-picker-clear" > - <RiCloseLine className="h-3.5 w-3.5 text-text-tertiary group-hover:text-text-secondary" /> - </div> + <RiCloseLine className="h-3.5 w-3.5 text-text-tertiary group-hover:text-text-secondary" aria-hidden="true" /> + </button> )} {!hasValue && valueTypePlaceHolder && ( <Badge diff --git a/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx index 72d640651d..ed3342ab57 100644 --- a/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx @@ -226,16 +226,15 @@ describe('code/panel', () => { expect(screen.getByText('editor:editable')).toBeInTheDocument() expect(screen.getByText('language:javascript')).toBeInTheDocument() - const addButtons = screen.getAllByTestId('add-button') - await user.click(addButtons[0]!) - await user.click(screen.getByTestId('sync-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.inputVars' })) + await user.click(screen.getByRole('button', { name: 'workflow.nodes.code.syncFunctionSignature' })) await user.click(screen.getByRole('button', { name: 'change-code' })) await user.click(screen.getByRole('button', { name: 'generate-code' })) await user.click(screen.getByRole('button', { name: 'language:javascript' })) await user.click(screen.getByRole('button', { name: 'change-var-list' })) await user.click(screen.getByRole('button', { name: 'change-output-list' })) await user.click(screen.getByRole('button', { name: 'remove-output' })) - await user.click(addButtons[1]!) + await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.outputVars' })) await user.click(screen.getByRole('button', { name: 'cancel-remove' })) await user.click(screen.getByRole('button', { name: 'confirm-remove' })) @@ -285,8 +284,9 @@ describe('code/panel', () => { renderPanel() - expect(screen.queryByTestId('sync-button')).not.toBeInTheDocument() - expect(screen.getAllByTestId('add-button')).toHaveLength(1) + expect(screen.queryByRole('button', { name: 'workflow.nodes.code.syncFunctionSignature' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.code.inputVars' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.outputVars' })).toBeInTheDocument() expect(screen.getByText('editor:readonly')).toBeInTheDocument() expect(screen.getByText('var-list:readonly')).toBeInTheDocument() expect(screen.getByText('output-list:readonly')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/code/panel.tsx b/web/app/components/workflow/nodes/code/panel.tsx index b531dcf49c..225585eb26 100644 --- a/web/app/components/workflow/nodes/code/panel.tsx +++ b/web/app/components/workflow/nodes/code/panel.tsx @@ -75,17 +75,22 @@ const Panel: FC<NodePanelProps<CodeNodeType>> = ({ <div className="flex gap-2"> <Tooltip> <TooltipTrigger - className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={handleSyncFunctionSignature} - data-testid="sync-button" + aria-label={t(`${i18nPrefix}.syncFunctionSignature`, { ns: 'workflow' })} > - <span className="i-ri-refresh-line h-4 w-4 text-text-tertiary" /> + <span className="i-ri-refresh-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> </TooltipTrigger> <TooltipContent>{t(`${i18nPrefix}.syncFunctionSignature`, { ns: 'workflow' })}</TooltipContent> </Tooltip> - <div className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={handleAddVariable} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={`${t('operation.add', { ns: 'common' })} ${t(`${i18nPrefix}.inputVars`, { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddVariable} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> ) : undefined @@ -124,9 +129,14 @@ const Panel: FC<NodePanelProps<CodeNodeType>> = ({ <Field title={t(`${i18nPrefix}.outputVars`, { ns: 'workflow' })} operations={( - <div className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={handleAddOutputVariable} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={`${t('operation.add', { ns: 'common' })} ${t(`${i18nPrefix}.outputVars`, { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddOutputVariable} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> )} required > diff --git a/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx index b4218e338b..4340d341ff 100644 --- a/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx @@ -38,7 +38,7 @@ describe('EndPanel', () => { expect(screen.getByText('workflow.nodes.end.output.variable')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('add-button')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.end.output.variable' })) expect(handleAddVariable).toHaveBeenCalledTimes(1) }) @@ -53,6 +53,6 @@ describe('EndPanel', () => { render(<Panel id="end-node" data={createData()} panelProps={{} as PanelProps} />) - expect(screen.queryByTestId('add-button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.end.output.variable' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/end/panel.tsx b/web/app/components/workflow/nodes/end/panel.tsx index a88777c82d..a428a926eb 100644 --- a/web/app/components/workflow/nodes/end/panel.tsx +++ b/web/app/components/workflow/nodes/end/panel.tsx @@ -33,9 +33,14 @@ const Panel: FC<NodePanelProps<EndNodeType>> = ({ operations={ !readOnly ? ( - <div className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={handleAddVariable} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={`${t('operation.add', { ns: 'common' })} ${t(`${i18nPrefix}.output.variable`, { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddVariable} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> ) : undefined } diff --git a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx index 0f0a6839eb..03247710a7 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx @@ -306,7 +306,7 @@ describe('human-input/panel', () => { const config = createConfigResult() mockUseConfig.mockReturnValue(config) - const { container } = renderPanel() + renderPanel() expect(screen.getByRole('button', { name: 'delivery-method:editable' })).toBeInTheDocument() expect(screen.getByText('form-content:collapsed')).toBeInTheDocument() @@ -329,9 +329,8 @@ describe('human-input/panel', () => { await user.click(screen.getByRole('button', { name: 'toggle-output-vars' })) await user.click(screen.getByRole('button', { name: 'close-preview' })) - const iconContainers = container.querySelectorAll('div.flex.size-6.cursor-pointer') - await user.click(iconContainers[0] as HTMLElement) - await user.click(iconContainers[1] as HTMLElement) + await user.click(screen.getByRole('button', { name: 'common.operation.copy' })) + await user.click(screen.getByRole('button', { name: 'share.chat.expand' })) expect(config.handleDeliveryMethodChange).toHaveBeenCalledWith([{ id: 'dm-email', diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx new file mode 100644 index 0000000000..13ec9b346d --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx @@ -0,0 +1,102 @@ +import type { ReactNode } from 'react' +import type { HumanInputFormData } from '@/types/workflow' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InputVarType } from '@/app/components/workflow/types' +import { UserActionButtonType } from '../../types' +import SingleRunForm from '../single-run-form' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@langgenius/dify-ui/button', () => ({ + Button: ({ + children, + disabled, + onClick, + }: { + children?: ReactNode + disabled?: boolean + onClick?: () => void + }) => ( + <button type="button" disabled={disabled} onClick={onClick}> + {children} + </button> + ), +})) + +vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({ + __esModule: true, + default: ({ content }: { content: string }) => <div>{content}</div>, +})) + +const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({ + form_id: 'form-1', + node_id: 'human-1', + node_title: 'Review', + form_content: 'Please review {{#$output.review#}}', + inputs: [{ + type: InputVarType.paragraph, + output_variable_name: 'review', + default: { + selector: [], + type: 'constant', + value: 'initial review', + }, + }], + actions: [{ + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, + }], + form_token: 'token', + resolved_default_values: {}, + display_in_ui: true, + expiration_time: 0, + ...overrides, +}) + +describe('SingleRunForm', () => { + it('renders the back action as a named button and forwards clicks', async () => { + const user = userEvent.setup() + const handleBack = vi.fn() + + render( + <SingleRunForm + nodeName="Review" + data={createFormData()} + showBackButton + handleBack={handleBack} + />, + ) + + await user.click(screen.getByRole('button', { name: 'nodes.humanInput.singleRun.back' })) + + expect(handleBack).toHaveBeenCalledTimes(1) + }) + + it('submits the selected action with initialized inputs', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render( + <SingleRunForm + nodeName="Review" + data={createFormData()} + onSubmit={onSubmit} + />, + ) + + await user.click(screen.getByRole('button', { name: 'Approve' })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + inputs: { review: 'initial review' }, + action: 'approve', + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx index 7c62b20b8a..638a309c06 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx @@ -263,7 +263,7 @@ describe('human-input/delivery-method/test-email-sender', () => { expect(screen.getByPlaceholderText('message')).toBeInTheDocument() - await user.click(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.vars')) + await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.vars' })) expect(screen.queryByPlaceholderText('message')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index b88ec6cef6..158d199cbc 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -315,7 +315,13 @@ const EmailSenderModal = ({ i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.tip`} ns="workflow" components={{ - strong: <span onClick={jumpToEmailConfigModal} className="cursor-pointer system-xs-regular text-text-accent"></span>, + strong: ( + <button + type="button" + onClick={jumpToEmailConfigModal} + className="inline cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent" + /> + ), }} /> </div> @@ -328,10 +334,15 @@ const EmailSenderModal = ({ <Divider className="mt-4! mb-2! h-px! w-12! bg-divider-regular" /> </div> <div className="py-2"> - <div className="group flex h-6 cursor-pointer items-center" onClick={() => setCollapsed(!collapsed)}> + <button + type="button" + aria-expanded={!collapsed} + className="group flex h-6 cursor-pointer items-center border-none bg-transparent p-0 text-left" + onClick={() => setCollapsed(!collapsed)} + > <div className="mr-1 system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}</div> - <RiArrowRightSFill className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-primary', !collapsed && 'rotate-90')} /> - </div> + <RiArrowRightSFill className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-primary', !collapsed && 'rotate-90')} aria-hidden /> + </button> <div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}</div> {!collapsed && ( <div className="mt-3 space-y-4"> diff --git a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx index 6226b6c06f..507b9b1a39 100644 --- a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx +++ b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx @@ -49,10 +49,14 @@ const FormContent = ({ <> {showBackButton && ( <div className="flex items-center p-4 pb-1"> - <div className="flex cursor-pointer items-center system-sm-semibold-uppercase text-text-accent" onClick={handleBack}> - <RiArrowLeftLine className="mr-1 h-4 w-4" /> + <button + type="button" + className="flex cursor-pointer items-center border-none bg-transparent p-0 text-left system-sm-semibold-uppercase text-text-accent" + onClick={handleBack} + > + <RiArrowLeftLine className="mr-1 h-4 w-4" aria-hidden /> {t('nodes.humanInput.singleRun.back', { ns: 'workflow' })} - </div> + </button> <div className="mx-1 system-xs-regular text-divider-deep">/</div> <div className="system-sm-semibold-uppercase text-text-secondary">{nodeName}</div> </div> diff --git a/web/app/components/workflow/nodes/human-input/panel.tsx b/web/app/components/workflow/nodes/human-input/panel.tsx index 99b5da42eb..2f51af95b6 100644 --- a/web/app/components/workflow/nodes/human-input/panel.tsx +++ b/web/app/components/workflow/nodes/human-input/panel.tsx @@ -128,18 +128,25 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ </Button> <div className="mx-2 h-3 w-px bg-divider-regular"></div> <div className="flex items-center space-x-1"> - <div - className="flex size-6 cursor-pointer items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover" + <button + type="button" + aria-label={t('operation.copy', { ns: 'common' })} + className="flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-components-button-ghost-bg-hover" onClick={() => { copy(inputs.form_content) toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) }} > - <RiClipboardLine className="h-4 w-4 text-text-secondary" /> - </div> - <div className={cn('flex size-6 cursor-pointer items-center justify-center rounded-md text-text-secondary hover:bg-components-button-ghost-bg-hover', isExpandFormContent && 'bg-state-accent-active text-text-accent')} onClick={toggleExpandFormContent}> - {isExpandFormContent ? <RiCollapseDiagonalLine className="h-4 w-4" /> : <RiExpandDiagonalLine className="h-4 w-4" />} - </div> + <RiClipboardLine className="h-4 w-4 text-text-secondary" aria-hidden /> + </button> + <button + type="button" + aria-label={t(isExpandFormContent ? 'chat.collapse' : 'chat.expand', { ns: 'share' })} + className={cn('flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-text-secondary hover:bg-components-button-ghost-bg-hover', isExpandFormContent && 'bg-state-accent-active text-text-accent')} + onClick={toggleExpandFormContent} + > + {isExpandFormContent ? <RiCollapseDiagonalLine className="h-4 w-4" aria-hidden /> : <RiExpandDiagonalLine className="h-4 w-4" aria-hidden />} + </button> </div> </div> )} diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx index ea02cf5980..1c7513d13f 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx @@ -300,7 +300,7 @@ describe('knowledge-retrieval path', () => { />, ) - await user.click(screen.getByTestId('add-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge' })) await user.click(screen.getByText('select-dataset')) expect(onChange).toHaveBeenCalledWith([ @@ -606,7 +606,7 @@ describe('knowledge-retrieval path', () => { await user.click(screen.getAllByRole('button', { name: /contains/i })[0]!) await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is')) - await user.click(screen.getByText(/March 09 2024/).nextElementSibling as Element) + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) fireEvent.change(screen.getByDisplayValue('agent'), { target: { value: 'updated-agent' } }) fireEvent.click(container.querySelector('.ml-1.mt-1') as Element) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx index 85d480a561..df30932137 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx @@ -4,6 +4,7 @@ import type { DataSet } from '@/models/datasets' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' import SelectDataset from '@/app/components/app/configuration/dataset-config/select-dataset' type Props = { @@ -15,6 +16,7 @@ const AddDataset: FC<Props> = ({ selectedIds, onChange, }) => { + const { t } = useTranslation() const [isShowModal, { setTrue: showModal, setFalse: hideModal, @@ -28,9 +30,9 @@ const AddDataset: FC<Props> = ({ <div> <button type="button" - className="cursor-pointer rounded-md p-1 outline-hidden select-none hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" + aria-label={`${t('operation.add', { ns: 'common' })} ${t('nodes.knowledgeRetrieval.knowledge', { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 outline-hidden select-none hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={showModal} - data-testid="add-button" > <span aria-hidden="true" className="i-ri-add-line h-4 w-4 text-text-tertiary" /> </button> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx index 8182a5ab87..c8a0824565 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx @@ -94,7 +94,6 @@ const DatasetItem: FC<Props> = ({ editable && ( <ActionButton aria-label={t('operation.edit', { ns: 'common' })} - data-testid="dataset-item-edit-button" onClick={(e) => { e.stopPropagation() showSettingsModal() @@ -106,7 +105,6 @@ const DatasetItem: FC<Props> = ({ } <ActionButton aria-label={t('operation.remove', { ns: 'common' })} - data-testid="dataset-item-remove-button" onClick={handleRemove} state={isDeleteHovered ? ActionButtonState.Destructive : ActionButtonState.Default} onMouseEnter={() => setIsDeleteHovered(true)} diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx index fe4c086cb4..639ef164e4 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx @@ -31,41 +31,46 @@ const ConditionDate = ({ const renderTrigger = useCallback(({ handleClickTrigger, }: TriggerProps) => { + const hasValue = Boolean(value) + const triggerText = value + ? dayjs(value * 1000).tz(timezone).format('MMMM DD YYYY HH:mm A') + : t('nodes.knowledgeRetrieval.metadata.panel.datePlaceholder', { ns: 'workflow' }) + return ( - <div className="group flex items-center" onClick={handleClickTrigger}> - <div + <div className="group flex items-center"> + <button + type="button" className={cn( - 'mr-0.5 flex h-6 grow cursor-pointer items-center px-1 system-sm-regular', - value ? 'text-text-secondary' : 'text-text-tertiary', + 'mr-0.5 flex h-6 grow cursor-pointer items-center border-none bg-transparent px-1 py-0 text-left system-sm-regular focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + hasValue ? 'text-text-secondary' : 'text-text-tertiary', )} + onClick={handleClickTrigger} > - { - value - ? dayjs(value * 1000).tz(timezone).format('MMMM DD YYYY HH:mm A') - : t('nodes.knowledgeRetrieval.metadata.panel.datePlaceholder', { ns: 'workflow' }) - } - </div> - { - !!value && ( - <RiCloseCircleFill - className={cn( - 'hidden h-4 w-4 shrink-0 cursor-pointer group-hover:block hover:text-components-input-text-filled', - value && 'text-text-quaternary', - )} - onClick={(e) => { - e.stopPropagation() - handleDateChange() - }} - /> - ) - } - <RiCalendarLine - className={cn( - 'block h-4 w-4 shrink-0', - value ? 'text-text-quaternary' : 'text-text-tertiary', - value && 'group-hover:hidden', - )} - /> + <span className="grow">{triggerText}</span> + <RiCalendarLine + className={cn( + 'block h-4 w-4 shrink-0', + hasValue ? 'text-text-quaternary' : 'text-text-tertiary', + hasValue && 'group-hover:hidden', + )} + aria-hidden="true" + /> + </button> + {hasValue + ? ( + <button + type="button" + aria-label={t('operation.clear', { ns: 'common' })} + className="hidden h-4 w-4 shrink-0 cursor-pointer border-none bg-transparent p-0 text-text-quaternary group-hover:block hover:text-components-input-text-filled focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={(e) => { + e.stopPropagation() + handleDateChange() + }} + > + <RiCloseCircleFill className="h-4 w-4" aria-hidden="true" /> + </button> + ) + : null} </div> ) }, [value, handleDateChange, timezone, t]) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx index da8c89f755..d521a0b861 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx @@ -99,9 +99,14 @@ const JsonImporter: FC<JsonImporterProps> = ({ <div className="flex w-[400px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9"> {/* Title */} <div className="relative px-3 pt-3.5 pb-1"> - <div className="absolute right-2.5 bottom-0 flex h-8 w-8 items-center justify-center" onClick={onClose}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute right-2.5 bottom-0 flex h-8 w-8 items-center justify-center border-none bg-transparent p-0" + onClick={onClose} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <div className="flex pr-8 pl-1 system-xl-semibold text-text-primary"> {t('nodes.llm.jsonSchema.import', { ns: 'workflow' })} </div> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx index 2023d093a7..ddb1ac2eb3 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx @@ -68,9 +68,14 @@ const GeneratedResult: FC<GeneratedResultProps> = ({ </div> ) : ( <> - <div className="absolute top-2.5 right-2.5 flex h-8 w-8 items-center justify-center" onClick={onClose}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-2.5 right-2.5 flex h-8 w-8 items-center justify-center border-none bg-transparent p-0" + onClick={onClose} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> {/* Title */} <div className="flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1"> <div className="flex pr-8 pl-1 system-xl-semibold text-text-primary"> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx index 42b57bc40b..76afba2abe 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx @@ -44,9 +44,14 @@ const PromptEditor: FC<PromptEditorProps> = ({ return ( <div className="relative flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9"> - <div className="absolute top-2.5 right-2.5 flex h-8 w-8 items-center justify-center" onClick={onClose}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-2.5 right-2.5 flex h-8 w-8 items-center justify-center border-none bg-transparent p-0" + onClick={onClose} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> {/* Title */} <div className="flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1"> <div className="flex pr-8 pl-1 system-xl-semibold text-text-primary"> diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index 922a18e46e..ba103736d4 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -174,9 +174,14 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ operations={ !readOnly ? ( - <div className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={handleAddEmptyVariable} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={`${t('operation.add', { ns: 'common' })} ${t('nodes.templateTransform.inputVars', { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddEmptyVariable} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> ) : undefined } diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx index 2c5de93964..a818b8601b 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx @@ -604,7 +604,7 @@ describe('parameter-extractor path', () => { />, ) - expect(screen.getByTestId('add-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })).toBeInTheDocument() }) it('should reject invalid names and reset add modal fields after canceling', async () => { @@ -619,7 +619,7 @@ describe('parameter-extractor path', () => { />, ) - await user.click(screen.getByTestId('add-button')) + await user.click(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })) const nameInput = screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder') const descriptionInput = screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder') @@ -635,7 +635,7 @@ describe('parameter-extractor path', () => { expect(onCancel).toHaveBeenCalledTimes(1) expect(screen.queryByTestId('base-modal')).not.toBeInTheDocument() - await user.click(screen.getByTestId('add-button')) + await user.click(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })) expect(screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder')).toHaveValue('') expect(screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder')).toHaveValue('') }) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/update.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/update.spec.tsx index c73b990b68..34d788be50 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/update.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/update.spec.tsx @@ -41,7 +41,7 @@ describe('parameter-extractor/extract-parameter/update', () => { const existingDialogs = screen.queryAllByRole('dialog').length - fireEvent.click(screen.getByTestId('add-button')) + fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })) const dialogs = await waitFor(() => { const nextDialogs = screen.getAllByRole('dialog') expect(nextDialogs.length).toBeGreaterThan(existingDialogs) @@ -88,7 +88,7 @@ describe('parameter-extractor/extract-parameter/update', () => { const existingDialogs = screen.queryAllByRole('dialog').length - await user.click(screen.getByTestId('add-button')) + await user.click(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })) const dialogs = await waitFor(() => { const nextDialogs = screen.getAllByRole('dialog') expect(nextDialogs.length).toBeGreaterThan(existingDialogs) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx index 122ee16941..895f240176 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx @@ -119,9 +119,14 @@ const AddExtractParameter: FC<Props> = ({ return ( <div> {isAdd && ( - <div className="mx-1 cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={showAddModal} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t(`${i18nPrefix}.addExtractParameter`, { ns: 'workflow' })} + className="mx-1 cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={showAddModal} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> )} {isShowModal && ( <Dialog diff --git a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx index 8a7542ab89..0ce9af524f 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx @@ -79,10 +79,10 @@ describe('question-classifier/class-list', () => { await user.click(screen.getAllByText('remove-item')[0]!) expect(handleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('node-1', 'topic-1') - await user.click(screen.getByText('workflow.nodes.questionClassifiers.class')) + await user.click(screen.getByRole('button', { name: /workflow\.nodes\.questionClassifiers\.class/ })) expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument() - await user.click(screen.getByText('workflow.nodes.questionClassifiers.class')) + await user.click(screen.getByRole('button', { name: /workflow\.nodes\.questionClassifiers\.class/ })) await user.click(screen.getByText('workflow.nodes.questionClassifiers.addClass')) expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ name: '' }), diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx index 1a80266de5..cf92439cd5 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx @@ -117,8 +117,12 @@ const ClassList: FC<Props> = ({ return ( <> - <div className="mb-2 flex items-center justify-between" onClick={handleCollapse}> - <div className="flex cursor-pointer items-center text-xs font-semibold text-text-secondary uppercase"> + <div className="mb-2 flex items-center justify-between"> + <button + type="button" + className="flex cursor-pointer items-center border-none bg-transparent p-0 text-left text-xs font-semibold text-text-secondary uppercase focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleCollapse} + > {t(`${i18nPrefix}.class`, { ns: 'workflow' })} {' '} <span className="text-text-destructive">*</span> @@ -128,9 +132,10 @@ const ClassList: FC<Props> = ({ 'h-4 w-4 text-text-quaternary transition-transform duration-200', collapsed && '-rotate-90', )} + aria-hidden="true" /> )} - </div> + </button> </div> {shouldShowRenameHint && ( <div className="mb-2 rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-tertiary"> diff --git a/web/app/components/workflow/nodes/start/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/start/__tests__/panel.spec.tsx index 1d9e1bc7bb..7b7f7a096d 100644 --- a/web/app/components/workflow/nodes/start/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/start/__tests__/panel.spec.tsx @@ -93,7 +93,7 @@ describe('StartPanel', () => { expect(screen.getByText('userinput.files')).toBeInTheDocument() expect(screen.queryByText('LEGACY')).not.toBeInTheDocument() - fireEvent.click(screen.getByTestId('add-button')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.start.inputField' })) expect(showAddVarModal).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/workflow/nodes/start/components/__tests__/var-item.spec.tsx b/web/app/components/workflow/nodes/start/components/__tests__/var-item.spec.tsx new file mode 100644 index 0000000000..720d0f1bc0 --- /dev/null +++ b/web/app/components/workflow/nodes/start/components/__tests__/var-item.spec.tsx @@ -0,0 +1,38 @@ +import type { InputVar } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import VarItem from '../var-item' + +vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({ + __esModule: true, + default: ({ isShow }: { isShow: boolean }) => isShow ? <div role="dialog">edit-variable</div> : null, +})) + +const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({ + label: 'Query', + variable: 'query', + type: InputVarType.textInput, + required: false, + ...overrides, +}) + +describe('StartVarItem', () => { + it('shows named edit and remove actions on hover', () => { + const handleRemove = vi.fn() + const { container } = render( + <VarItem + readonly={false} + payload={createPayload()} + onRemove={handleRemove} + />, + ) + + fireEvent.mouseEnter(container.firstElementChild!) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.edit' })) + expect(screen.getByRole('dialog')).toHaveTextContent('edit-variable') + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) + expect(handleRemove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/nodes/start/components/var-item.tsx b/web/app/components/workflow/nodes/start/components/var-item.tsx index 8cb7f8acf6..ce03fb55f6 100644 --- a/web/app/components/workflow/nodes/start/components/var-item.tsx +++ b/web/app/components/workflow/nodes/start/components/var-item.tsx @@ -86,12 +86,22 @@ const VarItem: FC<Props> = ({ ) : (!readonly && ( <> - <div onClick={showEditVarModal} className="mr-1 cursor-pointer rounded-md p-1 hover:bg-state-base-hover"> - <Edit03 className="h-4 w-4 text-text-tertiary" /> - </div> - <div onClick={onRemove} className="group cursor-pointer rounded-md p-1 hover:bg-state-destructive-hover"> - <RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover:text-text-destructive" /> - </div> + <button + type="button" + aria-label={t('operation.edit', { ns: 'common' })} + className="mr-1 cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={showEditVarModal} + > + <Edit03 className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> + <button + type="button" + aria-label={t('operation.remove', { ns: 'common' })} + className="group cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-destructive-hover focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" + onClick={onRemove} + > + <RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover:text-text-destructive" aria-hidden="true" /> + </button> </> ))} </> diff --git a/web/app/components/workflow/nodes/start/panel.tsx b/web/app/components/workflow/nodes/start/panel.tsx index 5fc8f224fe..c723ee8931 100644 --- a/web/app/components/workflow/nodes/start/panel.tsx +++ b/web/app/components/workflow/nodes/start/panel.tsx @@ -47,9 +47,14 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ operations={ !readOnly ? ( - <div className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={showAddVarModal} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={`${t('operation.add', { ns: 'common' })} ${t(`${i18nPrefix}.inputField`, { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={showAddVarModal} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> ) : undefined } diff --git a/web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx index 6e5b00ee20..c956caf305 100644 --- a/web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx @@ -178,7 +178,7 @@ describe('template-transform path', () => { />, ) - await user.click(screen.getByTestId('add-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.templateTransform.inputVars' })) await user.click(screen.getByRole('button', { name: 'change-var-list' })) await user.click(screen.getByRole('button', { name: 'rename-var' })) await user.click(screen.getByRole('button', { name: 'add-var' })) @@ -219,6 +219,6 @@ describe('template-transform path', () => { />, ) - expect(screen.queryByTestId('add-button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.templateTransform.inputVars' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/template-transform/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/template-transform/__tests__/panel.spec.tsx index ed0db7647b..3a51948082 100644 --- a/web/app/components/workflow/nodes/template-transform/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/template-transform/__tests__/panel.spec.tsx @@ -144,7 +144,7 @@ describe('template-transform/panel', () => { />, ) - await user.click(screen.getByTestId('add-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.templateTransform.inputVars' })) await user.click(screen.getByRole('button', { name: 'change-var-list' })) await user.click(screen.getByRole('button', { name: 'rename-var' })) await user.click(screen.getByRole('button', { name: 'add-var' })) @@ -185,6 +185,6 @@ describe('template-transform/panel', () => { />, ) - expect(screen.queryByTestId('add-button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.templateTransform.inputVars' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/template-transform/panel.tsx b/web/app/components/workflow/nodes/template-transform/panel.tsx index 4a6515da7a..4993d232d1 100644 --- a/web/app/components/workflow/nodes/template-transform/panel.tsx +++ b/web/app/components/workflow/nodes/template-transform/panel.tsx @@ -43,9 +43,14 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({ operations={ !readOnly ? ( - <div className="cursor-pointer rounded-md p-1 select-none hover:bg-state-base-hover" onClick={handleAddEmptyVariable} data-testid="add-button"> - <span className="i-ri-add-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={`${t('operation.add', { ns: 'common' })} ${t(`${i18nPrefix}.inputVars`, { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddEmptyVariable} + > + <span className="i-ri-add-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> ) : undefined } diff --git a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx index 9b5f89ebd0..63ee0ceb37 100644 --- a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx @@ -153,7 +153,7 @@ describe('WorkflowPreview', () => { it('should keep the input tab active, switch to result after running, and close the preview panel', async () => { const user = userEvent.setup() - const { container } = renderWorkflowComponent( + renderWorkflowComponent( <WorkflowPreview />, { initialStoreState: { @@ -169,7 +169,7 @@ describe('WorkflowPreview', () => { await user.click(screen.getByRole('button', { name: 'run-inputs' })) expect(screen.getByTestId('result-text')).toBeInTheDocument() - await user.click(container.querySelector('.flex.items-center.justify-between .cursor-pointer.p-1') as HTMLElement) + await user.click(screen.getByRole('button', { name: /operation\.close/ })) expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/conversation-variable-modal.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/__tests__/conversation-variable-modal.spec.tsx index e36c17ca96..fa050b9bc5 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/conversation-variable-modal.spec.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/conversation-variable-modal.spec.tsx @@ -118,8 +118,7 @@ describe('ConversationVariableModal', () => { await user.click(screen.getByText('summary')) expect(screen.getByText('latest text')).toBeInTheDocument() - const closeTrigger = document.querySelector('.absolute.right-4.top-4.cursor-pointer') as HTMLElement - await user.click(closeTrigger) + await user.click(screen.getByRole('button', { name: /operation\.close/ })) expect(onHide).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx index 689b2fe0b3..b7f720b370 100644 --- a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx @@ -78,19 +78,29 @@ const ConversationVariableModal = ({ <Dialog open> <DialogContent className={cn('max-h-none w-full overflow-hidden! border-none text-left align-middle', cn('h-[640px] w-[920px] max-w-[920px] p-0'))}> - <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onHide} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> <div className="flex h-full w-full"> {/* LEFT */} <div className="flex h-full w-[224px] shrink-0 flex-col border-r border-divider-burn bg-background-sidenav-bg"> <div className="shrink-0 pt-5 pr-4 pb-3 pl-5 system-xl-semibold text-text-primary">{t('chatVariable.panelTitle', { ns: 'workflow' })}</div> <div className="grow overflow-y-auto px-3 py-2"> {varList.map(chatVar => ( - <div key={chatVar.id} className={cn('group mb-0.5 flex cursor-pointer items-center rounded-lg p-2 hover:bg-state-base-hover', currentVar.id === chatVar.id && 'bg-state-base-hover')} onClick={() => setCurrentVar(chatVar)}> - <BubbleX className={cn('mr-1 h-4 w-4 shrink-0 text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')} /> + <button + key={chatVar.id} + type="button" + className={cn('group mb-0.5 flex w-full cursor-pointer items-center rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', currentVar.id === chatVar.id && 'bg-state-base-hover')} + onClick={() => setCurrentVar(chatVar)} + > + <BubbleX className={cn('mr-1 h-4 w-4 shrink-0 text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')} aria-hidden="true" /> <div title={chatVar.name} className={cn('truncate system-sm-medium text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')}>{chatVar.name}</div> - </div> + </button> ))} </div> </div> diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx index 89ae09c374..7bf858fdd3 100644 --- a/web/app/components/workflow/panel/workflow-preview.tsx +++ b/web/app/components/workflow/panel/workflow-preview.tsx @@ -116,9 +116,14 @@ const WorkflowPreview = () => { /> <div className="flex items-center justify-between p-4 pb-1 text-base font-semibold text-text-primary"> {`Test Run${formatWorkflowRunIdentifier(workflowRunningData?.result.finished_at, workflowRunningData?.result.status)}`} - <div className="cursor-pointer p-1" onClick={() => handleCancelDebugAndPreviewPanel()}> - <span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + aria-label={t('operation.close', { ns: 'common' })} + className="cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => handleCancelDebugAndPreviewPanel()} + > + <span className="i-ri-close-line h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> <div className="relative flex grow flex-col"> <div className="flex shrink-0 items-center border-b-[0.5px] border-divider-subtle px-4"> diff --git a/web/app/components/workflow/run/__tests__/loop-result-panel.spec.tsx b/web/app/components/workflow/run/__tests__/loop-result-panel.spec.tsx index fd9ce7a29c..2f7658f984 100644 --- a/web/app/components/workflow/run/__tests__/loop-result-panel.spec.tsx +++ b/web/app/components/workflow/run/__tests__/loop-result-panel.spec.tsx @@ -75,7 +75,7 @@ describe('LoopResultPanel', () => { const contentPanels = container.querySelectorAll('.transition-all.duration-200') expect(contentPanels[0]).toHaveClass('max-h-0') - fireEvent.click(screen.getByText('workflow.singleRun.loop 1')) + fireEvent.click(screen.getByRole('button', { name: 'workflow.singleRun.loop 1' })) expect(contentPanels[0]).not.toHaveClass('max-h-0') expect(screen.getAllByTestId('tracing-panel')[0]).toHaveTextContent('1') expect(mockTracingPanel).toHaveBeenCalledWith({ @@ -83,11 +83,8 @@ describe('LoopResultPanel', () => { className: 'bg-background-section-burn', }) - fireEvent.click(screen.getByText('workflow.singleRun.back')) - const closeTrigger = container.querySelector('.ml-2.shrink-0.cursor-pointer.p-1') - if (!closeTrigger) - throw new Error('Expected close trigger to be rendered') - fireEvent.click(closeTrigger) + fireEvent.click(screen.getByRole('button', { name: 'workflow.singleRun.back' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(onBack).toHaveBeenCalledTimes(1) expect(onHide).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/run/__tests__/result-text.spec.tsx b/web/app/components/workflow/run/__tests__/result-text.spec.tsx index 9b0827c2f0..b9ba43deda 100644 --- a/web/app/components/workflow/run/__tests__/result-text.spec.tsx +++ b/web/app/components/workflow/run/__tests__/result-text.spec.tsx @@ -43,7 +43,7 @@ describe('ResultText', () => { expect(screen.getByText('runLog.resultEmpty.title')).toBeInTheDocument() - fireEvent.click(screen.getByText('runLog.resultEmpty.link')) + fireEvent.click(screen.getByRole('button', { name: 'runLog.resultEmpty.link' })) expect(onClick).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/workflow/run/loop-result-panel.tsx b/web/app/components/workflow/run/loop-result-panel.tsx index bd391266c3..a2867fb310 100644 --- a/web/app/components/workflow/run/loop-result-panel.tsx +++ b/web/app/components/workflow/run/loop-result-panel.tsx @@ -45,24 +45,34 @@ const LoopResultPanel: FC<Props> = ({ <div className="truncate system-xl-semibold text-text-primary"> {t(`${i18nPrefix}.testRunLoop`, { ns: 'workflow' }) } </div> - <div className="ml-2 shrink-0 cursor-pointer p-1" onClick={onHide}> - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </div> + <button + type="button" + className="ml-2 shrink-0 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onHide} + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" /> + </button> </div> - <div className="flex cursor-pointer items-center space-x-1 py-2 text-text-accent-secondary" onClick={onBack}> - <ArrowNarrowLeft className="h-4 w-4" /> + <button + type="button" + className="flex cursor-pointer items-center space-x-1 border-none bg-transparent px-0 py-2 text-left text-text-accent-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onBack} + > + <ArrowNarrowLeft className="h-4 w-4" aria-hidden="true" /> <div className="system-sm-medium">{t(`${i18nPrefix}.back`, { ns: 'workflow' })}</div> - </div> + </button> </div> {/* List */} <div className={cn(!noWrap ? 'grow overflow-auto' : 'max-h-full', 'bg-components-panel-bg p-2')}> {list.map((loop, index) => ( <div key={index} className={cn('mb-1 overflow-hidden rounded-xl border-none bg-background-section-burn')}> - <div + <button + type="button" className={cn( 'flex w-full cursor-pointer items-center justify-between px-3', expandedLoops[index] ? 'pt-3 pb-2' : 'py-3', - 'rounded-xl text-left', + 'rounded-xl border-none bg-transparent text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', )} onClick={() => toggleLoop(index)} > @@ -75,13 +85,15 @@ const LoopResultPanel: FC<Props> = ({ {' '} {index + 1} </span> - <RiArrowRightSLine className={cn( - 'h-4 w-4 shrink-0 text-text-tertiary transition-transform duration-200', - expandedLoops[index] && 'rotate-90', - )} + <RiArrowRightSLine + className={cn( + 'h-4 w-4 shrink-0 text-text-tertiary transition-transform duration-200', + expandedLoops[index] && 'rotate-90', + )} + aria-hidden="true" /> </div> - </div> + </button> {expandedLoops[index] && ( <div className="h-px grow bg-divider-subtle" diff --git a/web/app/components/workflow/run/result-text.tsx b/web/app/components/workflow/run/result-text.tsx index fcbbf8043d..f76c99fe9c 100644 --- a/web/app/components/workflow/run/result-text.tsx +++ b/web/app/components/workflow/run/result-text.tsx @@ -45,7 +45,13 @@ const ResultText: FC<ResultTextProps> = ({ <div className="mr-2">{t('resultEmpty.title', { ns: 'runLog' })}</div> <div> {t('resultEmpty.tipLeft', { ns: 'runLog' })} - <span onClick={onClick} className="cursor-pointer text-primary-600">{t('resultEmpty.link', { ns: 'runLog' })}</span> + <button + type="button" + onClick={onClick} + className="inline cursor-pointer border-none bg-transparent p-0 text-left text-primary-600" + > + {t('resultEmpty.link', { ns: 'runLog' })} + </button> {t('resultEmpty.tipRight', { ns: 'runLog' })} </div> </div> diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 56a1afbba6..a2d7117022 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -383,7 +383,6 @@ const SelectionContextmenu = () => { <ContextMenuGroup> <ContextMenuItem className="justify-between px-3 text-text-secondary" - data-testid="selection-contextmenu-item-copy" onClick={handleCopyNodes} > <span>{t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })}</span> @@ -391,7 +390,6 @@ const SelectionContextmenu = () => { </ContextMenuItem> <ContextMenuItem className="justify-between px-3 text-text-secondary" - data-testid="selection-contextmenu-item-duplicate" onClick={handleDuplicateNodes} > <span>{t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })}</span> @@ -402,7 +400,6 @@ const SelectionContextmenu = () => { <ContextMenuGroup> <ContextMenuItem className="justify-between px-3 text-text-secondary data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive" - data-testid="selection-contextmenu-item-delete" onClick={handleDeleteNodes} > <span>{t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })}</span> diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx index c5cb9cc62d..de5e92a006 100644 --- a/web/app/components/workflow/update-dsl-modal.tsx +++ b/web/app/components/workflow/update-dsl-modal.tsx @@ -210,9 +210,14 @@ const UpdateDSLModal = ({ <div className="mb-3 flex items-center justify-between"> <div className="title-2xl-semi-bold text-text-primary">{t('importApp', { ns: 'app' })}</div> - <div className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center" onClick={onCancel}> - <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" /> - </div> + <button + type="button" + className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + aria-label={t('operation.close', { ns: 'common' })} + onClick={onCancel} + > + <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" /> + </button> </div> <div className="relative mb-2 flex grow gap-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs"> <div className="absolute top-0 left-0 h-full w-full bg-toast-warning-bg opacity-40" /> diff --git a/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx index f87bc3b896..c445670717 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx @@ -69,7 +69,7 @@ describe('VariableInspect Group', () => { />, ) - fireEvent.click(screen.getByText('API_KEY')) + fireEvent.click(screen.getByRole('button', { name: /API_KEY/ })) expect(screen.getByText('workflow.debug.variableInspect.envNode'))!.toBeInTheDocument() expect(handleSelect).toHaveBeenCalledWith({ @@ -100,7 +100,7 @@ describe('VariableInspect Group', () => { expect(screen.getByText('visible_var'))!.toBeInTheDocument() expect(screen.queryByText('hidden_var')).not.toBeInTheDocument() - fireEvent.click(screen.getByText('Code')) + fireEvent.click(screen.getByRole('button', { name: 'Code' })) expect(screen.queryByText('visible_var')).not.toBeInTheDocument() }) @@ -120,10 +120,8 @@ describe('VariableInspect Group', () => { />, ) - const actionButtons = screen.getAllByRole('button') - - fireEvent.click(actionButtons[0]!) - fireEvent.click(actionButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.view' })) + fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.clearNode' })) expect(handleView).toHaveBeenCalledTimes(1) expect(handleClear).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/variable-inspect/group.tsx b/web/app/components/workflow/variable-inspect/group.tsx index ab3c327ce0..bef4fecb63 100644 --- a/web/app/components/workflow/variable-inspect/group.tsx +++ b/web/app/components/workflow/variable-inspect/group.tsx @@ -45,6 +45,11 @@ const Group = ({ const isEnv = varType === VarInInspectType.environment const isChatVar = varType === VarInInspectType.conversation const isSystem = varType === VarInInspectType.system + const groupTitle = nodeData?.title + || (isEnv && t('debug.variableInspect.envNode', { ns: 'workflow' })) + || (isChatVar && t('debug.variableInspect.chatNode', { ns: 'workflow' })) + || (isSystem && t('debug.variableInspect.systemNode', { ns: 'workflow' })) + || '' const visibleVarList = isEnv ? varList : varList.filter(v => v.visible) @@ -100,15 +105,20 @@ const Group = ({ <div className="p-0.5"> {/* node item */} <div className="group flex h-6 items-center gap-0.5"> - <div className="h-3 w-3 shrink-0"> - {nodeData?.isSingRunRunning && ( - <RiLoader2Line className="h-3 w-3 animate-spin text-text-accent" /> - )} - {(!nodeData || !nodeData.isSingRunRunning) && visibleVarList.length > 0 && ( - <RiArrowRightSLine className={cn('h-3 w-3 text-text-tertiary', !isCollapsed && 'rotate-90')} onClick={() => setIsCollapsed(!isCollapsed)} /> - )} - </div> - <div className="flex grow cursor-pointer items-center gap-1" onClick={() => setIsCollapsed(!isCollapsed)}> + <button + type="button" + aria-expanded={visibleVarList.length > 0 ? !isCollapsed : undefined} + className="flex min-w-0 grow cursor-pointer items-center gap-0.5 rounded-sm border-none bg-transparent p-0 text-left outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" + onClick={() => setIsCollapsed(!isCollapsed)} + > + <div className="h-3 w-3 shrink-0"> + {nodeData?.isSingRunRunning && ( + <RiLoader2Line className="h-3 w-3 animate-spin text-text-accent" aria-hidden /> + )} + {(!nodeData || !nodeData.isSingRunRunning) && visibleVarList.length > 0 && ( + <RiArrowRightSLine className={cn('h-3 w-3 text-text-tertiary', !isCollapsed && 'rotate-90')} aria-hidden /> + )} + </div> {nodeData && ( <> <BlockIcon @@ -122,19 +132,17 @@ const Group = ({ )} {!nodeData && ( <div className="truncate system-xs-medium-uppercase text-text-tertiary"> - {isEnv && t('debug.variableInspect.envNode', { ns: 'workflow' })} - {isChatVar && t('debug.variableInspect.chatNode', { ns: 'workflow' })} - {isSystem && t('debug.variableInspect.systemNode', { ns: 'workflow' })} + {groupTitle} </div> )} - </div> + </button> {nodeData && !nodeData.isSingRunRunning && ( <div className="hidden shrink-0 items-center group-hover:flex"> <Tooltip> <TooltipTrigger render={( - <ActionButton onClick={handleView}> - <RiFileList3Line className="h-4 w-4" /> + <ActionButton aria-label={t('debug.variableInspect.view', { ns: 'workflow' })} onClick={handleView}> + <RiFileList3Line className="h-4 w-4" aria-hidden /> </ActionButton> )} /> @@ -145,8 +153,8 @@ const Group = ({ <Tooltip> <TooltipTrigger render={( - <ActionButton onClick={handleClear}> - <RiDeleteBinLine className="h-4 w-4" /> + <ActionButton aria-label={t('debug.variableInspect.clearNode', { ns: 'workflow' })} onClick={handleClear}> + <RiDeleteBinLine className="h-4 w-4" aria-hidden /> </ActionButton> )} /> @@ -161,11 +169,14 @@ const Group = ({ {!isCollapsed && !nodeData?.isSingRunRunning && ( <div className="px-0.5"> {visibleVarList.length > 0 && visibleVarList.map(varItem => ( - <div + <button + type="button" key={varItem.id} className={cn( - 'relative flex cursor-pointer items-center gap-1 rounded-md px-3 py-1 hover:bg-state-base-hover', - varItem.id === currentVar?.var?.id && 'bg-state-base-hover-alt hover:bg-state-base-hover-alt', + 'relative flex w-full cursor-pointer items-center gap-1 rounded-md border-none px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover', + varItem.id === currentVar?.var?.id + ? 'bg-state-base-hover-alt hover:bg-state-base-hover-alt' + : 'bg-transparent', )} onClick={() => handleSelectVar(varItem, varType)} > @@ -176,7 +187,7 @@ const Group = ({ /> <div className="grow truncate system-sm-medium text-text-secondary">{varItem.name}</div> <div className="shrink-0 system-xs-regular text-text-tertiary">{varItem.value_type}</div> - </div> + </button> ))} </div> )} diff --git a/web/app/education-apply/expire-notice-modal.tsx b/web/app/education-apply/expire-notice-modal.tsx index 5e07bf0fb1..ca59527508 100644 --- a/web/app/education-apply/expire-notice-modal.tsx +++ b/web/app/education-apply/expire-notice-modal.tsx @@ -49,7 +49,7 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => { }} > <DialogContent className="w-full max-w-[600px] overflow-hidden! border-none text-left align-middle"> - <DialogCloseButton data-testid="modal-close-button" /> + <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> {expired ? t(`${i18nPrefix}.expired.title`, { ns: 'education' }) : t(`${i18nPrefix}.isAboutToExpire.title`, { ns: 'education', date: formatTime(expireAt, t(`${i18nPrefix}.dateFormat`, { ns: 'education' }) as string), interpolation: { escapeValue: false } })} </DialogTitle> diff --git a/web/docs/test.md b/web/docs/test.md index 402a24d30f..aab8b81eef 100644 --- a/web/docs/test.md +++ b/web/docs/test.md @@ -13,10 +13,10 @@ Run these commands from `web/`. From the repository root, prefix them with `pnpm pnpm test # Watch mode -pnpm test:watch +pnpm test --watch # Generate coverage report -pnpm test:coverage +pnpm test --coverage # Run specific file pnpm test path/to/file.spec.tsx @@ -41,6 +41,8 @@ pnpm test path/to/file.spec.tsx - **Single behavior per test**: Each test verifies one user-observable behavior. - **Black-box first**: Assert external behavior and observable outputs, avoid internal implementation details. Prefer role-based queries (`getByRole`) and pattern matching (`/text/i`) over hardcoded string assertions. +- **Accessibility selectors first**: Prefer `getByRole` with accessible `name`, then `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)` queries. Avoid `getByTestId` unless the target has no user-observable semantics, such as a mocked non-visual integration boundary, virtualized/canvas output, or a third-party widget shim. +- **Fix markup before selectors**: If a test cannot find a control by role, name, label, landmark, or dialog scope, treat that as a component accessibility problem first. Add semantic HTML, an accessible name, or an associated label instead of adding `data-testid`. - **Semantic naming**: Use `should <behavior> when <condition>` and group related cases with `describe(<subject or scenario>)`. - **AAA / Given–When–Then**: Separate Arrange, Act, and Assert clearly with code blocks or comments. - **Minimal but sufficient assertions**: Keep only the expectations that express the essence of the behavior. @@ -79,6 +81,16 @@ Use `pnpm analyze-component <path>` to analyze component complexity and adopt di - ✅ TypeScript: No `any` types - ✅ **Cleanup**: `vi.clearAllMocks()` should be in `beforeEach()`, not `afterEach()`. This ensures mock call history is reset before each test, preventing test pollution when using assertions like `toHaveBeenCalledWith()` or `toHaveBeenCalledTimes()`. +### Test ID Policy + +`data-testid` is a last resort because users and assistive technology cannot perceive it. It can hide broken semantics, such as a clickable `div` that should have been a button or an input without a label. + +- Prefer `screen.getByRole('button', { name: /save/i })`, `screen.getByRole('textbox', { name: /email/i })`, and scoped queries like `within(screen.getByRole('dialog', { name: /settings/i }))`. +- Prefer `userEvent` for interactions that should match real user behavior. +- Remove production `data-testid` attributes when they exist only to support tests and a semantic selector is available. +- Keep `data-testid` only for justified boundaries that cannot expose stable semantics in the test environment: mocked non-visual children, browser/editor shims such as Monaco, canvas/chart output, or third-party widgets. In those cases, keep assertions focused on the boundary contract rather than internal DOM shape. +- Do not assert decorative icons by test id. If the icon conveys meaning, give the control an accessible name and assert the control. If the icon is decorative, keep it `aria-hidden`. + **⚠️ Mock components must accurately reflect actual component behavior**, especially conditional rendering based on props or state. **Rules**: diff --git a/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx b/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx index 7c950becc3..7de4325ad2 100644 --- a/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx +++ b/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx @@ -8,9 +8,9 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({ value: Tag[] onOpenTagManagement?: () => void }) => ( - <div data-testid="tag-selector"> - <div data-testid="tag-values">{value.map(tag => tag.id).join(',')}</div> - <div data-testid="selected-count"> + <div role="group" aria-label="Tag selector mock"> + <div>{value.map(tag => tag.id).join(',')}</div> + <div> {value.length} {' '} tags @@ -42,29 +42,29 @@ describe('DatasetCardTags', () => { describe('Rendering', () => { it('should render without crashing', () => { render(<DatasetCardTags {...defaultProps} />) - expect(screen.getByTestId('tag-selector')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Tag selector mock' })).toBeInTheDocument() }) it('should render TagSelector with correct value', () => { render(<DatasetCardTags {...defaultProps} />) - expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2') + expect(screen.getByText('tag-1,tag-2')).toBeInTheDocument() }) it('should display selected tags count', () => { render(<DatasetCardTags {...defaultProps} />) - expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags') + expect(screen.getByText('2 tags')).toBeInTheDocument() }) }) describe('Props', () => { it('should pass dataset id to TagSelector', () => { render(<DatasetCardTags {...defaultProps} datasetId="custom-dataset-id" />) - expect(screen.getByTestId('tag-selector')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Tag selector mock' })).toBeInTheDocument() }) it('should render with empty tags', () => { render(<DatasetCardTags {...defaultProps} tags={[]} />) - expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags') + expect(screen.getByText('0 tags')).toBeInTheDocument() }) }) @@ -119,7 +119,7 @@ describe('DatasetCardTags', () => { it('should keep TagSelector visible when tags are empty', () => { const { container } = render(<DatasetCardTags {...defaultProps} tags={[]} />) - const tagSelectorWrapper = screen.getByTestId('tag-selector').parentElement + const tagSelectorWrapper = screen.getByRole('group', { name: 'Tag selector mock' }).parentElement expect(tagSelectorWrapper).toBeInTheDocument() expect(tagSelectorWrapper).toHaveClass('w-full') @@ -129,7 +129,7 @@ describe('DatasetCardTags', () => { it('should keep TagSelector visible when tags exist', () => { const { container } = render(<DatasetCardTags {...defaultProps} />) - const tagSelectorWrapper = screen.getByTestId('tag-selector').parentElement + const tagSelectorWrapper = screen.getByRole('group', { name: 'Tag selector mock' }).parentElement expect(tagSelectorWrapper).toBeInTheDocument() expect(tagSelectorWrapper).toHaveClass('w-full') @@ -151,7 +151,7 @@ describe('DatasetCardTags', () => { binding_count: 0, })) render(<DatasetCardTags {...defaultProps} tags={manyTags} />) - expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags') + expect(screen.getByText('20 tags')).toBeInTheDocument() }) }) }) diff --git a/web/features/tag-management/__tests__/tag-filter.spec.tsx b/web/features/tag-management/__tests__/tag-filter.spec.tsx index 933f55683b..adf109079a 100644 --- a/web/features/tag-management/__tests__/tag-filter.spec.tsx +++ b/web/features/tag-management/__tests__/tag-filter.spec.tsx @@ -45,16 +45,15 @@ describe('TagFilter', () => { expect(screen.getByText(i18n.placeholder)).toBeInTheDocument() }) - it('should render the tag icon', () => { + it('should expose the trigger as a named combobox', () => { render(<TagFilter {...defaultProps} />) - expect(screen.getByTestId('tag-filter-trigger-icon')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: i18n.placeholder })).toBeInTheDocument() }) - it('should render the arrow down icon when no tags are selected', () => { + it('should keep the placeholder in the trigger when no tags are selected', () => { render(<TagFilter {...defaultProps} />) expect(screen.getByText(i18n.placeholder)).toBeInTheDocument() - expect(screen.getByTestId('tag-filter-trigger-icon')).toBeInTheDocument() - expect(screen.getByTestId('tag-filter-arrow-down-icon')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: i18n.placeholder })).toBeInTheDocument() }) it('should display the first selected tag name when tags are selected', () => { @@ -183,9 +182,9 @@ describe('TagFilter', () => { const onChange = vi.fn() render(<TagFilter {...defaultProps} value={['tag-1', 'tag-2']} onChange={onChange} />) - const clearButton = screen.getByTestId('tag-filter-clear-button') + const clearButton = screen.getByRole('button', { name: i18n.operationClear }) expect(clearButton).toBeInTheDocument() - await user.click(clearButton!) + await user.click(clearButton) expect(onChange).toHaveBeenCalledWith([]) }) @@ -281,7 +280,7 @@ describe('TagFilter', () => { const onChange = vi.fn() render(<TagFilter {...defaultProps} value={['tag-1']} onChange={onChange} />) - const clearButton = screen.getByTestId('tag-filter-clear-button') + const clearButton = screen.getByRole('button', { name: i18n.operationClear }) expect(clearButton).toBeInTheDocument() await user.click(clearButton) diff --git a/web/features/tag-management/__tests__/tag-item-editor.spec.tsx b/web/features/tag-management/__tests__/tag-item-editor.spec.tsx index 39ebd06ae7..0114b677a6 100644 --- a/web/features/tag-management/__tests__/tag-item-editor.spec.tsx +++ b/web/features/tag-management/__tests__/tag-item-editor.spec.tsx @@ -79,6 +79,12 @@ const baseTag: Tag = { binding_count: 3, } +const i18n = { + editTag: 'common.operation.edit Frontend', + removeTag: 'common.operation.remove Frontend', + renameTag: 'common.operation.rename Frontend', +} + describe('TagItemEditor', () => { beforeEach(() => { vi.clearAllMocks() @@ -102,19 +108,19 @@ describe('TagItemEditor', () => { const user = userEvent.setup() render(<TagItemEditor tag={baseTag} />) - const editButton = screen.getByTestId('tag-item-editor-edit-button') + const editButton = screen.getByRole('button', { name: i18n.editTag }) expect(editButton).toBeInTheDocument() - await user.click(editButton as HTMLElement) + await user.click(editButton) - expect(screen.getByRole('textbox')).toHaveValue('Frontend') + expect(screen.getByRole('textbox', { name: i18n.renameTag })).toHaveValue('Frontend') }) it('should update tag and notify success when submitting a new name', async () => { const user = userEvent.setup() render(<TagItemEditor tag={baseTag} />) - const editButton = screen.getByTestId('tag-item-editor-edit-button') - await user.click(editButton as HTMLElement) + const editButton = screen.getByRole('button', { name: i18n.editTag }) + await user.click(editButton) const input = screen.getByRole('textbox') await user.clear(input) @@ -135,7 +141,7 @@ describe('TagItemEditor', () => { const user = userEvent.setup() render(<TagItemEditor tag={baseTag} />) - await user.click(screen.getByTestId('tag-item-editor-edit-button') as HTMLElement) + await user.click(screen.getByRole('button', { name: i18n.editTag })) await user.keyboard('{Enter}') expect(tagMocks.updateTag).not.toHaveBeenCalled() @@ -146,8 +152,8 @@ describe('TagItemEditor', () => { const user = userEvent.setup() render(<TagItemEditor tag={baseTag} />) - const editButton = screen.getByTestId('tag-item-editor-edit-button') - await user.click(editButton as HTMLElement) + const editButton = screen.getByRole('button', { name: i18n.editTag }) + await user.click(editButton) const input = screen.getByRole('textbox') await user.clear(input) @@ -169,8 +175,8 @@ describe('TagItemEditor', () => { vi.mocked(tagMocks.updateTag).mockRejectedValueOnce(new Error('update failed')) render(<TagItemEditor tag={baseTag} />) - const editButton = screen.getByTestId('tag-item-editor-edit-button') - await user.click(editButton as HTMLElement) + const editButton = screen.getByRole('button', { name: i18n.editTag }) + await user.click(editButton) const input = screen.getByRole('textbox') await user.clear(input) @@ -194,9 +200,9 @@ describe('TagItemEditor', () => { const removableTag: Tag = { ...baseTag, binding_count: 0 } render(<TagItemEditor tag={removableTag} />) - const removeButton = screen.getByTestId('tag-item-editor-remove-button') + const removeButton = screen.getByRole('button', { name: i18n.removeTag }) expect(removeButton).toBeInTheDocument() - await user.click(removeButton as HTMLElement) + await user.click(removeButton) await waitFor(() => { expect(tagMocks.deleteTag).toHaveBeenCalledWith('tag-1') @@ -211,8 +217,8 @@ describe('TagItemEditor', () => { const user = userEvent.setup() render(<TagItemEditor tag={baseTag} />) - const removeButton = screen.getByTestId('tag-item-editor-remove-button') - await user.click(removeButton as HTMLElement) + const removeButton = screen.getByRole('button', { name: i18n.removeTag }) + await user.click(removeButton) expect(screen.getByText('common.tag.delete "Frontend"')).toBeInTheDocument() await user.click(screen.getByText('common.operation.confirm')) @@ -229,8 +235,8 @@ describe('TagItemEditor', () => { const user = userEvent.setup() render(<TagItemEditor tag={baseTag} />) - const removeButton = screen.getByTestId('tag-item-editor-remove-button') - await user.click(removeButton as HTMLElement) + const removeButton = screen.getByRole('button', { name: i18n.removeTag }) + await user.click(removeButton) expect(screen.getByText('common.tag.delete "Frontend"')).toBeInTheDocument() await user.click(screen.getByText('common.operation.cancel')) @@ -247,8 +253,8 @@ describe('TagItemEditor', () => { const removableTag: Tag = { ...baseTag, binding_count: 0 } render(<TagItemEditor tag={removableTag} />) - const removeButton = screen.getByTestId('tag-item-editor-remove-button') - await user.click(removeButton as HTMLElement) + const removeButton = screen.getByRole('button', { name: i18n.removeTag }) + await user.click(removeButton) await waitFor(() => { expect(tagMocks.deleteTag).toHaveBeenCalledWith('tag-1') diff --git a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx index 0c7b1a5323..d9afcca54f 100644 --- a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx +++ b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx @@ -100,13 +100,12 @@ describe('TagManagementModal', () => { it('should render the close button', () => { render(<TagManagementModal {...defaultProps} />) - const closeIcon = screen.getByTestId('tag-management-modal-close-button') - expect(closeIcon).toBeTruthy() + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() }) it('should render the new tag input with placeholder', () => { render(<TagManagementModal {...defaultProps} />) - expect(screen.getByPlaceholderText(i18n.addNew)).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: i18n.addNew })).toBeInTheDocument() }) it('should fallback to empty placeholder when translation returns empty', () => { @@ -142,7 +141,7 @@ describe('TagManagementModal', () => { const onClose = vi.fn() render(<TagManagementModal {...defaultProps} onClose={onClose} />) - await user.click(screen.getByTestId('tag-management-modal-close-button')) + await user.click(screen.getByRole('button', { name: 'Close' })) expect(onClose).toHaveBeenCalledTimes(1) }) @@ -151,7 +150,7 @@ describe('TagManagementModal', () => { const user = userEvent.setup() render(<TagManagementModal {...defaultProps} />) - const input = screen.getByPlaceholderText(i18n.addNew) + const input = screen.getByRole('textbox', { name: i18n.addNew }) await user.type(input, 'NewTag') expect(input).toHaveValue('NewTag') @@ -161,7 +160,7 @@ describe('TagManagementModal', () => { const user = userEvent.setup() render(<TagManagementModal {...defaultProps} />) - const input = screen.getByPlaceholderText(i18n.addNew) + const input = screen.getByRole('textbox', { name: i18n.addNew }) await user.type(input, 'NewTag') await user.keyboard('{Enter}') @@ -174,7 +173,7 @@ describe('TagManagementModal', () => { const user = userEvent.setup() render(<TagManagementModal {...defaultProps} />) - const input = screen.getByPlaceholderText(i18n.addNew) + const input = screen.getByRole('textbox', { name: i18n.addNew }) await user.type(input, 'NewTag') await user.keyboard('{Enter}') @@ -190,7 +189,7 @@ describe('TagManagementModal', () => { const user = userEvent.setup() render(<TagManagementModal {...defaultProps} />) - const input = screen.getByPlaceholderText(i18n.addNew) + const input = screen.getByRole('textbox', { name: i18n.addNew }) await user.type(input, 'NewTag') await user.keyboard('{Enter}') @@ -203,7 +202,7 @@ describe('TagManagementModal', () => { const user = userEvent.setup() render(<TagManagementModal {...defaultProps} />) - const input = screen.getByPlaceholderText(i18n.addNew) + const input = screen.getByRole('textbox', { name: i18n.addNew }) await user.type(input, 'NewTag') // Click outside to trigger blur await user.click(document.body) @@ -219,7 +218,7 @@ describe('TagManagementModal', () => { const user = userEvent.setup() render(<TagManagementModal {...defaultProps} />) - const input = screen.getByPlaceholderText(i18n.addNew) + const input = screen.getByRole('textbox', { name: i18n.addNew }) // Focus and press Enter without typing await user.click(input) await user.keyboard('{Enter}') @@ -233,7 +232,7 @@ describe('TagManagementModal', () => { render(<TagManagementModal {...defaultProps} />) - const input = screen.getByPlaceholderText(i18n.addNew) + const input = screen.getByRole('textbox', { name: i18n.addNew }) await user.type(input, 'FailTag') await user.keyboard('{Enter}') @@ -253,7 +252,7 @@ describe('TagManagementModal', () => { render(<TagManagementModal {...defaultProps} />) // Should still render the input - expect(screen.getByPlaceholderText(i18n.addNew)).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: i18n.addNew })).toBeInTheDocument() }) it('should handle tag creation with knowledge type', async () => { @@ -262,7 +261,7 @@ describe('TagManagementModal', () => { render(<TagManagementModal {...defaultProps} type="knowledge" />) - const input = screen.getByPlaceholderText(i18n.addNew) + const input = screen.getByRole('textbox', { name: i18n.addNew }) await user.type(input, 'KnowledgeTag') await user.keyboard('{Enter}') diff --git a/web/features/tag-management/__tests__/tag-panel.spec.tsx b/web/features/tag-management/__tests__/tag-panel.spec.tsx index 65b2f0c285..78c129b89d 100644 --- a/web/features/tag-management/__tests__/tag-panel.spec.tsx +++ b/web/features/tag-management/__tests__/tag-panel.spec.tsx @@ -135,8 +135,9 @@ describe('TagPanel', () => { await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'BrandNewTag') - expect(screen.getByTestId('create-tag-option')).toHaveTextContent(i18n.create) - expect(screen.getByTestId('create-tag-option')).toHaveTextContent('BrandNewTag') + const createOption = screen.getByRole('option', { name: /BrandNewTag/i }) + expect(createOption).toHaveTextContent(i18n.create) + expect(createOption).toHaveTextContent('BrandNewTag') }) it('does not show a create option for an exact existing tag name', async () => { @@ -145,7 +146,7 @@ describe('TagPanel', () => { await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'Frontend') - expect(screen.queryByTestId('create-tag-option')).not.toBeInTheDocument() + expect(screen.queryByRole('option', { name: /common\.tag\.create/i })).not.toBeInTheDocument() }) it('updates only the combobox draft value when selecting and deselecting options', async () => { @@ -165,7 +166,7 @@ describe('TagPanel', () => { const input = screen.getByRole('combobox', { name: i18n.selectorPlaceholder }) await user.type(input, 'BrandNewTag') - await user.click(screen.getByTestId('create-tag-option')) + await user.click(screen.getByRole('option', { name: /BrandNewTag/i })) expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([ expect.objectContaining({ diff --git a/web/features/tag-management/__tests__/tag-selector.spec.tsx b/web/features/tag-management/__tests__/tag-selector.spec.tsx index 3b751a7a17..f33470825b 100644 --- a/web/features/tag-management/__tests__/tag-selector.spec.tsx +++ b/web/features/tag-management/__tests__/tag-selector.spec.tsx @@ -233,7 +233,7 @@ describe('TagSelector', () => { await user.click(screen.getByRole('combobox', { name: i18n.addTag })) await user.type(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), 'NewKnowledgeTag') - await user.click(await screen.findByTestId('create-tag-option')) + await user.click(await screen.findByRole('option', { name: /NewKnowledgeTag/i })) await waitFor(() => { expect(createTag).toHaveBeenCalledWith('NewKnowledgeTag', 'knowledge') diff --git a/web/features/tag-management/__tests__/tag-trigger.spec.tsx b/web/features/tag-management/__tests__/tag-trigger.spec.tsx index 3f98d8de1c..abca2b95ec 100644 --- a/web/features/tag-management/__tests__/tag-trigger.spec.tsx +++ b/web/features/tag-management/__tests__/tag-trigger.spec.tsx @@ -41,8 +41,7 @@ describe('Trigger', () => { it('should render a badge even when a tag label is an empty string', () => { render(<TagTrigger tags={['']} />) - // One outer container + one tag badge. - expect(screen.getAllByTestId(/^tag-badge-/)).toHaveLength(1) + expect(screen.getAllByRole('listitem')).toHaveLength(1) expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument() }) @@ -51,7 +50,7 @@ describe('Trigger', () => { render(<TagTrigger tags={tags} />) tags.forEach(tag => expect(screen.getByText(tag)).toBeInTheDocument()) - expect(screen.getAllByTestId(/^tag-badge-/)).toHaveLength(tags.length) + expect(screen.getAllByRole('listitem')).toHaveLength(tags.length) }) }) }) diff --git a/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx b/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx index 26d6cf67aa..8a75e99770 100644 --- a/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx +++ b/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx @@ -17,11 +17,8 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({ renderTagSelector(props) return ( - <div data-testid="tag-selector"> - <span data-testid="target-id">{props.targetId}</span> - <span data-testid="tag-type">{props.type}</span> - <span data-testid="selected-tag-ids">{props.value.map(tag => tag.id).join(',')}</span> - <span data-testid="selected-tag-names">{props.value.map(tag => tag.name).join(',')}</span> + <div role="group" aria-label="Tag selector mock"> + <span>{props.value.map(tag => tag.name).join(',')}</span> <button type="button" onClick={props.onOpenTagManagement}>Manage Tags</button> <button type="button" onClick={props.onTagsChange}>Tags Changed</button> </div> @@ -43,11 +40,8 @@ describe('AppCardTags', () => { it('should render TagSelector with app tag bindings', () => { render(<AppCardTags appId="app-1" tags={tags} />) - expect(screen.getByTestId('tag-selector')).toBeInTheDocument() - expect(screen.getByTestId('target-id')).toHaveTextContent('app-1') - expect(screen.getByTestId('tag-type')).toHaveTextContent('app') - expect(screen.getByTestId('selected-tag-ids')).toHaveTextContent('tag-1,tag-2') - expect(screen.getByTestId('selected-tag-names')).toHaveTextContent('Frontend,Backend') + expect(screen.getByRole('group', { name: 'Tag selector mock' })).toBeInTheDocument() + expect(screen.getByText('Frontend,Backend')).toBeInTheDocument() expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({ placement: 'bottom-start', targetId: 'app-1', @@ -83,7 +77,6 @@ describe('AppCardTags', () => { it('should pass an empty selection when the app has no tags', () => { render(<AppCardTags appId="app-1" tags={[]} />) - expect(screen.getByTestId('selected-tag-ids')).toHaveTextContent('') expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({ value: [], })) diff --git a/web/features/tag-management/components/tag-filter.tsx b/web/features/tag-management/components/tag-filter.tsx index 6f7ac1dd93..bca8465730 100644 --- a/web/features/tag-management/components/tag-filter.tsx +++ b/web/features/tag-management/components/tag-filter.tsx @@ -80,7 +80,7 @@ export const TagFilter = ({ > <span className="flex min-w-0 items-center gap-1"> <span className="p-px"> - <Tag01Icon className="h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-trigger-icon" /> + <Tag01Icon className="h-3.5 w-3.5 text-text-tertiary" aria-hidden="true" /> </span> <span className="min-w-0 truncate text-[13px] leading-4.5 text-text-secondary"> {!value.length && t('tag.placeholder', { ns: 'common' })} @@ -91,7 +91,7 @@ export const TagFilter = ({ )} {!value.length && ( <span className="shrink-0 p-px"> - <span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-arrow-down-icon" /> + <span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" /> </span> )} </span> @@ -100,14 +100,13 @@ export const TagFilter = ({ <button type="button" aria-label={t('operation.clear', { ns: 'common' })} - className="group/clear absolute top-1/2 right-2 -translate-y-1/2 p-px" + className="group/clear absolute top-1/2 right-2 -translate-y-1/2 border-none bg-transparent p-px" onClick={(event) => { event.stopPropagation() onChange([]) }} - data-testid="tag-filter-clear-button" > - <XCircleIcon className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" /> + <XCircleIcon className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" aria-hidden="true" /> </button> )} <ComboboxContent diff --git a/web/features/tag-management/components/tag-item-editor.tsx b/web/features/tag-management/components/tag-item-editor.tsx index edf74196c3..0e47eafa60 100644 --- a/web/features/tag-management/components/tag-item-editor.tsx +++ b/web/features/tag-management/components/tag-item-editor.tsx @@ -100,11 +100,18 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => { </TooltipTrigger> <TooltipContent>{t('common.tagBound', { ns: 'workflow' })}</TooltipContent> </Tooltip> - <div className="group/edit shrink-0 cursor-pointer rounded-md p-1 hover:bg-state-base-hover" onClick={() => setIsEditing(true)}> - <span className="i-ri-edit-line h-3 w-3 text-text-tertiary group-hover/edit:text-text-secondary" data-testid="tag-item-editor-edit-button" /> - </div> - <div - className="group/remove shrink-0 cursor-pointer rounded-md p-1 hover:bg-state-base-hover" + <button + type="button" + aria-label={`${t('operation.edit', { ns: 'common' })} ${tag.name}`} + className="group/edit shrink-0 cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => setIsEditing(true)} + > + <span aria-hidden="true" className="i-ri-edit-line h-3 w-3 text-text-tertiary group-hover/edit:text-text-secondary" /> + </button> + <button + type="button" + aria-label={`${t('operation.remove', { ns: 'common' })} ${tag.name}`} + className="group/remove shrink-0 cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => { if (tag.binding_count) setShowRemoveModal(true) @@ -112,11 +119,11 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => { handleRemove() }} > - <span className="i-ri-delete-bin-line h-3 w-3 text-text-tertiary group-hover/remove:text-text-secondary" data-testid="tag-item-editor-remove-button" /> - </div> + <span aria-hidden="true" className="i-ri-delete-bin-line h-3 w-3 text-text-tertiary group-hover/remove:text-text-secondary" /> + </button> </> )} - {isEditing && (<input className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary" autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && editTag(tag.id, name)} onBlur={() => editTag(tag.id, name)} />)} + {isEditing && (<input aria-label={`${t('operation.rename', { ns: 'common' })} ${tag.name}`} className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary" autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && editTag(tag.id, name)} onBlur={() => editTag(tag.id, name)} />)} </div> <AlertDialog open={showRemoveModal} onOpenChange={open => !open && setShowRemoveModal(false)}> <AlertDialogContent> diff --git a/web/features/tag-management/components/tag-management-modal.tsx b/web/features/tag-management/components/tag-management-modal.tsx index 05a09519be..129ac2ce2e 100644 --- a/web/features/tag-management/components/tag-management-modal.tsx +++ b/web/features/tag-management/components/tag-management-modal.tsx @@ -56,9 +56,9 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan <Dialog open={show} onOpenChange={open => !open && handleClose()}> <DialogContent className="w-150 max-w-150 rounded-xl p-8"> <div className="relative pb-2 text-xl leading-7.5 font-semibold text-text-primary">{t('tag.manageTags', { ns: 'common' })}</div> - <DialogCloseButton data-testid="tag-management-modal-close-button" className="top-4 right-4" /> + <DialogCloseButton className="top-4 right-4" /> <div className="mt-3 flex flex-wrap gap-2"> - <input className="w-25 shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm leading-5 text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary focus:border-solid" placeholder={t('tag.addNew', { ns: 'common' }) || ''} autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} /> + <input aria-label={t('tag.addNew', { ns: 'common' }) || ''} className="w-25 shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm leading-5 text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary focus:border-solid" placeholder={t('tag.addNew', { ns: 'common' }) || ''} autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} /> {tagList.map(tag => (<TagItemEditor key={tag.id} tag={tag} onTagsChange={onTagsChange} />))} </div> </DialogContent> diff --git a/web/features/tag-management/components/tag-panel.tsx b/web/features/tag-management/components/tag-panel.tsx index d112dd9ceb..e778ecdb03 100644 --- a/web/features/tag-management/components/tag-panel.tsx +++ b/web/features/tag-management/components/tag-panel.tsx @@ -44,7 +44,6 @@ export const TagPanel = ({ className="mr-1.5 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-components-input-bg-hover hover:text-text-secondary focus-visible:bg-components-input-bg-hover focus-visible:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset" onClick={() => onInputValueChange('')} onPointerDown={event => event.preventDefault()} - data-testid="tag-search-clear-button" > <span className="i-ri-close-line size-4" aria-hidden="true" /> </button> @@ -59,7 +58,6 @@ export const TagPanel = ({ <Fragment key={tag.id}> <ComboboxItem value={tag} - data-testid="create-tag-option" > <ComboboxItemText className="flex items-center gap-x-1 px-0"> <span aria-hidden="true" className="i-ri-add-line h-4 w-4 shrink-0 text-text-tertiary" /> diff --git a/web/features/tag-management/components/tag-trigger.tsx b/web/features/tag-management/components/tag-trigger.tsx index 49b08b4f76..f76cba4333 100644 --- a/web/features/tag-management/components/tag-trigger.tsx +++ b/web/features/tag-management/components/tag-trigger.tsx @@ -10,7 +10,10 @@ export const TagTrigger = ({ const { t } = useTranslation() return ( - <div className="flex w-full cursor-pointer items-center gap-1 overflow-hidden rounded-lg p-1 hover:bg-state-base-hover"> + <div + className="flex w-full cursor-pointer items-center gap-1 overflow-hidden rounded-lg p-1 hover:bg-state-base-hover" + role={tags.length ? 'list' : undefined} + > {!tags.length ? ( <div className="flex max-w-full min-w-0 items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75"> @@ -27,8 +30,8 @@ export const TagTrigger = ({ return ( <div key={content} + role="listitem" className="flex max-w-30 min-w-0 shrink-0 items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75" - data-testid={`tag-badge-${content}`} > <span aria-hidden="true" className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" /> <div className="truncate system-2xs-medium-uppercase text-text-tertiary"> diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index d4f72c242d..bddbef74e1 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -193,6 +193,7 @@ "imageInput.browse": "browse", "imageInput.dropImageHere": "Drop your image here, or", "imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF", + "imageUploader.imageList": "Image list", "imageUploader.imageUpload": "Image Upload", "imageUploader.pasteImageLink": "Paste image link", "imageUploader.pasteImageLinkInputPlaceholder": "Paste image link here", @@ -511,6 +512,8 @@ "operation.ok": "OK", "operation.openInNewTab": "Open in new tab", "operation.params": "Params", + "operation.pause": "Pause", + "operation.play": "Play", "operation.refresh": "Restart", "operation.regenerate": "Regenerate", "operation.reload": "Reload", @@ -518,6 +521,7 @@ "operation.rename": "Rename", "operation.reset": "Reset", "operation.resetKeywords": "Reset keywords", + "operation.retry": "Retry", "operation.save": "Save", "operation.saveAndEnable": "Save & Enable", "operation.saveAndRegenerate": "Save & Regenerate Child Chunks", @@ -532,6 +536,8 @@ "operation.skip": "Skip", "operation.submit": "Submit", "operation.sure": "I'm sure", + "operation.toggleFullscreen": "Toggle fullscreen", + "operation.toggleMute": "Toggle mute", "operation.view": "View", "operation.viewDetails": "View Details", "operation.viewMore": "VIEW MORE", @@ -676,5 +682,6 @@ "voiceInput.converting": "Converting to text...", "voiceInput.notAllow": "microphone not authorized", "voiceInput.speaking": "Speak now...", + "voiceInput.start": "Voice input", "you": "You" } From 279b66bc7f048d91294cbff0208847131054045e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 03:53:07 +0000 Subject: [PATCH 31/53] chore(deps): bump gunicorn from 25.3.0 to 26.0.0 in /api in the flask group (#36011) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index a88ad174fd..f142ed7d39 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "gevent-websocket>=0.10.1", "gmpy2>=2.3.0", "google-api-python-client>=2.195.0", - "gunicorn>=25.3.0", + "gunicorn>=26.0.0", "psycogreen>=1.0.2", "psycopg2-binary>=2.9.12", "python-socketio>=5.13.0", diff --git a/api/uv.lock b/api/uv.lock index cbb6440533..53db435fc8 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1598,7 +1598,7 @@ requires-dist = [ { name = "google-api-python-client", specifier = ">=2.195.0" }, { name = "google-cloud-aiplatform", specifier = ">=1.149.0,<2.0.0" }, { name = "graphon", specifier = "~=0.3.0" }, - { name = "gunicorn", specifier = ">=25.3.0" }, + { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, { name = "json-repair", specifier = "~=0.59.4" }, @@ -3102,14 +3102,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "25.3.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] From a643b05368816d253d04935dab01dcbf849f844d Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 11 May 2026 12:01:49 +0800 Subject: [PATCH 32/53] fix(web): remove unsafe select value casts (#36007) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 10 --- .../app/overview/settings/index.tsx | 27 ++++--- .../field/input-type-select/index.tsx | 13 ++-- .../components/base/markdown-blocks/form.tsx | 55 +++++++------- web/app/components/base/theme-selector.tsx | 14 +++- .../subscription-list/create/index.tsx | 16 +++-- .../components/frequency-selector.tsx | 14 ++-- .../workflow/nodes/trigger-webhook/panel.tsx | 71 ++++++++++++++----- web/app/signin/invite-settings/page.tsx | 62 ++++++++++------ web/app/signin/one-more-step.tsx | 61 ++++++++++------ 10 files changed, 218 insertions(+), 125 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2de84456ee..a7512f8c66 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1334,11 +1334,6 @@ "count": 9 } }, - "web/app/components/base/markdown-blocks/form.tsx": { - "erasable-syntax-only/enums": { - "count": 3 - } - }, "web/app/components/base/markdown-blocks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 10 @@ -4435,11 +4430,6 @@ "count": 1 } }, - "web/app/signin/one-more-step.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/signup/layout.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index ae772d0750..aefe339b94 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -55,10 +55,12 @@ export type ConfigParams = { const prefixSettings = 'overview.appInfo.settings' type SelectOption = { - value: string + value: Language name: string } +const LANGUAGE_OPTIONS: SelectOption[] = languages.filter(item => item.supported) + const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { const { title, @@ -139,8 +141,13 @@ const SettingsModal: FC<ISettingsModalProps> = ({ const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const isFreePlan = plan.type === 'sandbox' - const languageOptions: SelectOption[] = languages.filter(item => item.supported) - const selectedLanguage = languageOptions.find(item => item.value === language) + const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language) + + const handleLanguageChange = (nextValue: string | null) => { + const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue) + if (nextLanguage) + setLanguage(nextLanguage.value) + } const handlePlanClick = useCallback(() => { if (isFreePlan) setShowPricingModal() @@ -308,17 +315,17 @@ const SettingsModal: FC<ISettingsModalProps> = ({ <div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div> <Select value={selectedLanguage?.value ?? null} - onValueChange={(nextValue) => { - if (!nextValue) - return - setLanguage(nextValue as Language) - }} + onValueChange={handleLanguageChange} > - <SelectTrigger size="large" className="w-[200px]"> + <SelectTrigger + aria-label={t(`${prefixSettings}.language`, { ns: 'appOverview' })} + size="large" + className="w-[200px]" + > {selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })} </SelectTrigger> <SelectContent> - {languageOptions.map(item => ( + {LANGUAGE_OPTIONS.map(item => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/base/form/components/field/input-type-select/index.tsx b/web/app/components/base/form/components/field/input-type-select/index.tsx index 37f9a510d4..176d82a492 100644 --- a/web/app/components/base/form/components/field/input-type-select/index.tsx +++ b/web/app/components/base/form/components/field/input-type-select/index.tsx @@ -12,6 +12,7 @@ import Label from '../../label' import { useInputTypeOptions } from './hooks' import Option from './option' import Trigger from './trigger' +import { InputTypeEnum } from './types' type InputTypeSelectFieldProps = { label: string @@ -32,6 +33,12 @@ const InputTypeSelectField = ({ const inputTypeOptions = useInputTypeOptions(supportFile) const selected = inputTypeOptions.find(option => option.value === field.state.value) + const handleInputTypeChange = (next: string | null) => { + const inputType = InputTypeEnum.safeParse(next) + if (inputType.success) + field.handleChange(inputType.data) + } + return ( <div className={cn('flex flex-col gap-y-0.5', className)}> <Label @@ -43,11 +50,7 @@ const InputTypeSelectField = ({ items={inputTypeOptions} value={field.state.value ?? null} disabled={disabled} - onValueChange={(next) => { - if (next == null) - return - field.handleChange(next as InputType) - }} + onValueChange={handleInputTypeChange} > <SelectTrigger id={field.name} className="gap-x-0.5 px-2"> <Trigger option={selected} /> diff --git a/web/app/components/base/markdown-blocks/form.tsx b/web/app/components/base/markdown-blocks/form.tsx index 7c80590e24..55385c1c2e 100644 --- a/web/app/components/base/markdown-blocks/form.tsx +++ b/web/app/components/base/markdown-blocks/form.tsx @@ -12,28 +12,32 @@ import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-tim import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' -enum DATA_FORMAT { - TEXT = 'text', - JSON = 'json', -} -enum SUPPORTED_TAGS { - LABEL = 'label', - INPUT = 'input', - TEXTAREA = 'textarea', - BUTTON = 'button', -} -enum SUPPORTED_TYPES { - TEXT = 'text', - PASSWORD = 'password', - EMAIL = 'email', - NUMBER = 'number', - DATE = 'date', - TIME = 'time', - DATETIME = 'datetime', - CHECKBOX = 'checkbox', - SELECT = 'select', - HIDDEN = 'hidden', -} +const DATA_FORMAT = { + TEXT: 'text', + JSON: 'json', +} as const + +const SUPPORTED_TAGS = { + LABEL: 'label', + INPUT: 'input', + TEXTAREA: 'textarea', + BUTTON: 'button', +} as const + +const SUPPORTED_TYPES = { + TEXT: 'text', + PASSWORD: 'password', + EMAIL: 'email', + NUMBER: 'number', + DATE: 'date', + TIME: 'time', + DATETIME: 'datetime', + CHECKBOX: 'checkbox', + SELECT: 'select', + HIDDEN: 'hidden', +} as const + +type SupportedType = typeof SUPPORTED_TYPES[keyof typeof SUPPORTED_TYPES] const SUPPORTED_TYPES_SET = new Set<string>(Object.values(SUPPORTED_TYPES)) @@ -253,7 +257,7 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { if (!isSafeName(name)) return null - const type = str(child.properties.type) as SUPPORTED_TYPES + const type = str(child.properties.type) as SupportedType if (type === SUPPORTED_TYPES.DATE || type === SUPPORTED_TYPES.DATETIME) { return ( @@ -309,7 +313,10 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { <Select key={key} defaultValue={formValues[name] as string | undefined} - onValueChange={val => updateValue(name, val as string)} + onValueChange={(val) => { + if (val != null) + updateValue(name, val) + }} > <SelectTrigger className="w-full"> <SelectValue /> diff --git a/web/app/components/base/theme-selector.tsx b/web/app/components/base/theme-selector.tsx index 1676dfff72..67dc7b7b29 100644 --- a/web/app/components/base/theme-selector.tsx +++ b/web/app/components/base/theme-selector.tsx @@ -12,7 +12,12 @@ import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -export type Theme = 'light' | 'dark' | 'system' +const THEMES = ['light', 'dark', 'system'] as const +export type Theme = typeof THEMES[number] + +const isTheme = (value: string): value is Theme => { + return (THEMES as readonly string[]).includes(value) +} export default function ThemeSelector() { const { t } = useTranslation() @@ -22,6 +27,11 @@ export default function ThemeSelector() { setTheme(newTheme) } + const handleThemeValueChange = (value: string) => { + if (isTheme(value)) + handleThemeChange(value) + } + const getCurrentIcon = () => { switch (theme) { case 'light': return <span className="i-ri-sun-line h-4 w-4 text-text-tertiary" /> @@ -43,7 +53,7 @@ export default function ThemeSelector() { {getCurrentIcon()} </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-end" sideOffset={6} popupClassName="w-[144px]"> - <DropdownMenuRadioGroup value={theme || 'system'} onValueChange={value => handleThemeChange(value as Theme)}> + <DropdownMenuRadioGroup value={theme || 'system'} onValueChange={handleThemeValueChange}> <DropdownMenuRadioItem value="light" closeOnClick> <span className="i-ri-sun-line h-4 w-4 text-text-tertiary" /> <span className="grow px-1 system-md-regular">{t('theme.light', { ns: 'common' })}</span> diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx index 9091cd337c..9c9a95a37b 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx @@ -186,6 +186,15 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU } } + const handleCreateTypeChange = (value: string | null) => { + const option = visibleOptions.find(item => item.value === value) + if (!option) + return + + setIsMenuOpen(false) + void onChooseCreateType(option.value) + } + const onClickCreate = (e: React.MouseEvent<HTMLButtonElement>) => { if (subscriptionCount >= MAX_COUNT) { e.stopPropagation() @@ -209,12 +218,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU value={methodType === DEFAULT_METHOD ? null : methodType} open={shouldAllowSelect ? isMenuOpen : false} onOpenChange={setIsMenuOpen} - onValueChange={(value) => { - if (!value) - return - setIsMenuOpen(false) - void onChooseCreateType(value as SupportedCreationMethods) - }} + onValueChange={handleCreateTypeChange} > <SelectTrigger render={<div />} diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx index 0343e0bead..379749e3b8 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx @@ -9,8 +9,6 @@ import { SelectLabel, SelectTrigger, } from '@langgenius/dify-ui/select' -import * as React from 'react' -import { useMemo } from 'react' import { useTranslation } from 'react-i18next' type FrequencyOption = { @@ -27,19 +25,25 @@ const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => { const { t } = useTranslation() const groupLabel = t('nodes.triggerSchedule.frequency.label', { ns: 'workflow' }) - const frequencies = useMemo<FrequencyOption[]>(() => [ + const frequencies: FrequencyOption[] = [ { value: 'hourly', name: t('nodes.triggerSchedule.frequency.hourly', { ns: 'workflow' }) }, { value: 'daily', name: t('nodes.triggerSchedule.frequency.daily', { ns: 'workflow' }) }, { value: 'weekly', name: t('nodes.triggerSchedule.frequency.weekly', { ns: 'workflow' }) }, { value: 'monthly', name: t('nodes.triggerSchedule.frequency.monthly', { ns: 'workflow' }) }, - ], [t]) + ] const selectedFrequency = frequencies.find(item => item.value === frequency) + const handleFrequencyChange = (value: string | null) => { + const selected = frequencies.find(item => item.value === value) + if (selected) + onChange(selected.value) + } + return ( <Select key={`${frequency}-${groupLabel}`} value={frequency} - onValueChange={value => value && onChange(value as ScheduleFrequency)} + onValueChange={handleFrequencyChange} > <SelectTrigger className="w-full py-2"> {selectedFrequency?.name ?? t('nodes.triggerSchedule.selectFrequency', { ns: 'workflow' })} diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx index 53498c52f2..9acfa7e702 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -36,7 +36,7 @@ const HTTP_METHODS = [ { name: 'DELETE', value: 'DELETE' }, { name: 'PATCH', value: 'PATCH' }, { name: 'HEAD', value: 'HEAD' }, -] +] satisfies Array<{ name: string, value: HttpMethod }> const CONTENT_TYPES = [ { name: 'application/json', value: 'application/json' }, @@ -46,6 +46,51 @@ const CONTENT_TYPES = [ { name: 'multipart/form-data', value: 'multipart/form-data' }, ] +type WebhookMethodSelectorProps = { + nodeId: string + label: string + value: HttpMethod + disabled: boolean + onChange: (method: HttpMethod) => void +} + +const WebhookMethodSelector = ({ + nodeId, + label, + value, + disabled, + onChange, +}: WebhookMethodSelectorProps) => { + const selectedMethod = HTTP_METHODS.find(item => item.value === value) ?? null + + const handleMethodChange = (nextValue: string | null) => { + const nextMethod = HTTP_METHODS.find(item => item.value === nextValue) + if (nextMethod) + onChange(nextMethod.value) + } + + return ( + <Select + key={`${nodeId}-method-${value}`} + value={selectedMethod?.value ?? null} + disabled={disabled} + onValueChange={handleMethodChange} + > + <SelectTrigger aria-label={label} className="h-8 pr-8 text-sm"> + {selectedMethod?.name} + </SelectTrigger> + <SelectContent popupClassName="w-26 min-w-26"> + {HTTP_METHODS.map(item => ( + <SelectItem key={item.value} value={item.value}> + <SelectItemText>{item.name}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + ) +} + const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ id, data, @@ -75,7 +120,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ } }, [readOnly, inputs.webhook_url, generateWebhookUrl]) - const selectedMethod = HTTP_METHODS.find(item => item.value === inputs.method) ?? null const selectedContentType = CONTENT_TYPES.find(item => item.value === inputs.content_type) ?? null return ( @@ -86,24 +130,13 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ <div className="space-y-1"> <div className="flex gap-1" style={{ height: '32px' }}> <div className="w-26 shrink-0"> - <Select - key={`${id}-method-${inputs.method}`} - value={selectedMethod?.value ?? null} + <WebhookMethodSelector + nodeId={id} + label={t(`${i18nPrefix}.method`, { ns: 'workflow' })} + value={inputs.method} disabled={readOnly} - onValueChange={value => value && handleMethodChange(value as HttpMethod)} - > - <SelectTrigger className="h-8 pr-8 text-sm"> - {selectedMethod?.name} - </SelectTrigger> - <SelectContent popupClassName="w-26 min-w-26"> - {HTTP_METHODS.map(item => ( - <SelectItem key={item.value} value={item.value}> - <SelectItemText>{item.name}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> + onChange={handleMethodChange} + /> </div> <div className="flex-1" style={{ width: '284px' }}> <InputWithCopy diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index c055789440..af0ff5e07a 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -21,11 +21,28 @@ import { useInvitationCheck } from '@/service/use-common' import { timezones } from '@/utils/timezone' import { resolvePostLoginRedirect } from '../utils/post-login-redirect' -type SelectOption = { +type LanguageSelectOption = { + value: Locale + name: string +} + +type TimezoneSelectOption = { value: string name: string } +const LANGUAGE_OPTIONS: LanguageSelectOption[] = languages + .filter(item => item.supported) + .map(item => ({ + value: item.value, + name: item.name, + })) + +const TIMEZONE_OPTIONS: TimezoneSelectOption[] = timezones.map(item => ({ + value: String(item.value), + name: item.name, +})) + export default function InviteSettingsPage() { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -35,9 +52,20 @@ export default function InviteSettingsPage() { const [name, setName] = useState('') const [language, setLanguage] = useState(LanguagesSupported[0]) const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles') - const languageOptions: SelectOption[] = languages.filter(item => item.supported) - const selectedLanguage = languageOptions.find(item => item.value === language) - const selectedTimezone = timezones.find(item => item.value === timezone) + const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language) + const selectedTimezone = TIMEZONE_OPTIONS.find(item => item.value === timezone) + + const handleLanguageChange = (nextValue: string | null) => { + const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue) + if (nextLanguage) + setLanguage(nextLanguage.value) + } + + const handleTimezoneChange = (nextValue: string | null) => { + const nextTimezone = TIMEZONE_OPTIONS.find(item => item.value === nextValue) + if (nextTimezone) + setTimezone(nextTimezone.value) + } const checkParams = { url: '/activate/check', @@ -123,23 +151,19 @@ export default function InviteSettingsPage() { </div> </div> <div className="mb-5"> - <label htmlFor="name" className="my-2 system-md-semibold text-text-secondary"> + <label htmlFor="interface_language" className="my-2 system-md-semibold text-text-secondary"> {t('interfaceLanguage', { ns: 'login' })} </label> <div className="mt-1"> <Select value={selectedLanguage?.value ?? null} - onValueChange={(nextValue) => { - if (!nextValue) - return - setLanguage(nextValue as Locale) - }} + onValueChange={handleLanguageChange} > - <SelectTrigger size="large"> + <SelectTrigger id="interface_language" size="large"> {selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })} </SelectTrigger> <SelectContent> - {languageOptions.map(item => ( + {LANGUAGE_OPTIONS.map(item => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -156,19 +180,15 @@ export default function InviteSettingsPage() { </label> <div className="mt-1"> <Select - value={selectedTimezone ? String(selectedTimezone.value) : null} - onValueChange={(nextValue) => { - if (!nextValue) - return - setTimezone(nextValue as string) - }} + value={selectedTimezone?.value ?? null} + onValueChange={handleTimezoneChange} > - <SelectTrigger size="large"> + <SelectTrigger id="timezone" size="large"> {selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })} </SelectTrigger> <SelectContent> - {timezones.map(item => ( - <SelectItem key={item.value} value={String(item.value)}> + {TIMEZONE_OPTIONS.map(item => ( + <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> </SelectItem> diff --git a/web/app/signin/one-more-step.tsx b/web/app/signin/one-more-step.tsx index 582cb1b37c..bb86a8675d 100644 --- a/web/app/signin/one-more-step.tsx +++ b/web/app/signin/one-more-step.tsx @@ -1,6 +1,5 @@ 'use client' import type { Reducer } from 'react' -import type { LanguagesSupported } from '@/i18n-config/language' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' @@ -51,6 +50,19 @@ type SelectOption = { name: string } +const LANGUAGE_OPTIONS: SelectOption[] = languages.filter(item => item.supported) +const TIMEZONE_OPTIONS: SelectOption[] = timezones.map(item => ({ + value: String(item.value), + name: item.name, +})) + +const hasStatus = (error: unknown): error is { status: number } => { + return typeof error === 'object' + && error !== null + && 'status' in error + && typeof error.status === 'number' +} + const OneMoreStep = () => { const { t } = useTranslation() const router = useRouter() @@ -62,9 +74,20 @@ const OneMoreStep = () => { timezone: 'Asia/Shanghai', }) const { mutateAsync: submitOneMoreStep, isPending } = useOneMoreStep() - const languageOptions: SelectOption[] = languages.filter(item => item.supported) - const selectedLanguage = languageOptions.find(item => item.value === state.interface_language) - const selectedTimezone = timezones.find(item => item.value === state.timezone) + const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === state.interface_language) + const selectedTimezone = TIMEZONE_OPTIONS.find(item => item.value === state.timezone) + + const handleLanguageChange = (nextValue: string | null) => { + const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue) + if (nextLanguage) + dispatch({ type: 'interface_language', value: nextLanguage.value }) + } + + const handleTimezoneChange = (nextValue: string | null) => { + const nextTimezone = TIMEZONE_OPTIONS.find(item => item.value === nextValue) + if (nextTimezone) + dispatch({ type: 'timezone', value: nextTimezone.value }) + } const handleSubmit = async () => { if (isPending) @@ -77,8 +100,8 @@ const OneMoreStep = () => { }) router.push('/apps') } - catch (error: any) { - if (error && error.status === 400) + catch (error: unknown) { + if (hasStatus(error) && error.status === 400) toast.error(t('invalidInvitationCode', { ns: 'login' })) dispatch({ type: 'failed', payload: null }) } @@ -136,23 +159,19 @@ const OneMoreStep = () => { </div> </div> <div className="mb-5"> - <label htmlFor="name" className="my-2 system-md-semibold text-text-secondary"> + <label htmlFor="interface_language" className="my-2 system-md-semibold text-text-secondary"> {t('interfaceLanguage', { ns: 'login' })} </label> <div className="mt-1"> <Select value={selectedLanguage?.value ?? null} - onValueChange={(nextValue) => { - if (!nextValue) - return - dispatch({ type: 'interface_language', value: nextValue as typeof LanguagesSupported[number] }) - }} + onValueChange={handleLanguageChange} > - <SelectTrigger size="large"> + <SelectTrigger id="interface_language" size="large"> {selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })} </SelectTrigger> <SelectContent> - {languageOptions.map(item => ( + {LANGUAGE_OPTIONS.map(item => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -168,19 +187,15 @@ const OneMoreStep = () => { </label> <div className="mt-1"> <Select - value={selectedTimezone ? String(selectedTimezone.value) : null} - onValueChange={(nextValue) => { - if (!nextValue) - return - dispatch({ type: 'timezone', value: nextValue as typeof state.timezone }) - }} + value={selectedTimezone?.value ?? null} + onValueChange={handleTimezoneChange} > - <SelectTrigger size="large"> + <SelectTrigger id="timezone" size="large"> {selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })} </SelectTrigger> <SelectContent> - {timezones.map(item => ( - <SelectItem key={item.value} value={String(item.value)}> + {TIMEZONE_OPTIONS.map(item => ( + <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> </SelectItem> From 153064bbd4076cda8b540a6ca52066ae945a5f6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 04:06:49 +0000 Subject: [PATCH 33/53] chore(i18n): sync translations with en-US (#36019) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/ar-TN/workflow.json | 4 ++-- web/i18n/de-DE/workflow.json | 4 ++-- web/i18n/es-ES/workflow.json | 4 ++-- web/i18n/fa-IR/workflow.json | 4 ++-- web/i18n/fr-FR/workflow.json | 4 ++-- web/i18n/hi-IN/workflow.json | 4 ++-- web/i18n/id-ID/workflow.json | 4 ++-- web/i18n/it-IT/workflow.json | 4 ++-- web/i18n/ja-JP/workflow.json | 4 ++-- web/i18n/ko-KR/workflow.json | 4 ++-- web/i18n/nl-NL/workflow.json | 4 ++-- web/i18n/pl-PL/workflow.json | 4 ++-- web/i18n/pt-BR/workflow.json | 4 ++-- web/i18n/ro-RO/workflow.json | 4 ++-- web/i18n/ru-RU/workflow.json | 4 ++-- web/i18n/sl-SI/workflow.json | 4 ++-- web/i18n/th-TH/workflow.json | 4 ++-- web/i18n/tr-TR/workflow.json | 4 ++-- web/i18n/uk-UA/workflow.json | 4 ++-- web/i18n/vi-VN/workflow.json | 4 ++-- web/i18n/zh-Hans/workflow.json | 4 ++-- web/i18n/zh-Hant/workflow.json | 4 ++-- 22 files changed, 44 insertions(+), 44 deletions(-) diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index 4df2de3173..08825f5dbc 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "يجب أن يبدأ معرف الإجراء بحرف أو شرطة سفلية، متبوعاً بأحرف أو أرقام أو شرطات سفلية", "nodes.humanInput.userActions.actionIdTooLong": "يجب أن يكون معرف الإجراء {{maxLength}} حرفاً أو أقل", "nodes.humanInput.userActions.actionNamePlaceholder": "اسم الإجراء", - "nodes.humanInput.userActions.buttonTextPlaceholder": "نص عرض الزر", + "nodes.humanInput.userActions.buttonTextPlaceholder": "قيمة الإجراء", "nodes.humanInput.userActions.buttonTextTooLong": "يجب أن يكون نص الزر {{maxLength}} حرفاً أو أقل", "nodes.humanInput.userActions.chooseStyle": "اختر نمط الزر", "nodes.humanInput.userActions.emptyTip": "انقر فوق الزر '+' لإضافة إجراءات المستخدم", "nodes.humanInput.userActions.title": "إجراءات المستخدم", - "nodes.humanInput.userActions.tooltip": "حدد الأزرار التي يمكن للمستخدمين النقر عليها للرد على هذا النموذج. يمكن لكل زر تشغيل مسارات سير عمل مختلفة. يجب أن يبدأ معرف الإجراء بحرف أو شرطة سفلية، متبوعاً بأحرف أو أرقام أو شرطات سفلية.", + "nodes.humanInput.userActions.tooltip": "حدد الأزرار التي يمكن للمستخدمين النقر عليها للرد على هذا النموذج. يتحكم معرف الإجراء في التفريع. يتم عرض قيمة الإجراء في المرحلة التالية كإخراج مدمج محدد. يجب أن يبدأ معرف الإجراء بحرف أو شرطة سفلية، متبوعاً بأحرف أو أرقام أو شرطات سفلية.", "nodes.humanInput.userActions.triggered": "تم تشغيل <strong>{{actionName}}</strong>", "nodes.ifElse.addCondition": "إضافة شرط", "nodes.ifElse.addSubVariable": "متغير فرعي", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index e00d300f01..0133060080 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "Aktions-ID muss mit einem Buchstaben oder Unterstrich beginnen, gefolgt von Buchstaben, Zahlen oder Unterstrichen", "nodes.humanInput.userActions.actionIdTooLong": "Aktions-ID darf höchstens {{maxLength}} Zeichen lang sein", "nodes.humanInput.userActions.actionNamePlaceholder": "Aktionsname", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Schaltflächen-Anzeigetext", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Aktionswert", "nodes.humanInput.userActions.buttonTextTooLong": "Schaltflächentext darf höchstens {{maxLength}} Zeichen lang sein", "nodes.humanInput.userActions.chooseStyle": "Wählen Sie einen Schaltflächenstil", "nodes.humanInput.userActions.emptyTip": "Klicken Sie auf die '+'-Schaltfläche, um Benutzeraktionen hinzuzufügen", "nodes.humanInput.userActions.title": "Benutzeraktionen", - "nodes.humanInput.userActions.tooltip": "Definieren Sie Schaltflächen, auf die Benutzer klicken können, um auf dieses Formular zu reagieren. Jede Schaltfläche kann unterschiedliche Workflow-Pfade auslösen. Aktions-ID muss mit einem Buchstaben oder Unterstrich beginnen, gefolgt von Buchstaben, Zahlen oder Unterstrichen.", + "nodes.humanInput.userActions.tooltip": "Definieren Sie Schaltflächen, auf die Benutzer klicken können, um auf dieses Formular zu reagieren. Die Aktions-ID steuert die Verzweigung. Der Aktionswert wird nachgelagert als ausgewählte integrierte Ausgabe bereitgestellt. Aktions-ID muss mit einem Buchstaben oder Unterstrich beginnen, gefolgt von Buchstaben, Zahlen oder Unterstrichen.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> wurde ausgelöst", "nodes.ifElse.addCondition": "Bedingung hinzufügen", "nodes.ifElse.addSubVariable": "Untervariable", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index c12a6f5cdd..57cf555831 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "El ID de acción debe comenzar con una letra o guiones bajos, seguido de letras, números o guiones bajos", "nodes.humanInput.userActions.actionIdTooLong": "El ID de acción debe tener {{maxLength}} caracteres o menos", "nodes.humanInput.userActions.actionNamePlaceholder": "Nombre de Acción", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Texto de visualización del botón", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Valor de la Acción", "nodes.humanInput.userActions.buttonTextTooLong": "El texto del botón debe tener {{maxLength}} caracteres o menos", "nodes.humanInput.userActions.chooseStyle": "Elija un estilo de botón", "nodes.humanInput.userActions.emptyTip": "Haga clic en el botón '+' para agregar acciones del usuario", "nodes.humanInput.userActions.title": "Acciones del Usuario", - "nodes.humanInput.userActions.tooltip": "Defina botones en los que los usuarios puedan hacer clic para responder a este formulario. Cada botón puede activar diferentes rutas de flujo de trabajo. El ID de acción debe comenzar con una letra o guiones bajos, seguido de letras, números o guiones bajos.", + "nodes.humanInput.userActions.tooltip": "Defina botones en los que los usuarios puedan hacer clic para responder a este formulario. El ID de acción controla la ramificación. El Valor de la Acción se expone aguas abajo como la salida integrada seleccionada. El ID de acción debe comenzar con una letra o guiones bajos, seguido de letras, números o guiones bajos.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> ha sido activado", "nodes.ifElse.addCondition": "Agregar condición", "nodes.ifElse.addSubVariable": "Sub Variable", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index d9cb4c2c40..813931c74f 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "شناسه عملیات باید با حرف یا زیرخط شروع شود و فقط شامل حروف، اعداد و زیرخط باشد", "nodes.humanInput.userActions.actionIdTooLong": "شناسه عملیات باید {{maxLength}} کاراکتر یا کمتر باشد", "nodes.humanInput.userActions.actionNamePlaceholder": "نام عملیات", - "nodes.humanInput.userActions.buttonTextPlaceholder": "متن نمایشی دکمه", + "nodes.humanInput.userActions.buttonTextPlaceholder": "مقدار عملیات", "nodes.humanInput.userActions.buttonTextTooLong": "متن دکمه باید {{maxLength}} کاراکتر یا کمتر باشد", "nodes.humanInput.userActions.chooseStyle": "انتخاب سبک دکمه", "nodes.humanInput.userActions.emptyTip": "برای افزودن عملیات کاربر روی '+' کلیک کنید", "nodes.humanInput.userActions.title": "عملیات کاربر", - "nodes.humanInput.userActions.tooltip": "دکمه‌هایی تعریف کنید که کاربر برای پاسخ به فرم روی آن‌ها کلیک کند. هر دکمه می‌تواند مسیر متفاوتی در گردش کار ایجاد کند. شناسه عملیات باید با حرف یا زیرخط شروع شود.", + "nodes.humanInput.userActions.tooltip": "دکمه‌هایی تعریف کنید که کاربر برای پاسخ به فرم روی آن‌ها کلیک کند. شناسه عملیات انشعاب را کنترل می‌کند. مقدار عملیات به عنوان خروجی داخلی انتخاب‌شده در مرحله پایین‌تر نمایش داده می‌شود. شناسه عملیات باید با حرف یا زیرخط شروع شود، و سپس حروف، اعداد یا زیرخط بیاید.", "nodes.humanInput.userActions.triggered": "عملیات <strong>{{actionName}}</strong> فعال شد", "nodes.ifElse.addCondition": "افزودن شرط", "nodes.ifElse.addSubVariable": "متغیر فرعی", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index cabed62c22..8cc130cc02 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "L'ID d'action doit commencer par une lettre ou des traits de soulignement, suivi de lettres, de chiffres ou de traits de soulignement", "nodes.humanInput.userActions.actionIdTooLong": "L'ID d'action doit comporter {{maxLength}} caractères ou moins", "nodes.humanInput.userActions.actionNamePlaceholder": "Nom de l'Action", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Texte d'affichage du bouton", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Valeur de l'action", "nodes.humanInput.userActions.buttonTextTooLong": "Le texte du bouton doit comporter {{maxLength}} caractères ou moins", "nodes.humanInput.userActions.chooseStyle": "Choisissez un style de bouton", "nodes.humanInput.userActions.emptyTip": "Cliquez sur le bouton '+' pour ajouter des actions utilisateur", "nodes.humanInput.userActions.title": "Actions Utilisateur", - "nodes.humanInput.userActions.tooltip": "Définissez les boutons sur lesquels les utilisateurs peuvent cliquer pour répondre à ce formulaire. Chaque bouton peut déclencher différents chemins de flux de travail. L'ID d'action doit commencer par une lettre ou des traits de soulignement, suivi de lettres, de chiffres ou de traits de soulignement.", + "nodes.humanInput.userActions.tooltip": "Définissez les boutons sur lesquels les utilisateurs peuvent cliquer pour répondre à ce formulaire. L'ID d'action contrôle la ramification. La Valeur de l'action est exposée en aval comme la sortie intégrée sélectionnée. L'ID d'action doit commencer par une lettre ou des traits de soulignement, suivi de lettres, de chiffres ou de traits de soulignement.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> a été déclenché", "nodes.ifElse.addCondition": "Ajouter une condition", "nodes.ifElse.addSubVariable": "Sous-variable", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index d16b9d0274..d5824f0039 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "कार्रवाई आईडी एक अक्षर या अंडरस्कोर से शुरू होनी चाहिए, उसके बाद अक्षर, संख्याएं या अंडरस्कोर हो सकते हैं", "nodes.humanInput.userActions.actionIdTooLong": "कार्रवाई आईडी {{maxLength}} वर्ण या उससे कम होनी चाहिए", "nodes.humanInput.userActions.actionNamePlaceholder": "कार्रवाई नाम", - "nodes.humanInput.userActions.buttonTextPlaceholder": "बटन प्रदर्शित करने के लिए पाठ", + "nodes.humanInput.userActions.buttonTextPlaceholder": "क्रिया मूल्य", "nodes.humanInput.userActions.buttonTextTooLong": "बटन पाठ {{maxLength}} वर्ण या उससे कम होना चाहिए", "nodes.humanInput.userActions.chooseStyle": "बटन शैली चुनें", "nodes.humanInput.userActions.emptyTip": "उपयोगकर्ता कार्रवाइयां जोड़ने के लिए '+' बटन पर क्लिक करें", "nodes.humanInput.userActions.title": "उपयोगकर्ता कार्रवाइयां", - "nodes.humanInput.userActions.tooltip": "उन बटन को परिभाषित करें जिन पर उपयोगकर्ता इस फॉर्म का जवाब देने के लिए क्लिक कर सकते हैं। प्रत्येक बटन विभिन्न वर्कफ़्लो पथों को ट्रिगर कर सकता है। कार्रवाई आईडी एक अक्षर या अंडरस्कोर से शुरू होनी चाहिए, उसके बाद अक्षर, संख्याएं या अंडरस्कोर हो सकते हैं।", + "nodes.humanInput.userActions.tooltip": "उन बटन को परिभाषित करें जिन पर उपयोगकर्ता इस फॉर्म का जवाब देने के लिए क्लिक कर सकते हैं। कार्रवाई आईडी शाखाओं को नियंत्रित करती है। क्रिया मूल्य डाउनस्ट्रीम में चयनित बिल्ट-इन आउटपुट के रूप में उजागर होता है। कार्रवाई आईडी एक अक्षर या अंडरस्कोर से शुरू होनी चाहिए, उसके बाद अक्षर, संख्याएं या अंडरस्कोर हो सकते हैं।", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> ट्रिगर किया गया है", "nodes.ifElse.addCondition": "शर्त जोड़ें", "nodes.ifElse.addSubVariable": "उप चर", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index dffc4ac5bf..7e60b33609 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "ID tindakan harus dimulai dengan huruf atau garis bawah, diikuti dengan huruf, angka, atau garis bawah", "nodes.humanInput.userActions.actionIdTooLong": "ID tindakan harus {{maxLength}} karakter atau kurang", "nodes.humanInput.userActions.actionNamePlaceholder": "Nama Tindakan", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Teks Tampilan Tombol", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Nilai Tindakan", "nodes.humanInput.userActions.buttonTextTooLong": "Teks tombol harus {{maxLength}} karakter atau kurang", "nodes.humanInput.userActions.chooseStyle": "Pilih gaya tombol", "nodes.humanInput.userActions.emptyTip": "Klik tombol '+' untuk menambahkan tindakan pengguna", "nodes.humanInput.userActions.title": "Tindakan Pengguna", - "nodes.humanInput.userActions.tooltip": "Tentukan tombol yang dapat diklik pengguna untuk merespons formulir ini. Setiap tombol dapat memicu jalur alur kerja yang berbeda. ID tindakan harus dimulai dengan huruf atau garis bawah, diikuti dengan huruf, angka, atau garis bawah.", + "nodes.humanInput.userActions.tooltip": "Tentukan tombol yang dapat diklik pengguna untuk merespons formulir ini. ID tindakan mengontrol percabangan. Nilai Tindakan diekspos ke hilir sebagai output bawaan yang dipilih. ID tindakan harus dimulai dengan huruf atau garis bawah, diikuti dengan huruf, angka, atau garis bawah.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> telah dipicu", "nodes.ifElse.addCondition": "Tambahkan Kondisi", "nodes.ifElse.addSubVariable": "Sub Variabel", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index fc86bf1b2f..e8967d8b09 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "L'ID azione deve iniziare con una lettera o underscore, seguito da lettere, numeri o underscore", "nodes.humanInput.userActions.actionIdTooLong": "L'ID azione deve essere di {{maxLength}} caratteri o meno", "nodes.humanInput.userActions.actionNamePlaceholder": "Nome Azione", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Testo del Pulsante da Visualizzare", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Valore dell'azione", "nodes.humanInput.userActions.buttonTextTooLong": "Il testo del pulsante deve essere di {{maxLength}} caratteri o meno", "nodes.humanInput.userActions.chooseStyle": "Scegli uno stile del pulsante", "nodes.humanInput.userActions.emptyTip": "Clicca il pulsante '+' per aggiungere azioni utente", "nodes.humanInput.userActions.title": "Azioni Utente", - "nodes.humanInput.userActions.tooltip": "Definisci i pulsanti su cui gli utenti possono cliccare per rispondere a questo modulo. Ogni pulsante può attivare percorsi di workflow diversi. L'ID azione deve iniziare con una lettera o underscore, seguito da lettere, numeri o underscore.", + "nodes.humanInput.userActions.tooltip": "Definisci i pulsanti su cui gli utenti possono cliccare per rispondere a questo modulo. L'ID azione controlla la ramificazione. Il Valore dell'azione viene esposto a valle come output integrato selezionato. L'ID azione deve iniziare con una lettera o underscore, seguito da lettere, numeri o underscore.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> è stato attivato", "nodes.ifElse.addCondition": "Aggiungi Condizione", "nodes.ifElse.addSubVariable": "Variabile secondaria", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index f00d49f6bf..c7db4c465a 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "アクションIDは文字またはアンダースコアで始まり、その後に文字、数字、またはアンダースコアが続く必要があります", "nodes.humanInput.userActions.actionIdTooLong": "アクションIDは{{maxLength}}文字以下である必要があります", "nodes.humanInput.userActions.actionNamePlaceholder": "アクション名", - "nodes.humanInput.userActions.buttonTextPlaceholder": "ボタン表示テキスト", + "nodes.humanInput.userActions.buttonTextPlaceholder": "アクション値", "nodes.humanInput.userActions.buttonTextTooLong": "ボタンテキストは{{maxLength}}文字以下である必要があります", "nodes.humanInput.userActions.chooseStyle": "ボタンスタイルを選択", "nodes.humanInput.userActions.emptyTip": "'+'ボタンをクリックしてユーザーアクションを追加", "nodes.humanInput.userActions.title": "ユーザーアクション", - "nodes.humanInput.userActions.tooltip": "ユーザーがこのフォームに応答するためにクリックできるボタンを定義します。各ボタンは異なるワークフローパスをトリガーできます。アクションIDは文字またはアンダースコアで始まり、その後に文字、数字、またはアンダースコアが続く必要があります。", + "nodes.humanInput.userActions.tooltip": "ユーザーがこのフォームに応答するためにクリックできるボタンを定義します。アクションIDは分岐を制御します。アクション値は選択された組み込み出力として下流に公開されます。アクションIDは文字またはアンダースコアで始まり、その後に文字、数字、またはアンダースコアが続く必要があります。", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong>がトリガーされました", "nodes.ifElse.addCondition": "条件を追加", "nodes.ifElse.addSubVariable": "サブ変数", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index 06225bb1a1..7eb315f1d4 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "작업 ID는 문자 또는 밑줄로 시작하고 그 뒤에 문자, 숫자 또는 밑줄이 와야 합니다", "nodes.humanInput.userActions.actionIdTooLong": "작업 ID는 {{maxLength}}자 이하여야 합니다", "nodes.humanInput.userActions.actionNamePlaceholder": "작업 이름", - "nodes.humanInput.userActions.buttonTextPlaceholder": "버튼 표시 텍스트", + "nodes.humanInput.userActions.buttonTextPlaceholder": "액션 값", "nodes.humanInput.userActions.buttonTextTooLong": "버튼 텍스트는 {{maxLength}}자 이하여야 합니다", "nodes.humanInput.userActions.chooseStyle": "버튼 스타일 선택", "nodes.humanInput.userActions.emptyTip": "'+' 버튼을 클릭하여 사용자 작업 추가", "nodes.humanInput.userActions.title": "사용자 작업", - "nodes.humanInput.userActions.tooltip": "사용자가 이 양식에 응답하기 위해 클릭할 수 있는 버튼을 정의합니다. 각 버튼은 다른 워크플로 경로를 트리거할 수 있습니다. 작업 ID는 문자 또는 밑줄로 시작하고 그 뒤에 문자, 숫자 또는 밑줄이 와야 합니다.", + "nodes.humanInput.userActions.tooltip": "사용자가 이 양식에 응답하기 위해 클릭할 수 있는 버튼을 정의합니다. 작업 ID는 분기를 제어합니다. 액션 값은 선택된 내장 출력으로 다운스트림에 노출됩니다. 작업 ID는 문자 또는 밑줄로 시작하고 그 뒤에 문자, 숫자 또는 밑줄이 와야 합니다.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong>이(가) 트리거되었습니다", "nodes.ifElse.addCondition": "조건 추가", "nodes.ifElse.addSubVariable": "하위 변수", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index 86b6b89ea5..a9607b92df 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "Action ID must start with a letter or underscores, followed by letters, numbers, or underscores", "nodes.humanInput.userActions.actionIdTooLong": "Action ID must be {{maxLength}} characters or less", "nodes.humanInput.userActions.actionNamePlaceholder": "Action Name", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Button display Text", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Actiewaarde", "nodes.humanInput.userActions.buttonTextTooLong": "Button text must be {{maxLength}} characters or less", "nodes.humanInput.userActions.chooseStyle": "Choose a button style", "nodes.humanInput.userActions.emptyTip": "Click the '+' button to add user actions", "nodes.humanInput.userActions.title": "User Actions", - "nodes.humanInput.userActions.tooltip": "Define buttons that users can click to respond to this form. Each button can trigger different workflow paths. Action ID must start with a letter or underscores, followed by letters, numbers, or underscores.", + "nodes.humanInput.userActions.tooltip": "Definieer knoppen waarop gebruikers kunnen klikken om op dit formulier te reageren. Actie-ID bepaalt de vertakking. De Actiewaarde wordt stroomafwaarts beschikbaar gesteld als de geselecteerde ingebouwde uitvoer. Actie-ID moet beginnen met een letter of onderstrepingsteken, gevolgd door letters, cijfers of onderstrepingstekens.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> has been triggered", "nodes.ifElse.addCondition": "Add Condition", "nodes.ifElse.addSubVariable": "Sub Variable", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 1d4ac0ec8a..d8118a1bc5 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "Identyfikator akcji musi zaczynać się od litery lub podkreślenia, po którym następują litery, cyfry lub podkreślenia", "nodes.humanInput.userActions.actionIdTooLong": "Identyfikator akcji musi mieć {{maxLength}} znaków lub mniej", "nodes.humanInput.userActions.actionNamePlaceholder": "Nazwa akcji", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Tekst wyświetlany na przycisku", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Wartość akcji", "nodes.humanInput.userActions.buttonTextTooLong": "Tekst przycisku musi mieć {{maxLength}} znaków lub mniej", "nodes.humanInput.userActions.chooseStyle": "Wybierz styl przycisku", "nodes.humanInput.userActions.emptyTip": "Kliknij przycisk '+', aby dodać akcje użytkownika", "nodes.humanInput.userActions.title": "Akcje użytkownika", - "nodes.humanInput.userActions.tooltip": "Zdefiniuj przyciski, które użytkownicy mogą klikać, aby odpowiedzieć na ten formularz. Każdy przycisk może uruchomić różne ścieżki przepływu pracy. Identyfikator akcji musi zaczynać się od litery lub podkreślenia, po którym następują litery, cyfry lub podkreślenia.", + "nodes.humanInput.userActions.tooltip": "Zdefiniuj przyciski, które użytkownicy mogą klikać, aby odpowiedzieć na ten formularz. Identyfikator akcji kontroluje rozgałęzianie. Wartość akcji jest udostępniana w dalszej części jako wybrane wbudowane wyjście. Identyfikator akcji musi zaczynać się od litery lub podkreślenia, po którym następują litery, cyfry lub podkreślenia.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> został uruchomiony", "nodes.ifElse.addCondition": "Dodaj warunek", "nodes.ifElse.addSubVariable": "Zmienna podrzędna", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index 77209ffaf1..f303905759 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "O ID da ação deve começar com uma letra ou sublinhados, seguido de letras, números ou sublinhados", "nodes.humanInput.userActions.actionIdTooLong": "O ID da ação deve ter {{maxLength}} caracteres ou menos", "nodes.humanInput.userActions.actionNamePlaceholder": "Nome da Ação", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Texto de exibição do botão", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Valor da Ação", "nodes.humanInput.userActions.buttonTextTooLong": "O texto do botão deve ter {{maxLength}} caracteres ou menos", "nodes.humanInput.userActions.chooseStyle": "Escolha um estilo de botão", "nodes.humanInput.userActions.emptyTip": "Clique no botão '+' para adicionar ações do usuário", "nodes.humanInput.userActions.title": "Ações do Usuário", - "nodes.humanInput.userActions.tooltip": "Defina botões em que os usuários podem clicar para responder a este formulário. Cada botão pode acionar diferentes caminhos de fluxo de trabalho. O ID da ação deve começar com uma letra ou sublinhados, seguido de letras, números ou sublinhados.", + "nodes.humanInput.userActions.tooltip": "Defina botões em que os usuários podem clicar para responder a este formulário. O ID da ação controla a ramificação. O Valor da Ação é exposto posteriormente como a saída integrada selecionada. O ID da ação deve começar com uma letra ou sublinhados, seguido de letras, números ou sublinhados.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> foi acionado", "nodes.ifElse.addCondition": "Adicionar condição", "nodes.ifElse.addSubVariable": "Subvariável", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index 2151236135..aad60382da 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "ID-ul acțiunii trebuie să înceapă cu o literă sau underscore, urmat de litere, cifre sau underscore", "nodes.humanInput.userActions.actionIdTooLong": "ID-ul acțiunii trebuie să fie de {{maxLength}} caractere sau mai puțin", "nodes.humanInput.userActions.actionNamePlaceholder": "Nume acțiune", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Text afișat pe buton", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Valoarea Acțiunii", "nodes.humanInput.userActions.buttonTextTooLong": "Textul butonului trebuie să fie de {{maxLength}} caractere sau mai puțin", "nodes.humanInput.userActions.chooseStyle": "Alegeți un stil de buton", "nodes.humanInput.userActions.emptyTip": "Faceți clic pe butonul '+' pentru a adăuga acțiuni utilizator", "nodes.humanInput.userActions.title": "Acțiuni utilizator", - "nodes.humanInput.userActions.tooltip": "Definiți butoane pe care utilizatorii le pot apăsa pentru a răspunde la acest formular. Fiecare buton poate declanșa căi de flux diferite. ID-ul acțiunii trebuie să înceapă cu o literă sau underscore, urmat de litere, cifre sau underscore.", + "nodes.humanInput.userActions.tooltip": "Definiți butoane pe care utilizatorii le pot apăsa pentru a răspunde la acest formular. ID-ul acțiunii controlează ramificarea. Valoarea Acțiunii este expusă în aval ca ieșirea integrată selectată. ID-ul acțiunii trebuie să înceapă cu o literă sau underscore, urmat de litere, cifre sau underscore.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> a fost declanșat", "nodes.ifElse.addCondition": "Adăugați condiție", "nodes.ifElse.addSubVariable": "Subvariabilă", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index 195f8ef988..8748f2a27d 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "Идентификатор действия должен начинаться с буквы или подчеркивания, за которым следуют буквы, цифры или подчеркивания", "nodes.humanInput.userActions.actionIdTooLong": "Идентификатор действия должен содержать не более {{maxLength}} символов", "nodes.humanInput.userActions.actionNamePlaceholder": "Название действия", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Текст кнопки для отображения", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Значение действия", "nodes.humanInput.userActions.buttonTextTooLong": "Текст кнопки должен содержать не более {{maxLength}} символов", "nodes.humanInput.userActions.chooseStyle": "Выберите стиль кнопки", "nodes.humanInput.userActions.emptyTip": "Нажмите кнопку '+' для добавления действий пользователя", "nodes.humanInput.userActions.title": "Действия пользователя", - "nodes.humanInput.userActions.tooltip": "Определите кнопки, на которые пользователи могут нажимать, чтобы ответить на эту форму. Каждая кнопка может запускать разные пути рабочего процесса. Идентификатор действия должен начинаться с буквы или подчеркивания, за которым следуют буквы, цифры или подчеркивания.", + "nodes.humanInput.userActions.tooltip": "Определите кнопки, на которые пользователи могут нажимать, чтобы ответить на эту форму. Идентификатор действия управляет ветвлением. Значение действия передаётся далее как выбранный встроенный вывод. Идентификатор действия должен начинаться с буквы или подчеркивания, за которым следуют буквы, цифры или подчеркивания.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> был запущен", "nodes.ifElse.addCondition": "Добавить условие", "nodes.ifElse.addSubVariable": "Подпеременная", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index 3c3f4667f6..af8d236bf0 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "ID dejanja se mora začeti s črko ali podčrtajem, ki mu sledijo črke, številke ali podčrtaji", "nodes.humanInput.userActions.actionIdTooLong": "ID dejanja mora biti {{maxLength}} znakov ali manj", "nodes.humanInput.userActions.actionNamePlaceholder": "Ime dejanja", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Besedilo za prikaz na gumbu", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Vrednost dejanja", "nodes.humanInput.userActions.buttonTextTooLong": "Besedilo gumba mora biti {{maxLength}} znakov ali manj", "nodes.humanInput.userActions.chooseStyle": "Izberite slog gumba", "nodes.humanInput.userActions.emptyTip": "Kliknite gumb '+' za dodajanje uporabniških dejanj", "nodes.humanInput.userActions.title": "Uporabniška dejanja", - "nodes.humanInput.userActions.tooltip": "Definirajte gumbe, ki jih lahko uporabniki kliknejo, da se odzovejo na ta obrazec. Vsak gumb lahko sproži različne poti delovnega toka. ID dejanja se mora začeti s črko ali podčrtajem, ki mu sledijo črke, številke ali podčrtaji.", + "nodes.humanInput.userActions.tooltip": "Definirajte gumbe, ki jih lahko uporabniki kliknejo, da se odzovejo na ta obrazec. ID dejanja nadzoruje razvejanje. Vrednost dejanja je izpostavljena navzdol kot izbrani vgrajeni izhod. ID dejanja se mora začeti s črko ali podčrtajem, ki mu sledijo črke, številke ali podčrtaji.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> je bil sprožen", "nodes.ifElse.addCondition": "Dodaj pogoj", "nodes.ifElse.addSubVariable": "Podspremenljivka", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index ecf8a1277f..b08d9fde0e 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "ID การดำเนินการต้องเริ่มต้นด้วยตัวอักษรหรือขีดล่าง ตามด้วยตัวอักษร ตัวเลข หรือขีดล่าง", "nodes.humanInput.userActions.actionIdTooLong": "ID การดำเนินการต้องมีความยาว {{maxLength}} ตัวอักษรหรือน้อยกว่า", "nodes.humanInput.userActions.actionNamePlaceholder": "ชื่อการดำเนินการ", - "nodes.humanInput.userActions.buttonTextPlaceholder": "ข้อความแสดงบนปุ่ม", + "nodes.humanInput.userActions.buttonTextPlaceholder": "ค่าการดำเนินการ", "nodes.humanInput.userActions.buttonTextTooLong": "ข้อความบนปุ่มต้องมีความยาว {{maxLength}} ตัวอักษรหรือน้อยกว่า", "nodes.humanInput.userActions.chooseStyle": "เลือกสไตล์ปุ่ม", "nodes.humanInput.userActions.emptyTip": "คลิกปุ่ม '+' เพื่อเพิ่มการดำเนินการของผู้ใช้", "nodes.humanInput.userActions.title": "การดำเนินการของผู้ใช้", - "nodes.humanInput.userActions.tooltip": "กำหนดปุ่มที่ผู้ใช้สามารถคลิกเพื่อตอบสนองต่อแบบฟอร์มนี้ แต่ละปุ่มสามารถเรียกใช้เส้นทางเวิร์กโฟลว์ที่แตกต่างกัน ID การดำเนินการต้องเริ่มต้นด้วยตัวอักษรหรือขีดล่าง ตามด้วยตัวอักษร ตัวเลข หรือขีดล่าง", + "nodes.humanInput.userActions.tooltip": "กำหนดปุ่มที่ผู้ใช้สามารถคลิกเพื่อตอบสนองต่อแบบฟอร์มนี้ ID การดำเนินการควบคุมการแยกสาขา ค่าการดำเนินการถูกเปิดเผยในระดับถัดไปเป็นเอาต์พุตในตัวที่เลือก ID การดำเนินการต้องเริ่มต้นด้วยตัวอักษรหรือขีดล่าง ตามด้วยตัวอักษร ตัวเลข หรือขีดล่าง", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> ถูกเรียกใช้แล้ว", "nodes.ifElse.addCondition": "เพิ่มเงื่อนไข", "nodes.ifElse.addSubVariable": "ตัวแปรย่อย", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index 018c5a6077..c9a6a77168 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "Eylem kimliği bir harf veya alt çizgi ile başlamalı, ardından harf, rakam veya alt çizgi gelmelidir", "nodes.humanInput.userActions.actionIdTooLong": "Eylem kimliği {{maxLength}} karakter veya daha az olmalıdır", "nodes.humanInput.userActions.actionNamePlaceholder": "Eylem Adı", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Düğme Görüntüleme Metni", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Eylem Değeri", "nodes.humanInput.userActions.buttonTextTooLong": "Düğme metni {{maxLength}} karakter veya daha az olmalıdır", "nodes.humanInput.userActions.chooseStyle": "Bir düğme stili seçin", "nodes.humanInput.userActions.emptyTip": "Kullanıcı eylemleri eklemek için '+' düğmesine tıklayın", "nodes.humanInput.userActions.title": "Kullanıcı Eylemleri", - "nodes.humanInput.userActions.tooltip": "Kullanıcıların bu forma yanıt vermek için tıklayabileceği düğmeleri tanımlayın. Her düğme farklı iş akışı yollarını tetikleyebilir. Eylem kimliği bir harf veya alt çizgi ile başlamalı, ardından harf, rakam veya alt çizgi gelmelidir.", + "nodes.humanInput.userActions.tooltip": "Kullanıcıların bu forma yanıt vermek için tıklayabileceği düğmeleri tanımlayın. Eylem kimliği dallanmayı kontrol eder. Eylem Değeri, seçilen yerleşik çıktı olarak aşağı akışta sunulur. Eylem kimliği bir harf veya alt çizgi ile başlamalı, ardından harf, rakam veya alt çizgi gelmelidir.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> tetiklendi", "nodes.ifElse.addCondition": "Koşul Ekle", "nodes.ifElse.addSubVariable": "Alt Değişken", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index a7687edcc8..6fe9e4672f 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "Ідентифікатор дії повинен починатися з літери або підкреслення, за яким слідують літери, цифри або підкреслення", "nodes.humanInput.userActions.actionIdTooLong": "Ідентифікатор дії повинен містити не більше {{maxLength}} символів", "nodes.humanInput.userActions.actionNamePlaceholder": "Назва дії", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Текст кнопки для відображення", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Значення дії", "nodes.humanInput.userActions.buttonTextTooLong": "Текст кнопки повинен містити не більше {{maxLength}} символів", "nodes.humanInput.userActions.chooseStyle": "Виберіть стиль кнопки", "nodes.humanInput.userActions.emptyTip": "Натисніть кнопку '+' для додавання дій користувача", "nodes.humanInput.userActions.title": "Дії користувача", - "nodes.humanInput.userActions.tooltip": "Визначте кнопки, на які користувачі можуть натискати, щоб відповісти на цю форму. Кожна кнопка може запускати різні шляхи робочого процесу. Ідентифікатор дії повинен починатися з літери або підкреслення, за яким слідують літери, цифри або підкреслення.", + "nodes.humanInput.userActions.tooltip": "Визначте кнопки, на які користувачі можуть натискати, щоб відповісти на цю форму. Ідентифікатор дії контролює розгалуження. Значення дії відкривається нижче за потоком як вибраний вбудований вивід. Ідентифікатор дії повинен починатися з літери або підкреслення, за яким слідують літери, цифри або підкреслення.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> було запущено", "nodes.ifElse.addCondition": "Додати умову", "nodes.ifElse.addSubVariable": "Підзмінна", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index db9d484b23..bbaccec6c6 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "ID hành động phải bắt đầu bằng chữ cái hoặc dấu gạch dưới, theo sau là chữ cái, số hoặc dấu gạch dưới", "nodes.humanInput.userActions.actionIdTooLong": "ID hành động phải có {{maxLength}} ký tự trở xuống", "nodes.humanInput.userActions.actionNamePlaceholder": "Tên Hành động", - "nodes.humanInput.userActions.buttonTextPlaceholder": "Văn bản Hiển thị Nút", + "nodes.humanInput.userActions.buttonTextPlaceholder": "Giá trị Hành động", "nodes.humanInput.userActions.buttonTextTooLong": "Văn bản nút phải có {{maxLength}} ký tự trở xuống", "nodes.humanInput.userActions.chooseStyle": "Chọn kiểu nút", "nodes.humanInput.userActions.emptyTip": "Nhấp vào nút '+' để thêm hành động người dùng", "nodes.humanInput.userActions.title": "Hành động Người dùng", - "nodes.humanInput.userActions.tooltip": "Xác định các nút mà người dùng có thể nhấp để phản hồi biểu mẫu này. Mỗi nút có thể kích hoạt các đường dẫn quy trình làm việc khác nhau. ID hành động phải bắt đầu bằng chữ cái hoặc dấu gạch dưới, theo sau là chữ cái, số hoặc dấu gạch dưới.", + "nodes.humanInput.userActions.tooltip": "Xác định các nút mà người dùng có thể nhấp để phản hồi biểu mẫu này. ID hành động kiểm soát việc phân nhánh. Giá trị Hành động được hiển thị ở phía sau như là đầu ra tích hợp được chọn. ID hành động phải bắt đầu bằng chữ cái hoặc dấu gạch dưới, theo sau là chữ cái, số hoặc dấu gạch dưới.", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong> đã được kích hoạt", "nodes.ifElse.addCondition": "Thêm điều kiện", "nodes.ifElse.addSubVariable": "Biến phụ", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 6536f5ccdc..951d60bfd8 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "操作 ID 必须以字母或下划线开头,后跟字母、数字或下划线", "nodes.humanInput.userActions.actionIdTooLong": "操作 ID 不能超过 {{maxLength}} 个字符", "nodes.humanInput.userActions.actionNamePlaceholder": "操作名称", - "nodes.humanInput.userActions.buttonTextPlaceholder": "按钮显示文本", + "nodes.humanInput.userActions.buttonTextPlaceholder": "操作值", "nodes.humanInput.userActions.buttonTextTooLong": "按钮文本不能超过 {{maxLength}} 个字符", "nodes.humanInput.userActions.chooseStyle": "选择按钮样式", "nodes.humanInput.userActions.emptyTip": "点击 '+' 按钮添加用户操作", "nodes.humanInput.userActions.title": "用户操作", - "nodes.humanInput.userActions.tooltip": "定义用户可以点击以响应此表单的按钮。每个按钮都可以触发不同的工作流路径。操作 ID 必须以字母或下划线开头,后跟字母、数字或下划线。", + "nodes.humanInput.userActions.tooltip": "定义用户可以点击以响应此表单的按钮。操作 ID 控制分支。操作值作为所选内置输出在下游公开。操作 ID 必须以字母或下划线开头,后跟字母、数字或下划线。", "nodes.humanInput.userActions.triggered": "已触发<strong>{{actionName}}</strong>", "nodes.ifElse.addCondition": "添加条件", "nodes.ifElse.addSubVariable": "添加子变量", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 9544a4ba2e..bd7e1b20d1 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -646,12 +646,12 @@ "nodes.humanInput.userActions.actionIdFormatTip": "操作 ID 必須以字母或下劃線開頭,後跟字母、數字或下劃線", "nodes.humanInput.userActions.actionIdTooLong": "操作 ID 不能超過{{maxLength}}個字符", "nodes.humanInput.userActions.actionNamePlaceholder": "操作名稱", - "nodes.humanInput.userActions.buttonTextPlaceholder": "按鈕顯示文本", + "nodes.humanInput.userActions.buttonTextPlaceholder": "操作值", "nodes.humanInput.userActions.buttonTextTooLong": "按鈕文本不能超過{{maxLength}}個字符", "nodes.humanInput.userActions.chooseStyle": "選擇按鈕樣式", "nodes.humanInput.userActions.emptyTip": "點擊 '+' 按鈕添加用戶操作", "nodes.humanInput.userActions.title": "用戶操作", - "nodes.humanInput.userActions.tooltip": "定義用戶可以點擊以響應此表單的按鈕。每個按鈕可以觸發不同的工作流路徑。操作 ID 必須以字母或下劃線開頭,後跟字母、數字或下劃線。", + "nodes.humanInput.userActions.tooltip": "定義用戶可以點擊以響應此表單的按鈕。操作 ID 控制分支。操作值作為所選內置輸出在下游公開。操作 ID 必須以字母或下劃線開頭,後跟字母、數字或下劃線。", "nodes.humanInput.userActions.triggered": "<strong>{{actionName}}</strong>已被觸發", "nodes.ifElse.addCondition": "新增條件", "nodes.ifElse.addSubVariable": "子變數", From 2162ea6a68caa68f2f603065c1e2aff2b646f7b7 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 11 May 2026 12:22:46 +0800 Subject: [PATCH 34/53] fix: improve workflow checklist semantics (#36006) --- .../header/checklist/__tests__/index.spec.tsx | 2 + .../checklist/__tests__/node-group.spec.tsx | 3 + .../workflow/header/checklist/index.tsx | 22 +++++-- .../workflow/header/checklist/node-group.tsx | 65 ++++++++++++------- 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx index 25150e2d04..934dcc490f 100644 --- a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx @@ -54,6 +54,8 @@ vi.mock('@langgenius/dify-ui/popover', () => ({ }, PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>, PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, + PopoverTitle: ({ children, className }: { children: ReactNode, className?: string }) => <h2 className={className}>{children}</h2>, + PopoverDescription: ({ children, className }: { children: ReactNode, className?: string }) => <p className={className}>{children}</p>, PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>, })) diff --git a/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx index d574dda0ac..a84ddd06af 100644 --- a/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx @@ -36,6 +36,7 @@ describe('ChecklistNodeGroup', () => { expect(screen.getByText('Needs configuration')).toBeInTheDocument() expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument() expect(screen.getAllByText(/goToFix/i)).toHaveLength(2) + expect(screen.getByRole('button', { name: /Needs configuration/i })).toHaveAttribute('title', 'Needs configuration') fireEvent.click(screen.getByText('Needs configuration')) @@ -57,5 +58,7 @@ describe('ChecklistNodeGroup', () => { expect(onItemClick).not.toHaveBeenCalled() expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Needs configuration/i })).not.toBeInTheDocument() + expect(screen.getByText('Needs configuration').parentElement).toHaveAttribute('title', 'Needs configuration') }) }) diff --git a/web/app/components/workflow/header/checklist/index.tsx b/web/app/components/workflow/header/checklist/index.tsx index 9a54175c8c..1e4303d401 100644 --- a/web/app/components/workflow/header/checklist/index.tsx +++ b/web/app/components/workflow/header/checklist/index.tsx @@ -7,6 +7,8 @@ import { Popover, PopoverClose, PopoverContent, + PopoverDescription, + PopoverTitle, PopoverTrigger, } from '@langgenius/dify-ui/popover' import { @@ -43,6 +45,7 @@ const WorkflowChecklist = ({ const nodes = useNodes() const needWarningNodes = useChecklist(nodes, edges) const { handleNodeSelect } = useNodesInteractions() + const checklistLabel = t('panel.checklist', { ns: 'workflow' }) const { pluginItems, nodeItems } = useMemo(() => { const plugins: ChecklistItem[] = [] @@ -75,12 +78,14 @@ const WorkflowChecklist = ({ disabled && 'cursor-not-allowed opacity-50', )} disabled={disabled || undefined} + aria-label={checklistLabel} > <span className={cn('group flex h-full w-full items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')} > <span className={cn('i-ri-list-check-3 h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} + aria-hidden="true" /> </span> {!!needWarningNodes.length && ( @@ -104,19 +109,22 @@ const WorkflowChecklist = ({ <div className="flex flex-col gap-0.5 px-3 pt-3.5 pb-1"> <div className="flex items-start px-1"> <div className="min-w-0 grow pr-8"> - <h2 className="text-base leading-6 font-semibold text-text-primary"> - {t('panel.checklist', { ns: 'workflow' })} + <PopoverTitle className="text-base leading-6 font-semibold text-text-primary"> + {checklistLabel} {needWarningNodes.length > 0 && `(${needWarningNodes.length})`} - </h2> + </PopoverTitle> </div> - <PopoverClose className="-mt-0.5 -mr-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg"> - <span className="i-ri-close-line size-4 text-text-tertiary" /> + <PopoverClose + className="-mt-0.5 -mr-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg" + aria-label={t('operation.close', { ns: 'common' })} + > + <span className="i-ri-close-line size-4 text-text-tertiary" aria-hidden="true" /> </PopoverClose> </div> {needWarningNodes.length > 0 && ( - <p className="px-1 text-xs leading-4 text-text-tertiary"> + <PopoverDescription className="px-1 text-xs leading-4 text-text-tertiary"> {t('panel.checklistDescription', { ns: 'workflow' })} - </p> + </PopoverDescription> )} </div> diff --git a/web/app/components/workflow/header/checklist/node-group.tsx b/web/app/components/workflow/header/checklist/node-group.tsx index a161c6cb87..05987b632e 100644 --- a/web/app/components/workflow/header/checklist/node-group.tsx +++ b/web/app/components/workflow/header/checklist/node-group.tsx @@ -45,29 +45,48 @@ export const ChecklistNodeGroup = memo(({ </span> </div> <div className="p-1"> - {subItems.map(sub => ( - <div - key={sub.key} - className={cn( - 'group/item flex items-start gap-2 rounded-lg px-1', - goToEnabled && 'cursor-pointer hover:bg-state-base-hover', - )} - onClick={() => goToEnabled && onItemClick(item)} - > - <ItemIndicator /> - <span className="min-w-0 grow py-1 text-xs leading-4 text-text-warning"> - {sub.message} - </span> - {goToEnabled && ( - <div className="flex shrink-0 items-center gap-0.5 pt-1 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100"> - <span className="text-xs leading-4 font-medium whitespace-nowrap text-text-accent"> - {t('panel.goToFix', { ns: 'workflow' })} - </span> - <span className="i-ri-arrow-right-line size-3.5 text-text-accent" /> - </div> - )} - </div> - ))} + {subItems.map((sub) => { + const content = ( + <> + <ItemIndicator /> + <span className="min-w-0 grow truncate text-xs leading-4 text-text-warning"> + {sub.message} + </span> + {goToEnabled && ( + <div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100"> + <span className="text-xs leading-4 font-medium whitespace-nowrap text-text-accent"> + {t('panel.goToFix', { ns: 'workflow' })} + </span> + <span className="i-ri-arrow-right-line size-3.5 text-text-accent" aria-hidden="true" /> + </div> + )} + </> + ) + const className = cn( + 'group/item flex w-full items-center gap-2 rounded-lg px-1 text-left', + goToEnabled && 'cursor-pointer hover:bg-state-base-hover', + ) + + if (goToEnabled) { + return ( + <button + key={sub.key} + type="button" + className={cn(className, 'border-none bg-transparent')} + title={sub.message} + onClick={() => onItemClick(item)} + > + {content} + </button> + ) + } + + return ( + <div key={sub.key} className={className} title={sub.message}> + {content} + </div> + ) + })} </div> </div> ) From bd0d10ac5c00275ce705986e0aac0be1cb9cb557 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 11 May 2026 12:23:04 +0800 Subject: [PATCH 35/53] fix: use infotip for help glyphs (#36008) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 5 -- .../config-prompt/advanced-prompt-input.tsx | 25 ++----- .../config-prompt/simple-prompt-input.tsx | 25 ++----- .../__tests__/modal.spec.tsx | 4 +- .../conversation-opener/modal.tsx | 28 +++---- .../new-feature-panel/feature-card.tsx | 21 ++---- web/app/components/base/infotip/index.tsx | 7 +- .../components/general-chunking-options.tsx | 21 ++---- .../index-method/__tests__/index.spec.tsx | 4 +- .../__tests__/keyword-number.spec.tsx | 9 ++- .../settings/index-method/keyword-number.tsx | 18 ++--- .../model-load-balancing-configs.tsx | 21 +++--- .../multiple-tool-selector/index.tsx | 27 +++---- .../create/__tests__/index.spec.tsx | 22 +++--- .../subscription-list/create/index.tsx | 20 ++--- .../subscription-list/list-view.tsx | 21 ++---- .../subscription-list/selector-view.tsx | 21 ++---- .../components/tools/workflow-tool/index.tsx | 23 +++--- .../layout/__tests__/field-title.spec.tsx | 8 +- .../_base/components/layout/field-title.tsx | 17 +---- .../components/index-method.tsx | 17 ++--- .../json-schema-generator/prompt-editor.tsx | 21 ++---- .../components/monthly-days-selector.tsx | 75 ++++++++++--------- 23 files changed, 193 insertions(+), 267 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a7512f8c66..97d2f93a59 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2527,11 +2527,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { "ts/no-explicit-any": { "count": 2 diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index 5fd394ad45..418982fcb6 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -5,11 +5,6 @@ import type { PromptRole, PromptVariable } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' import { RiDeleteBinLine, RiErrorWarningFill, @@ -25,6 +20,7 @@ import { Copy, CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' +import { Infotip } from '@/app/components/base/infotip' import PromptEditor from '@/app/components/base/prompt-editor' import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' import ConfigContext from '@/context/debug-configuration' @@ -183,18 +179,13 @@ const AdvancedPromptInput: FC<Props> = ({ <div className="text-sm font-semibold text-indigo-800 uppercase"> {t('pageTitle.line1', { ns: 'appDebug' })} </div> - <Tooltip> - <TooltipTrigger - render={( - <span className="ml-1 i-ri-question-line h-4 w-4 shrink-0 text-text-quaternary" /> - )} - /> - <TooltipContent> - <div className="w-[180px]"> - {t('promptTip', { ns: 'appDebug' })} - </div> - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('promptTip', { ns: 'appDebug' })} + className="ml-1" + popupClassName="w-[180px]" + > + {t('promptTip', { ns: 'appDebug' })} + </Infotip> </div> )} <div className={cn(s.optionWrap, 'items-center space-x-1')}> diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index 2935504f15..63cb16083e 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -5,11 +5,6 @@ import type { PromptVariable } from '@/models/debug' import type { GenRes } from '@/service/debug' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/function' import { produce } from 'immer' @@ -21,6 +16,7 @@ import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/confi import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn' import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res' import { useFeaturesStore } from '@/app/components/base/features/hooks' +import { Infotip } from '@/app/components/base/infotip' import PromptEditor from '@/app/components/base/prompt-editor' import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block' import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' @@ -183,18 +179,13 @@ const Prompt: FC<ISimplePromptInput> = ({ <div className="flex items-center space-x-1"> <div className="system-sm-semibold-uppercase text-text-secondary">{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}</div> {!readonly && ( - <Tooltip> - <TooltipTrigger - render={( - <span className="ml-1 i-ri-question-line h-4 w-4 shrink-0 text-text-quaternary" /> - )} - /> - <TooltipContent> - <div className="w-[180px]"> - {t('promptTip', { ns: 'appDebug' })} - </div> - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('promptTip', { ns: 'appDebug' })} + className="ml-1" + popupClassName="w-[180px]" + > + {t('promptTip', { ns: 'appDebug' })} + </Infotip> )} </div> <div className="flex items-center"> diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx index 56551be922..e41079e66a 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx @@ -369,7 +369,7 @@ describe('OpeningSettingModal', () => { expect(screen.getByTestId('opener-input-section')).toBeInTheDocument() expect(screen.getByTestId('opener-questions-section')).toBeInTheDocument() expect(screen.getByText(/openingStatement\.editorTitle/)).toBeInTheDocument() - expect(screen.getByTestId('opening-questions-tooltip')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /openingStatement\.openingQuestionDescription/ })).toBeInTheDocument() expect(screen.queryByText(/openingStatement\.openingQuestionDescription/)).not.toBeInTheDocument() }) @@ -383,7 +383,7 @@ describe('OpeningSettingModal', () => { ) act(() => { - fireEvent.mouseEnter(screen.getByTestId('opening-questions-tooltip')) + fireEvent.mouseEnter(screen.getByRole('button', { name: /openingStatement\.openingQuestionDescription/ })) }) expect(screen.getByText(/openingStatement\.openingQuestionDescription/)).toBeInTheDocument() diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index 78798d27cf..fb2f341e64 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -4,7 +4,6 @@ import type { PromptVariable } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' import { produce } from 'immer' import * as React from 'react' @@ -14,6 +13,7 @@ import { ReactSortable } from 'react-sortablejs' import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var' import { getInputKeys } from '@/app/components/base/block-input' import Divider from '@/app/components/base/divider' +import { Infotip } from '@/app/components/base/infotip' import PromptEditor from '@/app/components/base/prompt-editor' import { checkKeys, getNewVar } from '@/utils/var' @@ -117,24 +117,14 @@ const OpeningSettingModal = ({ <div className="text-sm font-medium text-text-primary"> {t('openingStatement.openingQuestion', { ns: 'appDebug' })} </div> - <Tooltip> - <TooltipTrigger - delay={0} - render={( - <button - type="button" - className="flex items-center rounded-sm p-px text-text-quaternary hover:text-text-tertiary" - data-testid="opening-questions-tooltip" - aria-label={t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })} - > - <span className="i-ri-question-line h-3.5 w-3.5" /> - </button> - )} - /> - <TooltipContent className="max-w-[220px] system-sm-regular text-text-secondary"> - {t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })} + className="h-3.5 w-3.5" + popupClassName="max-w-[220px] system-sm-regular text-text-secondary" + delay={0} + > + {t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })} + </Infotip> </div> <div className="text-xs leading-[18px] font-medium text-text-tertiary"> {tempSuggestedQuestions.length} diff --git a/web/app/components/base/features/new-feature-panel/feature-card.tsx b/web/app/components/base/features/new-feature-panel/feature-card.tsx index 0c25a514fe..953487352c 100644 --- a/web/app/components/base/features/new-feature-panel/feature-card.tsx +++ b/web/app/components/base/features/new-feature-panel/feature-card.tsx @@ -1,9 +1,6 @@ import { Switch } from '@langgenius/dify-ui/switch' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiQuestionLine, -} from '@remixicon/react' import * as React from 'react' +import { Infotip } from '@/app/components/base/infotip' type Props = { icon: any @@ -41,16 +38,12 @@ const FeatureCard = ({ <div className="flex grow items-center system-sm-semibold text-text-secondary"> {title} {tooltip && ( - <Tooltip> - <TooltipTrigger - render={( - <div className="ml-0.5 p-px"><RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary" /></div> - )} - /> - <TooltipContent> - {tooltip} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={typeof tooltip === 'string' ? tooltip : String(title)} + className="ml-0.5 h-3.5 w-3.5" + > + {tooltip} + </Infotip> )} </div> <Switch disabled={disabled} className="shrink-0" onCheckedChange={state => onChange?.(state)} checked={value} /> diff --git a/web/app/components/base/infotip/index.tsx b/web/app/components/base/infotip/index.tsx index 0927e6ac27..94c35a101d 100644 --- a/web/app/components/base/infotip/index.tsx +++ b/web/app/components/base/infotip/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { Placement } from '@langgenius/dify-ui/popover' -import type { ReactNode } from 'react' +import type { MouseEvent, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' @@ -58,6 +58,10 @@ export function Infotip({ delay = 300, closeDelay = 200, }: InfotipProps) { + const handleClick = (event: MouseEvent<HTMLButtonElement>) => { + event.stopPropagation() + } + return ( <Popover> <PopoverTrigger @@ -65,6 +69,7 @@ export function Infotip({ delay={delay} closeDelay={closeDelay} aria-label={ariaLabel} + onClick={handleClick} className={cn( 'inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden', className, diff --git a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx index f0740e9cf4..92e9136555 100644 --- a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx +++ b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import type { PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiAlertFill, RiSearchEyeLine, @@ -11,6 +10,7 @@ import { import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import Divider from '@/app/components/base/divider' +import { Infotip } from '@/app/components/base/infotip' import SummaryIndexSetting from '@/app/components/datasets/settings/summary-index-setting' import { IS_CE_EDITION } from '@/config' import { ChunkingMode } from '@/models/datasets' @@ -191,18 +191,13 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({ onSelect={onDocLanguageChange} disabled={currentDocForm !== ChunkingMode.qa} /> - <Tooltip> - <TooltipTrigger - render={( - <span className="flex h-3.5 w-3.5 shrink-0 p-px"> - <span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" /> - </span> - )} - /> - <TooltipContent> - {t('stepTwo.QATip', { ns: 'datasetCreation' })} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('stepTwo.QATip', { ns: 'datasetCreation' })} + className="h-3.5 w-3.5" + iconClassName="h-full w-full" + > + {t('stepTwo.QATip', { ns: 'datasetCreation' })} + </Infotip> </div> {currentDocForm === ChunkingMode.qa && ( <div diff --git a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx index 7aa11b4d7a..eb55acfb38 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx @@ -14,7 +14,9 @@ describe('IndexMethod', () => { vi.clearAllMocks() }) - const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords') + const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render without crashing', () => { diff --git a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx index cd0d56bbeb..92516f00a4 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx @@ -11,7 +11,9 @@ describe('KeyWordNumber', () => { vi.clearAllMocks() }) - const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords') + const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render without crashing', () => { @@ -24,9 +26,10 @@ describe('KeyWordNumber', () => { expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument() }) - it('should render tooltip with question icon', () => { + it('should render infotip with question icon', () => { render(<KeyWordNumber {...defaultProps} />) - const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement + const trigger = screen.getByRole('button', { name: 'datasetSettings.form.numberOfKeywords' }) + const container = trigger.parentElement const questionIcon = container?.querySelector('.i-ri-question-line') expect(questionIcon).toBeInTheDocument() }) diff --git a/web/app/components/datasets/settings/index-method/keyword-number.tsx b/web/app/components/datasets/settings/index-method/keyword-number.tsx index a31ab07160..b6a90155f0 100644 --- a/web/app/components/datasets/settings/index-method/keyword-number.tsx +++ b/web/app/components/datasets/settings/index-method/keyword-number.tsx @@ -7,10 +7,10 @@ import { NumberFieldInput, } from '@langgenius/dify-ui/number-field' import { Slider } from '@langgenius/dify-ui/slider' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' const MIN_KEYWORD_NUMBER = 0 const MAX_KEYWORD_NUMBER = 50 @@ -36,16 +36,12 @@ const KeyWordNumber = ({ <div className="truncate system-xs-medium text-text-secondary"> {t('form.numberOfKeywords', { ns: 'datasetSettings' })} </div> - <Tooltip> - <TooltipTrigger - render={( - <span className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" /> - )} - /> - <TooltipContent> - {t('form.numberOfKeywords', { ns: 'datasetSettings' })} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })} + className="h-3.5 w-3.5" + > + {t('form.numberOfKeywords', { ns: 'datasetSettings' })} + </Infotip> </div> <Slider className="mr-3 w-[206px] shrink-0" diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx index e8b99bbcea..6d87e6af33 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx @@ -15,6 +15,7 @@ import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge/index' import GridMask from '@/app/components/base/grid-mask' +import { Infotip } from '@/app/components/base/infotip' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import s from '@/app/components/custom/style.module.css' import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth' @@ -152,18 +153,14 @@ const ModelLoadBalancingConfigs = ({ <div className="grow"> <div className="flex items-center gap-1 text-sm text-text-primary"> {t('modelProvider.loadBalancing', { ns: 'common' })} - <Tooltip> - <TooltipTrigger - render={( - <span className="flex h-3 w-3 shrink-0 p-px"> - <span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" /> - </span> - )} - /> - <TooltipContent className="max-w-[300px]"> - {t('modelProvider.loadBalancingInfo', { ns: 'common' })} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('modelProvider.loadBalancingInfo', { ns: 'common' })} + className="h-3 w-3" + iconClassName="h-full w-full" + popupClassName="max-w-[300px]" + > + {t('modelProvider.loadBalancingInfo', { ns: 'common' })} + </Infotip> </div> <div className="text-xs text-text-tertiary">{t('modelProvider.loadBalancingDescription', { ns: 'common' })}</div> </div> diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx index 7972ea5f89..f2779772b6 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx @@ -2,16 +2,15 @@ import type { Node } from 'reactflow' import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiAddLine, - RiQuestionLine, } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Divider from '@/app/components/base/divider' import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general' +import { Infotip } from '@/app/components/base/infotip' import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector' import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' import { useAllMCPTools } from '@/service/use-tools' @@ -21,7 +20,7 @@ type Props = { value: ToolValue[] label: string required?: boolean - tooltip?: any + tooltip?: React.ReactNode supportCollapse?: boolean scope?: string onChange: (value: ToolValue[]) => void @@ -111,18 +110,16 @@ const MultipleToolSelector = ({ > <div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary">{label}</div> {required && <div className="text-red-500">*</div>} - {tooltip && ( - <Tooltip> - <TooltipTrigger - render={( - <div><RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" /></div> - )} - /> - <TooltipContent> - {tooltip} - </TooltipContent> - </Tooltip> - )} + {tooltip + ? ( + <Infotip + aria-label={typeof tooltip === 'string' ? tooltip : label} + className="h-3.5 w-3.5" + > + {tooltip} + </Infotip> + ) + : null} {supportCollapse && ( <ArrowDownRoundFill className={cn( diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx index 7ceb8607c5..726c6f32dc 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx @@ -257,6 +257,10 @@ const createDefaultProps = (overrides: Partial<Parameters<typeof CreateSubscript ...overrides, }) +const getCreateButton = () => screen.getByRole('button', { + name: /pluginTrigger\.subscription\.(createButton|empty\.button)/, +}) + const setupMocks = (config: { providerInfo?: TriggerProviderApiEntity oauthConfig?: TriggerOAuthConfig @@ -353,7 +357,7 @@ describe('CreateSubscriptionButton', () => { // Assert // Assert - expect(screen.getByRole('button'))!.toBeInTheDocument() + expect(getCreateButton()).toBeInTheDocument() }) it('should render icon button when buttonType is ICON_BUTTON', () => { @@ -387,7 +391,7 @@ describe('CreateSubscriptionButton', () => { // Assert // Assert - expect(screen.getByRole('button'))!.toBeInTheDocument() + expect(getCreateButton()).toBeInTheDocument() }) it('should apply shape prop correctly', () => { @@ -592,7 +596,7 @@ describe('CreateSubscriptionButton', () => { // Assert // Assert - expect(screen.getByRole('button'))!.toHaveTextContent('pluginTrigger.subscription.createButton.apiKey') + expect(getCreateButton()).toHaveTextContent('pluginTrigger.subscription.createButton.apiKey') }) it('should display correct button text for MANUAL method', () => { @@ -610,7 +614,7 @@ describe('CreateSubscriptionButton', () => { // Assert // Assert - expect(screen.getByRole('button'))!.toHaveTextContent('pluginTrigger.subscription.createButton.manual') + expect(getCreateButton()).toHaveTextContent('pluginTrigger.subscription.createButton.manual') }) it('should display default button text when multiple methods are supported', () => { @@ -628,7 +632,7 @@ describe('CreateSubscriptionButton', () => { // Assert // Assert - expect(screen.getByRole('button'))!.toHaveTextContent('pluginTrigger.subscription.empty.button') + expect(getCreateButton()).toHaveTextContent('pluginTrigger.subscription.empty.button') }) }) @@ -780,7 +784,7 @@ describe('CreateSubscriptionButton', () => { // Act render(<CreateSubscriptionButton {...props} />) - const button = screen.getByRole('button') + const button = getCreateButton() fireEvent.click(button) // Assert - modal should not open @@ -830,7 +834,7 @@ describe('CreateSubscriptionButton', () => { // Act render(<CreateSubscriptionButton {...props} />) - const button = screen.getByRole('button') + const button = getCreateButton() fireEvent.click(button) // Assert - modal should open @@ -1328,7 +1332,7 @@ describe('CreateSubscriptionButton', () => { render(<CreateSubscriptionButton {...props} />) // Assert - should not have settings divider - const button = screen.getByRole('button') + const button = getCreateButton() const divider = button.querySelector('.bg-text-primary-on-surface') expect(divider).not.toBeInTheDocument() }) @@ -1447,7 +1451,7 @@ describe('CreateSubscriptionButton', () => { render(<CreateSubscriptionButton {...props} />) // Assert - const button = screen.getByRole('button') + const button = getCreateButton() expect(button)!.toHaveClass('w-full') }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx index 9c9a95a37b..7060b39dcf 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx @@ -9,6 +9,7 @@ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActionButton, ActionButtonState } from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' +import { Infotip } from '@/app/components/base/infotip' import { openOAuthPopup } from '@/hooks/use-oauth' import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers' import { SupportedCreationMethods } from '../../../types' @@ -124,18 +125,13 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU value: SupportedCreationMethods.MANUAL, label: t('subscription.addType.options.manual.description', { ns: 'pluginTrigger' }), extra: ( - <Tooltip> - <TooltipTrigger - render={( - <span className="flex h-3.5 w-3.5 shrink-0 p-px"> - <span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" /> - </span> - )} - /> - <TooltipContent> - {t('subscription.addType.options.manual.tip', { ns: 'pluginTrigger' })} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('subscription.addType.options.manual.tip', { ns: 'pluginTrigger' })} + className="h-3.5 w-3.5" + iconClassName="h-full w-full" + > + {t('subscription.addType.options.manual.tip', { ns: 'pluginTrigger' })} + </Infotip> ), show: supportedMethods.includes(SupportedCreationMethods.MANUAL), }, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx index 66dfabb4db..76c5a02065 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx @@ -1,9 +1,9 @@ 'use client' import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import { CreateSubscriptionButton } from './create' import { CreateButtonType } from './create/types' import SubscriptionCard from './subscription-card' @@ -31,18 +31,13 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({ <span className="system-sm-semibold-uppercase text-text-secondary"> {t('subscription.listNum', { ns: 'pluginTrigger', num: subscriptionCount })} </span> - <Tooltip> - <TooltipTrigger - render={( - <span className="flex h-3.5 w-3.5 shrink-0 p-px"> - <span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" /> - </span> - )} - /> - <TooltipContent> - {t('subscription.list.tip', { ns: 'pluginTrigger' })} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('subscription.list.tip', { ns: 'pluginTrigger' })} + className="h-3.5 w-3.5" + iconClassName="h-full w-full" + > + {t('subscription.list.tip', { ns: 'pluginTrigger' })} + </Infotip> </div> )} <CreateSubscriptionButton diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx index 06b0724247..e5be965a12 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx @@ -1,12 +1,12 @@ 'use client' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' import { cn } from '@langgenius/dify-ui/cn' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCheckLine, RiDeleteBinLine, RiWebhookLine } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' +import { Infotip } from '@/app/components/base/infotip' import { CreateSubscriptionButton } from './create' import { CreateButtonType } from './create/types' import { DeleteConfirm } from './delete-confirm' @@ -34,18 +34,13 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({ <span className="system-sm-semibold-uppercase text-text-secondary"> {t('subscription.listNum', { ns: 'pluginTrigger', num: subscriptionCount })} </span> - <Tooltip> - <TooltipTrigger - render={( - <span className="flex h-3.5 w-3.5 shrink-0 p-px"> - <span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" /> - </span> - )} - /> - <TooltipContent> - {t('subscription.list.tip', { ns: 'pluginTrigger' })} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('subscription.list.tip', { ns: 'pluginTrigger' })} + className="h-3.5 w-3.5" + iconClassName="h-full w-full" + > + {t('subscription.list.tip', { ns: 'pluginTrigger' })} + </Infotip> </div> <CreateSubscriptionButton buttonType={CreateButtonType.ICON_BUTTON} diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index dfa64b1506..2fe23abd48 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Divider from '@/app/components/base/divider' import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import LabelSelector from '@/app/components/tools/labels/selector' @@ -71,20 +72,16 @@ type WorkflowToolDrawerFrameProps = { children: React.ReactNode } -const InfoTooltip = ({ children }: { children: React.ReactNode }) => { +const InfoTooltip = ({ children }: { children: string }) => { return ( - <Tooltip> - <TooltipTrigger - render={( - <span className="i-ri-question-line h-3.5 w-3.5 shrink-0 cursor-help text-text-quaternary hover:text-text-tertiary" /> - )} - /> - <TooltipContent> - <div className="w-[180px]"> - {children} - </div> - </TooltipContent> - </Tooltip> + <Infotip + aria-label={children} + className="ml-1 h-3.5 w-3.5" + iconClassName="h-3.5 w-3.5" + popupClassName="w-[180px]" + > + {children} + </Infotip> ) } diff --git a/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx index c11b5ecfc8..371a68fe49 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx @@ -1,12 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { FieldTitle } from '../field-title' -vi.mock('@langgenius/dify-ui/tooltip', () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>, - TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, -})) - describe('FieldTitle', () => { it('should render title, subtitle, operation, tooltip and warning dot', () => { render( @@ -21,7 +15,7 @@ describe('FieldTitle', () => { expect(screen.getByText('Embedding')).toBeInTheDocument() expect(screen.getByText('subtitle')).toBeInTheDocument() - expect(screen.getByText('Tooltip copy')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Tooltip copy' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'action' })).toBeInTheDocument() expect(document.querySelector('.bg-text-warning-secondary')).not.toBeNull() }) diff --git a/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx b/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx index ce080fb6dd..070b932605 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { memo, useState, } from 'react' +import { Infotip } from '@/app/components/base/infotip' export type FieldTitleProps = { title?: string @@ -62,18 +62,9 @@ export const FieldTitle = memo(({ } { tooltip && ( - <Tooltip> - <TooltipTrigger - render={( - <span className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center"> - <span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" /> - </span> - )} - /> - <TooltipContent> - {tooltip} - </TooltipContent> - </Tooltip> + <Infotip aria-label={tooltip} className="ml-1"> + {tooltip} + </Infotip> ) } </div> diff --git a/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx b/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx index 82ccf265ba..0597d621f6 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx @@ -1,7 +1,5 @@ import { cn } from '@langgenius/dify-ui/cn' import { Slider } from '@langgenius/dify-ui/slider' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { RiQuestionLine } from '@remixicon/react' import { memo, useCallback, @@ -11,6 +9,7 @@ import { Economic, HighQuality, } from '@/app/components/base/icons/src/vender/knowledge' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import { Field } from '@/app/components/workflow/nodes/_base/components/layout' import { @@ -97,14 +96,12 @@ const IndexMethod = ({ <div className="truncate system-xs-medium text-text-secondary"> {t('form.numberOfKeywords', { ns: 'datasetSettings' })} </div> - <Tooltip> - <TooltipTrigger - render={<RiQuestionLine className="ml-0.5 h-3.5 w-3.5 text-text-quaternary" />} - /> - <TooltipContent> - number of keywords - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })} + className="ml-0.5 h-3.5 w-3.5" + > + {t('form.numberOfKeywords', { ns: 'datasetSettings' })} + </Infotip> </div> <Slider disabled={readonly} diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx index 76afba2abe..60311129db 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx @@ -2,11 +2,11 @@ import type { FC } from 'react' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Model } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiSparklingFill } from '@remixicon/react' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import Textarea from '@/app/components/base/textarea' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -80,18 +80,13 @@ const PromptEditor: FC<PromptEditorProps> = ({ <div className="flex flex-col gap-y-1 px-4 py-2"> <div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary"> <span>{t('nodes.llm.jsonSchema.instruction', { ns: 'workflow' })}</span> - <Tooltip> - <TooltipTrigger - render={( - <span className="flex h-3.5 w-3.5 shrink-0 p-px"> - <span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" /> - </span> - )} - /> - <TooltipContent> - {t('nodes.llm.jsonSchema.promptTooltip', { ns: 'workflow' })} - </TooltipContent> - </Tooltip> + <Infotip + aria-label={t('nodes.llm.jsonSchema.promptTooltip', { ns: 'workflow' })} + className="h-3.5 w-3.5" + iconClassName="h-full w-full" + > + {t('nodes.llm.jsonSchema.promptTooltip', { ns: 'workflow' })} + </Infotip> </div> <div className="flex items-center"> <Textarea diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx index 5fd0fc7c41..23e87554e3 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx @@ -1,7 +1,6 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { RiQuestionLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' type MonthlyDaysSelectorProps = { selectedDays: (number | 'last')[] @@ -41,38 +40,46 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp {rows.map((row, rowIndex) => ( <div key={rowIndex} className="grid grid-cols-7 gap-1.5"> {row.map(day => ( - <button - key={day} - type="button" - onClick={() => handleDayClick(day)} - className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${ - day === 'last' ? 'col-span-2 min-w-0' : '' - } ${ - isDaySelected(day) - ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' - : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' - }`} - > - {day === 'last' - ? ( - <div className="flex items-center justify-center gap-1"> - <span>{t('nodes.triggerSchedule.lastDay', { ns: 'workflow' })}</span> - <Tooltip> - <TooltipTrigger - render={( - <RiQuestionLine className="h-3 w-3 text-text-quaternary" /> - )} - /> - <TooltipContent> - {t('nodes.triggerSchedule.lastDayTooltip', { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - </div> - ) - : ( - day - )} - </button> + day === 'last' + ? ( + <div + key={day} + className={`col-span-2 flex min-w-0 items-center rounded-lg border bg-components-option-card-option-bg text-xs transition-colors ${ + isDaySelected(day) + ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' + : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' + }`} + > + <button + type="button" + onClick={() => handleDayClick(day)} + className="min-w-0 flex-1 py-1" + > + {t('nodes.triggerSchedule.lastDay', { ns: 'workflow' })} + </button> + <Infotip + aria-label={t('nodes.triggerSchedule.lastDayTooltip', { ns: 'workflow' })} + className="mr-1 h-3 w-3" + iconClassName="h-3 w-3" + > + {t('nodes.triggerSchedule.lastDayTooltip', { ns: 'workflow' })} + </Infotip> + </div> + ) + : ( + <button + key={day} + type="button" + onClick={() => handleDayClick(day)} + className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${ + isDaySelected(day) + ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' + : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' + }`} + > + {day} + </button> + ) ))} {/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */} {rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => ( From 1aa6188b7d00713d092a7e5b9c5ce4282937e48b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 13:35:14 +0900 Subject: [PATCH 36/53] chore(deps): bump the google group across 1 directory with 2 updates (#36012) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 4 ++-- api/uv.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index f142ed7d39..359844a3b5 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "gevent>=26.4.0", "gevent-websocket>=0.10.1", "gmpy2>=2.3.0", - "google-api-python-client>=2.195.0", + "google-api-python-client>=2.196.0", "gunicorn>=26.0.0", "psycogreen>=1.0.2", "psycopg2-binary>=2.9.12", @@ -31,7 +31,7 @@ dependencies = [ "flask-migrate>=4.1.0,<5.0.0", "flask-orjson>=2.0.0,<3.0.0", "flask-restx>=1.3.2,<2.0.0", - "google-cloud-aiplatform>=1.149.0,<2.0.0", + "google-cloud-aiplatform>=1.151.0,<2.0.0", "httpx[socks]>=0.28.1,<1.0.0", "opentelemetry-distro>=0.62b1,<1.0.0", "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", diff --git a/api/uv.lock b/api/uv.lock index 53db435fc8..68e1266af5 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1595,8 +1595,8 @@ requires-dist = [ { name = "gevent", specifier = ">=26.4.0" }, { name = "gevent-websocket", specifier = ">=0.10.1" }, { name = "gmpy2", specifier = ">=2.3.0" }, - { name = "google-api-python-client", specifier = ">=2.195.0" }, - { name = "google-cloud-aiplatform", specifier = ">=1.149.0,<2.0.0" }, + { name = "google-api-python-client", specifier = ">=2.196.0" }, + { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, { name = "graphon", specifier = "~=0.3.0" }, { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, @@ -2722,7 +2722,7 @@ grpc = [ [[package]] name = "google-api-python-client" -version = "2.195.0" +version = "2.196.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -2731,9 +2731,9 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/07/08d759b9cb10f48af14b25262dd0d6685ca8cda6c1f9e8a8109f57457205/google_api_python_client-2.195.0.tar.gz", hash = "sha256:c72cf2661c3addf01c880ce60541e83e1df354644b874f7f9d8d5ed2070446ae", size = 14584819, upload-time = "2026-04-30T21:51:50.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129, upload-time = "2026-05-06T23:47:35.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/b9/2c71095e31fff57668fec7c07ac897df065f15521d070e63229e13689590/google_api_python_client-2.195.0-py3-none-any.whl", hash = "sha256:753e62057f23049a89534bea0162b60fe391b85fb86d80bcdf884d05ec91c5bf", size = 15162418, upload-time = "2026-04-30T21:51:47.444Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663, upload-time = "2026-05-06T23:47:32.886Z" }, ] [[package]] @@ -2769,7 +2769,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.149.0" +version = "1.151.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -2785,9 +2785,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/2c/fba4adc56f74c0ee0fbd91a39d414ca2c3588dd8b71f9be8a507015ca886/google_cloud_aiplatform-1.149.0.tar.gz", hash = "sha256:a4d73485bf1d727a9e1bbbd13d08d7031490686bbf7d125eb905c1a6c1559a35", size = 10451466, upload-time = "2026-04-27T23:11:54.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/f6/e2fbe175a011f5080da8c1f7d9169a6875a00ea2c7bee4193d952b097400/google_cloud_aiplatform-1.151.0.tar.gz", hash = "sha256:2f29b1853f790a7371a746c747bf1f664380b534254682441acd4b5ee26fafd2", size = 10617421, upload-time = "2026-05-07T21:56:52.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/a0/27719ba23967ef62e52a1d54e013e0fc174bdab8dd84fb300bab9bf0d4a3/google_cloud_aiplatform-1.149.0-py2.py3-none-any.whl", hash = "sha256:e6b5299fa5d303e971cb29a19f03fdbb7b1e3b9d2faa3a788ca933341fba2f2e", size = 8570410, upload-time = "2026-04-27T23:11:50.495Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/cd35f8ba622d563b1335222284d2838aa789b953b40516b1b997e50fe5b6/google_cloud_aiplatform-1.151.0-py2.py3-none-any.whl", hash = "sha256:61372bb0923b14b8027f45b83393452df3a85bf4ea86fa48e08844fb5ec50049", size = 8732627, upload-time = "2026-05-07T21:56:49.014Z" }, ] [[package]] From b108ea42f64c7e46567091627f30d877590a4af4 Mon Sep 17 00:00:00 2001 From: -LAN- <laipz8200@outlook.com> Date: Mon, 11 May 2026 13:21:02 +0800 Subject: [PATCH 37/53] fix(docker): update middleware env setup (#35946) --- Makefile | 23 +++++++++++++++++++---- dev/pytest/pytest_full.sh | 2 +- dev/setup | 4 ++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index ae7589bbd6..3b5024683f 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,10 @@ DOCKER_REGISTRY=langgenius WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web API_IMAGE=$(DOCKER_REGISTRY)/dify-api VERSION=latest +DOCKER_DIR=docker +DOCKER_MIDDLEWARE_ENV=$(DOCKER_DIR)/middleware.env +DOCKER_MIDDLEWARE_ENV_EXAMPLE=$(DOCKER_DIR)/envs/middleware.env.example +DOCKER_MIDDLEWARE_PROJECT=dify-middlewares-dev # Default target - show help .DEFAULT_GOAL := help @@ -17,8 +21,13 @@ dev-setup: prepare-docker prepare-web prepare-api # Step 1: Prepare Docker middleware prepare-docker: @echo "🐳 Setting up Docker middleware..." - @cp -n docker/middleware.env.example docker/middleware.env 2>/dev/null || echo "Docker middleware.env already exists" - @cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev up -d + @if [ ! -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \ + cp "$(DOCKER_MIDDLEWARE_ENV_EXAMPLE)" "$(DOCKER_MIDDLEWARE_ENV)"; \ + echo "Docker middleware.env created"; \ + else \ + echo "Docker middleware.env already exists"; \ + fi + @cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) up -d @echo "✅ Docker middleware started" # Step 2: Prepare web environment @@ -39,12 +48,18 @@ prepare-api: # Clean dev environment dev-clean: @echo "⚠️ Stopping Docker containers..." - @cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev down + @if [ -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \ + cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) down; \ + else \ + echo "Docker middleware.env does not exist, skipping compose down"; \ + fi @echo "🗑️ Removing volumes..." @rm -rf docker/volumes/db + @rm -rf docker/volumes/mysql @rm -rf docker/volumes/redis @rm -rf docker/volumes/plugin_daemon @rm -rf docker/volumes/weaviate + @rm -rf docker/volumes/sandbox/dependencies @rm -rf api/storage @echo "✅ Cleanup complete" @@ -132,7 +147,7 @@ help: @echo " make prepare-docker - Set up Docker middleware" @echo " make prepare-web - Set up web environment" @echo " make prepare-api - Set up API environment" - @echo " make dev-clean - Stop Docker middleware containers" + @echo " make dev-clean - Stop Docker middleware containers and remove dev data" @echo "" @echo "Backend Code Quality:" @echo " make format - Format code with ruff" diff --git a/dev/pytest/pytest_full.sh b/dev/pytest/pytest_full.sh index 2989a74ad8..ca09aeb729 100755 --- a/dev/pytest/pytest_full.sh +++ b/dev/pytest/pytest_full.sh @@ -15,7 +15,7 @@ mkdir -p "${OPENDAL_FS_ROOT}" # Prepare env files like CI cp -n docker/.env.example docker/.env || true -cp -n docker/middleware.env.example docker/middleware.env || true +cp -n docker/envs/middleware.env.example docker/middleware.env || true cp -n api/tests/integration_tests/.env.example api/tests/integration_tests/.env || true # Expose service ports (same as CI) without leaving the repo dirty diff --git a/dev/setup b/dev/setup index 4236ff7fa7..1d2501a48b 100755 --- a/dev/setup +++ b/dev/setup @@ -8,7 +8,7 @@ API_ENV_EXAMPLE="$ROOT/api/.env.example" API_ENV="$ROOT/api/.env" WEB_ENV_EXAMPLE="$ROOT/web/.env.example" WEB_ENV="$ROOT/web/.env.local" -MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/middleware.env.example" +MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/envs/middleware.env.example" MIDDLEWARE_ENV="$ROOT/docker/middleware.env" # 1) Copy api/.env.example -> api/.env @@ -17,7 +17,7 @@ cp "$API_ENV_EXAMPLE" "$API_ENV" # 2) Copy web/.env.example -> web/.env.local cp "$WEB_ENV_EXAMPLE" "$WEB_ENV" -# 3) Copy docker/middleware.env.example -> docker/middleware.env +# 3) Copy docker/envs/middleware.env.example -> docker/middleware.env cp "$MIDDLEWARE_ENV_EXAMPLE" "$MIDDLEWARE_ENV" # 4) Install deps From 74a04afe2753f7394de69e159ed7468b8587ce1c Mon Sep 17 00:00:00 2001 From: QuantumGhost <obelisk.reg+git@gmail.com> Date: Mon, 11 May 2026 13:32:17 +0800 Subject: [PATCH 38/53] chore(api): upgrade graphon to v0.3.1 (#35987) Co-authored-by: -LAN- <laipz8200@outlook.com> --- api/core/workflow/node_runtime.py | 1 + api/pyproject.toml | 2 +- .../workflow/nodes/test_llm.py | 13 ++++++---- .../nodes/test_parameter_extractor.py | 24 +++++++++++-------- .../app/test_workflow_draft_variable.py | 6 +++-- .../console/auth/test_data_source_oauth.py | 4 +++- .../console/auth/test_password_reset.py | 11 ++++++--- .../core/variables/test_segment_type.py | 8 +++---- .../core/workflow/nodes/llm/test_llm_utils.py | 2 +- .../workflow/nodes/tool/test_tool_node.py | 9 ++++++- .../workflow/test_node_mapping_bootstrap.py | 13 +++++++++- api/uv.lock | 8 +++---- 12 files changed, 67 insertions(+), 34 deletions(-) diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index c1d3a856fb..d687d9a6e0 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -378,6 +378,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): node_id: str, node_data: ToolNodeData, variable_pool, + node_execution_id: str | None = None, ) -> ToolRuntimeHandle: try: tool_runtime = ToolManager.get_workflow_tool_runtime( diff --git a/api/pyproject.toml b/api/pyproject.toml index 359844a3b5..604d01594e 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]~=0.7.0", - "graphon~=0.3.0", + "graphon~=0.3.1", "httpx-sse~=0.4.0", "json-repair~=0.59.4", ] diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py index 92f3a1926c..5b7790f6f4 100644 --- a/api/tests/integration_tests/workflow/nodes/test_llm.py +++ b/api/tests/integration_tests/workflow/nodes/test_llm.py @@ -91,7 +91,11 @@ def init_llm_node(config: dict) -> LLMNode: return node -def test_execute_llm(): +def _mock_db_session_close(monkeypatch) -> None: + monkeypatch.setattr(db.session, "close", MagicMock()) + + +def test_execute_llm(monkeypatch): node = init_llm_node( config={ "id": "llm", @@ -118,7 +122,7 @@ def test_execute_llm(): }, ) - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) def build_mock_model_instance() -> MagicMock: from decimal import Decimal @@ -195,7 +199,7 @@ def test_execute_llm(): assert item.node_run_result.outputs.get("usage", {})["total_tokens"] > 0 -def test_execute_llm_with_jinja2(): +def test_execute_llm_with_jinja2(monkeypatch): """ Test execute LLM node with jinja2 """ @@ -233,8 +237,7 @@ def test_execute_llm_with_jinja2(): }, ) - # Mock db.session.close() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) def build_mock_model_instance() -> MagicMock: from decimal import Decimal diff --git a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py index f11188323a..fc230a2a68 100644 --- a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py +++ b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py @@ -83,7 +83,11 @@ def init_parameter_extractor_node(config: dict, memory=None): return node -def test_function_calling_parameter_extractor(setup_model_mock): +def _mock_db_session_close(monkeypatch) -> None: + monkeypatch.setattr(db.session, "close", MagicMock()) + + +def test_function_calling_parameter_extractor(setup_model_mock, monkeypatch): """ Test function calling for parameter extractor. """ @@ -114,7 +118,7 @@ def test_function_calling_parameter_extractor(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -124,7 +128,7 @@ def test_function_calling_parameter_extractor(setup_model_mock): assert result.outputs.get("__reason") == None -def test_instructions(setup_model_mock): +def test_instructions(setup_model_mock, monkeypatch): """ Test chat parameter extractor. """ @@ -155,7 +159,7 @@ def test_instructions(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -174,7 +178,7 @@ def test_instructions(setup_model_mock): assert "what's the weather in SF" in prompt.get("text") -def test_chat_parameter_extractor(setup_model_mock): +def test_chat_parameter_extractor(setup_model_mock, monkeypatch): """ Test chat parameter extractor. """ @@ -205,7 +209,7 @@ def test_chat_parameter_extractor(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -225,7 +229,7 @@ def test_chat_parameter_extractor(setup_model_mock): assert '<structure>\n{"type": "object"' in prompt.get("text") -def test_completion_parameter_extractor(setup_model_mock): +def test_completion_parameter_extractor(setup_model_mock, monkeypatch): """ Test completion parameter extractor. """ @@ -256,7 +260,7 @@ def test_completion_parameter_extractor(setup_model_mock): mode="completion", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -350,7 +354,7 @@ def test_extract_json_from_tool_call(): assert result["location"] == "kawaii" -def test_chat_parameter_extractor_with_memory(setup_model_mock): +def test_chat_parameter_extractor_with_memory(setup_model_mock, monkeypatch): """ Test chat parameter extractor with memory. """ @@ -382,7 +386,7 @@ def test_chat_parameter_extractor_with_memory(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py index 290be87697..a071d22ee9 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py @@ -168,6 +168,7 @@ def test_node_variable_collection_get_success( account, tenant = create_console_account_and_tenant(db_session_with_containers) app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW) node_variable = _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_123") + node_variable_id = node_variable.id _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_456", name="other") response = test_client_with_containers.get( @@ -178,7 +179,7 @@ def test_node_variable_collection_get_success( assert response.status_code == 200 payload = response.get_json() assert payload is not None - assert [item["id"] for item in payload["items"]] == [node_variable.id] + assert [item["id"] for item in payload["items"]] == [node_variable_id] def test_node_variable_collection_get_invalid_node_id( @@ -377,6 +378,7 @@ def test_system_variable_collection_get( account, tenant = create_console_account_and_tenant(db_session_with_containers) app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW) variable = _create_system_variable(db_session_with_containers, app.id, account.id) + variable_id = variable.id response = test_client_with_containers.get( f"/console/api/apps/{app.id}/workflows/draft/system-variables", @@ -386,7 +388,7 @@ def test_system_variable_collection_get( assert response.status_code == 200 payload = response.get_json() assert payload is not None - assert [item["id"] for item in payload["items"]] == [variable.id] + assert [item["id"] for item in payload["items"]] == [variable_id] def test_environment_variable_collection_get( diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py index 81b5423261..f2c45f76da 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py @@ -17,6 +17,8 @@ def test_get_oauth_url_successful( test_client_with_containers: FlaskClient, ) -> None: account, tenant = create_console_account_and_tenant(db_session_with_containers) + tenant_id = tenant.id + current_tenant_id = account.current_tenant_id provider = MagicMock() provider.get_authorization_url.return_value = "http://oauth.provider/auth" @@ -29,7 +31,7 @@ def test_get_oauth_url_successful( headers=authenticate_console_client(test_client_with_containers, account), ) - assert tenant.id == account.current_tenant_id + assert tenant_id == current_tenant_id assert response.status_code == 200 assert response.get_json() == {"data": "http://oauth.provider/auth"} provider.get_authorization_url.assert_called_once() diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py index d017e8f2bd..5fc3b3084a 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask +from sqlalchemy.orm import Session from controllers.console.auth.error import ( EmailCodeError, @@ -20,13 +21,15 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordSendEmailApi, ) from controllers.console.error import AccountNotFound, EmailSendIpLimitError +from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup class TestForgotPasswordSendEmailApi: """Test cases for sending password reset emails.""" @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @pytest.fixture @@ -139,7 +142,8 @@ class TestForgotPasswordCheckApi: """Test cases for verifying password reset codes.""" @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") @@ -322,7 +326,8 @@ class TestForgotPasswordResetApi: """Test cases for resetting password with verified token.""" @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @pytest.fixture diff --git a/api/tests/unit_tests/core/variables/test_segment_type.py b/api/tests/unit_tests/core/variables/test_segment_type.py index baa2ac2dc7..009899a92d 100644 --- a/api/tests/unit_tests/core/variables/test_segment_type.py +++ b/api/tests/unit_tests/core/variables/test_segment_type.py @@ -233,8 +233,6 @@ class TestSegmentTypeAdditionalMethods: assert SegmentType.GROUP.is_valid([StringSegment(value="b")]) is True assert SegmentType.GROUP.is_valid(["not-segment"]) is False - def test_unreachable_assertion_branch(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(SegmentType, "is_array_type", lambda self: False) - - with pytest.raises(AssertionError, match="unreachable"): - SegmentType.ARRAY_STRING.is_valid(["a"]) + def test_unreachable_assertion_branch(self): + with pytest.raises(AssertionError, match="Expected code to be unreachable"): + SegmentType.is_valid("not-a-segment-type", None) # type: ignore[arg-type] diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py index 212ad07bd3..6a2fc81fef 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py @@ -613,7 +613,7 @@ def test_combine_message_content_with_role_handles_all_supported_roles(): SystemPromptMessage(content=contents) ) - with pytest.raises(NotImplementedError, match="Role custom is not supported"): + with pytest.raises(AssertionError, match="Expected code to be unreachable"): llm_utils.combine_message_content_with_role(contents=contents, role="custom") # type: ignore[arg-type] diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py index 4aa5803ac7..f17c95fc13 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -24,7 +24,14 @@ if TYPE_CHECKING: # pragma: no cover - imported for type checking only class _StubToolRuntime: - def get_runtime(self, *, node_id: str, node_data: Any, variable_pool: Any) -> ToolRuntimeHandle: + def get_runtime( + self, + *, + node_id: str, + node_data: Any, + variable_pool: Any, + node_execution_id: str | None = None, + ) -> ToolRuntimeHandle: raise NotImplementedError def get_runtime_parameters(self, *, tool_runtime: ToolRuntimeHandle) -> list[Any]: diff --git a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py index d18fc262ef..2dd3953d9a 100644 --- a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py +++ b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py @@ -7,6 +7,17 @@ from pathlib import Path def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): api_root = Path(__file__).resolve().parents[4] + + # `PYTHONSAFEPATH=1` enables Python's safe-path mode, which suppresses the + # usual implicit insertion of the working directory into `sys.path`. + # Set `PYTHONPATH` explicitly so this subprocess test stays deterministic in + # both CI and local shells that may export `PYTHONSAFEPATH`. + env = os.environ.copy() + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(api_root) if not existing_pythonpath else os.pathsep.join([str(api_root), existing_pythonpath]) + ) + env["PYTHONSAFEPATH"] = "1" script = textwrap.dedent( """ from core.app.apps import workflow_app_runner @@ -34,7 +45,7 @@ def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): completed = subprocess.run( [sys.executable, "-c", script], cwd=api_root, - env=os.environ.copy(), + env=env, capture_output=True, text=True, check=False, diff --git a/api/uv.lock b/api/uv.lock index 68e1266af5..747bb7d647 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1597,7 +1597,7 @@ requires-dist = [ { name = "gmpy2", specifier = ">=2.3.0" }, { name = "google-api-python-client", specifier = ">=2.196.0" }, { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, - { name = "graphon", specifier = "~=0.3.0" }, + { name = "graphon", specifier = "~=0.3.1" }, { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, @@ -2940,7 +2940,7 @@ httpx = [ [[package]] name = "graphon" -version = "0.3.0" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -2961,9 +2961,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/62/83593d6e7a139ff124711ea05882cadca7065c11a38763aa9360d7e76804/graphon-0.3.0.tar.gz", hash = "sha256:cd38f842ae3dcfa956428b952efbe2a3ea9c1581446647142accbbdeb638b876", size = 241176, upload-time = "2026-04-21T15:18:48.291Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/ef/43217842e84160acca64a95858f1689389a50e04a53fc94f2aa836b4eaf7/graphon-0.3.1.tar.gz", hash = "sha256:49971baed1eb16c8e1983f755e659902e4f117a68dc62fad19e91472950b937d", size = 242210, upload-time = "2026-05-07T06:58:21.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/f7/81ee8f0368aa6a2d47f97fecc5d4a12865c987906798cbddd0e3b8387f33/graphon-0.3.0-py3-none-any.whl", hash = "sha256:9cca45ebab2a79fd4d04432f55b5b962e9e4f34fa037cc20fee7f18ec80eaa5d", size = 348486, upload-time = "2026-04-21T15:18:46.737Z" }, + { url = "https://files.pythonhosted.org/packages/62/37/bef16ed3d6da7446b36769fa388f4dc79f95337ffa16d6dfc3177152507e/graphon-0.3.1-py3-none-any.whl", hash = "sha256:e6422c7e3f1ce7d2185979c17e08201816ca25d46d400ebdd035c95d501c04fe", size = 349368, upload-time = "2026-05-07T06:58:20.217Z" }, ] [[package]] From dd1cdbbd41edaa302deb670eae3819e56bed311a Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 11 May 2026 13:57:30 +0800 Subject: [PATCH 39/53] refactor(web): split premium badge button semantics (#36026) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 5 -- .../(commonLayout)/account-page/index.tsx | 2 +- web/app/account/(commonLayout)/avatar.tsx | 2 +- .../app/overview/settings/index.tsx | 6 +- .../premium-badge/__tests__/index.spec.tsx | 25 +++++- .../components/base/premium-badge/index.css | 2 +- .../base/premium-badge/index.stories.tsx | 18 ++--- .../components/base/premium-badge/index.tsx | 58 +++++++++++--- .../upgrade-btn/__tests__/index.spec.tsx | 76 ++++++++++--------- .../components/billing/upgrade-btn/index.tsx | 18 +++-- .../header/__tests__/index.spec.tsx | 2 +- .../header/account-dropdown/compliance.tsx | 2 +- .../workplace-selector/index.tsx | 2 +- web/app/components/header/index.tsx | 2 +- .../components/header/license-env/index.tsx | 2 +- .../plan-badge/__tests__/index.spec.tsx | 19 ++++- .../components/header/plan-badge/index.tsx | 54 +++++++++---- .../rag-pipeline-header/publisher/popup.tsx | 4 +- .../__tests__/upgrade-modal.spec.tsx | 9 --- .../delivery-method/upgrade-modal.tsx | 9 +-- .../applied-education-content.tsx | 2 +- 21 files changed, 202 insertions(+), 117 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 97d2f93a59..4adca38aa0 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1741,11 +1741,6 @@ "count": 4 } }, - "web/app/components/billing/upgrade-btn/index.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/datasets/common/image-previewer/index.tsx": { "no-irregular-whitespace": { "count": 1 diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 0de33a2a71..b85fb1abba 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -166,7 +166,7 @@ export default function AccountPage() { {userProfile.name} {isEducationAccount && ( <PremiumBadge size="s" color="blue" className="ml-1 !px-2"> - <RiGraduationCapFill className="mr-1 h-3 w-3" /> + <RiGraduationCapFill aria-hidden="true" className="mr-1 h-3 w-3" /> <span className="system-2xs-medium">EDU</span> </PremiumBadge> )} diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 3fefb8a319..ef1c4b7e8c 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -62,7 +62,7 @@ export default function AppSelector() { {userProfile.name} {isEducationAccount && ( <PremiumBadge size="s" color="blue" className="ml-1 px-2!"> - <span className="mr-1 i-ri-graduation-cap-fill h-3 w-3" /> + <span aria-hidden="true" className="mr-1 i-ri-graduation-cap-fill h-3 w-3" /> <span className="system-2xs-medium">EDU</span> </PremiumBadge> )} diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index aefe339b94..f64972b1a1 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -17,7 +17,7 @@ import AppIcon from '@/app/components/base/app-icon' import AppIconPicker from '@/app/components/base/app-icon-picker' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' -import PremiumBadge from '@/app/components/base/premium-badge' +import { PremiumBadgeButton } from '@/app/components/base/premium-badge' import Textarea from '@/app/components/base/textarea' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' @@ -395,14 +395,14 @@ const SettingsModal: FC<ISettingsModalProps> = ({ {/* upgrade button */} {enableBilling && isFreePlan && ( <div className="h-[18px] select-none"> - <PremiumBadge size="s" color="blue" allowHover={true} onClick={handlePlanClick}> + <PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}> <span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> <div className="system-xs-medium"> <span className="p-1"> {t('upgradeBtn.encourageShort', { ns: 'billing' })} </span> </div> - </PremiumBadge> + </PremiumBadgeButton> </div> )} </div> diff --git a/web/app/components/base/premium-badge/__tests__/index.spec.tsx b/web/app/components/base/premium-badge/__tests__/index.spec.tsx index c3eb31c72e..ccbe4dfc92 100644 --- a/web/app/components/base/premium-badge/__tests__/index.spec.tsx +++ b/web/app/components/base/premium-badge/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react' -import PremiumBadge from '../index' +import userEvent from '@testing-library/user-event' +import PremiumBadge, { PremiumBadgeButton } from '../index' describe('PremiumBadge', () => { it('renders with default props', () => { @@ -24,9 +25,9 @@ describe('PremiumBadge', () => { it('applies allowHover class when allowHover is true', () => { render( - <PremiumBadge allowHover> + <PremiumBadgeButton> Premium - </PremiumBadge>, + </PremiumBadgeButton>, ) const badge = screen.getByText('Premium') expect(badge).toBeInTheDocument() @@ -43,4 +44,22 @@ describe('PremiumBadge', () => { expect(badge).toBeInTheDocument() expect(badge).toHaveStyle('background-color: red') }) + + it('renders a static badge without button semantics', () => { + render(<PremiumBadge>Premium</PremiumBadge>) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders an action badge as a button', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + + render(<PremiumBadgeButton onClick={handleClick}>Upgrade</PremiumBadgeButton>) + + const button = screen.getByRole('button', { name: 'Upgrade' }) + await user.click(button) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/app/components/base/premium-badge/index.css b/web/app/components/base/premium-badge/index.css index f192a51e57..0796bf3cf6 100644 --- a/web/app/components/base/premium-badge/index.css +++ b/web/app/components/base/premium-badge/index.css @@ -1,5 +1,5 @@ @utility premium-badge { - @apply shrink-0 relative inline-flex justify-center items-center rounded-md box-border border border-transparent text-white shadow-xs hover:shadow-lg bg-origin-border overflow-hidden transition-all duration-100 ease-out; + @apply shrink-0 relative inline-flex justify-center items-center rounded-md box-border border border-transparent text-white shadow-xs hover:shadow-lg bg-origin-border overflow-hidden transition-[background-color,background-image,box-shadow] duration-100 ease-out motion-reduce:transition-none; background-clip: padding-box, border-box; } diff --git a/web/app/components/base/premium-badge/index.stories.tsx b/web/app/components/base/premium-badge/index.stories.tsx index 5f3786a2ea..a0c16c19aa 100644 --- a/web/app/components/base/premium-badge/index.stories.tsx +++ b/web/app/components/base/premium-badge/index.stories.tsx @@ -1,14 +1,12 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import PremiumBadge from '.' +import PremiumBadge, { PremiumBadgeButton } from '.' const colors: Array<NonNullable<React.ComponentProps<typeof PremiumBadge>['color']>> = ['blue', 'indigo', 'gray', 'orange'] const PremiumBadgeGallery = ({ size = 'm', - allowHover = false, }: { size?: 's' | 'm' - allowHover?: boolean }) => { return ( <div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> @@ -16,7 +14,7 @@ const PremiumBadgeGallery = ({ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> {colors.map(color => ( <div key={color} className="flex flex-col items-center gap-2 rounded-xl border border-transparent px-2 py-4 hover:border-divider-subtle hover:bg-background-default-subtle"> - <PremiumBadge color={color} size={size} allowHover={allowHover}> + <PremiumBadge color={color} size={size}> <span className="px-2 text-xs font-semibold tracking-[0.14em] uppercase">Premium</span> </PremiumBadge> <span className="text-[11px] tracking-[0.16em] text-text-tertiary uppercase">{color}</span> @@ -43,11 +41,9 @@ const meta = { control: 'radio', options: ['s', 'm'], }, - allowHover: { control: 'boolean' }, }, args: { size: 'm', - allowHover: false, }, tags: ['autodocs'], } satisfies Meta<typeof PremiumBadgeGallery> @@ -57,8 +53,10 @@ type Story = StoryObj<typeof meta> export const Playground: Story = {} -export const HoverEnabled: Story = { - args: { - allowHover: true, - }, +export const Action: Story = { + render: () => ( + <PremiumBadgeButton color="blue" onClick={() => {}}> + <span className="px-2 text-xs font-semibold">Upgrade</span> + </PremiumBadgeButton> + ), } diff --git a/web/app/components/base/premium-badge/index.tsx b/web/app/components/base/premium-badge/index.tsx index 42ce158606..55628ea0e9 100644 --- a/web/app/components/base/premium-badge/index.tsx +++ b/web/app/components/base/premium-badge/index.tsx @@ -1,8 +1,7 @@ import type { VariantProps } from 'class-variance-authority' -import type { CSSProperties, ReactNode } from 'react' +import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { cva } from 'class-variance-authority' -import * as React from 'react' import { Highlight } from '@/app/components/base/icons/src/public/common' const PremiumBadgeVariants = cva( @@ -38,31 +37,66 @@ type PremiumBadgeProps = { color?: 'blue' | 'indigo' | 'gray' | 'orange' allowHover?: boolean styleCss?: CSSProperties + className?: string children?: ReactNode -} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof PremiumBadgeVariants> +} & VariantProps<typeof PremiumBadgeVariants> -const PremiumBadge: React.FC<PremiumBadgeProps> = ({ +type PremiumBadgeButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'color'> & Omit<PremiumBadgeProps, 'styleCss'> & { + style?: CSSProperties +} + +function BadgeHighlight({ size }: { size?: PremiumBadgeProps['size'] }) { + return ( + <Highlight + aria-hidden="true" + className={cn('absolute top-0 right-1/2 translate-x-[20%] opacity-50 transition-[opacity,transform] duration-100 ease-out hover:translate-x-[30%] hover:opacity-80 motion-reduce:transition-none', size === 's' ? 'h-[18px] w-12' : 'h-6 w-12')} + /> + ) +} + +function PremiumBadge({ className, size, color, allowHover, styleCss, children, - ...props -}) => { +}: PremiumBadgeProps) { return ( - <div + <span className={cn(PremiumBadgeVariants({ size, color, allowHover, className }), 'relative text-nowrap')} style={styleCss} + > + {children} + <BadgeHighlight size={size} /> + </span> + ) +} + +export function PremiumBadgeButton({ + className, + size, + color, + allowHover = true, + style, + children, + type = 'button', + ...props +}: PremiumBadgeButtonProps) { + return ( + <button + type={type} + className={cn( + PremiumBadgeVariants({ size, color, allowHover, className }), + 'relative touch-manipulation text-nowrap focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', + )} + style={style} {...props} > {children} - <Highlight - className={cn('absolute top-0 right-1/2 translate-x-[20%] opacity-50 transition-all duration-100 ease-out hover:translate-x-[30%] hover:opacity-80', size === 's' ? 'h-[18px] w-12' : 'h-6 w-12')} - /> - </div> + <BadgeHighlight size={size} /> + </button> ) } -PremiumBadge.displayName = 'PremiumBadge' export default PremiumBadge diff --git a/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx b/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx index 7eda24b944..5f6dc60038 100644 --- a/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx +++ b/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx @@ -38,10 +38,11 @@ describe('UpgradeBtn', () => { expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) - it('should render premium badge by default', () => { + it('should render premium badge button by default', () => { render(<UpgradeBtn />) - expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + expect(button).toHaveClass('premium-badge') }) it('should render plain button when isPlain is true', () => { @@ -75,7 +76,7 @@ describe('UpgradeBtn', () => { // Props tests (REQUIRED) describe('Props', () => { - it('should apply custom className to premium badge', () => { + it('should apply custom className to premium badge button', () => { const customClass = 'custom-upgrade-btn' const { container } = render(<UpgradeBtn className={customClass} />) @@ -93,7 +94,7 @@ describe('UpgradeBtn', () => { expect(button).toHaveClass(customClass) }) - it('should apply custom style to premium badge', () => { + it('should apply custom style to premium badge button', () => { const customStyle = { padding: '10px' } const { container } = render(<UpgradeBtn style={customStyle} />) @@ -132,13 +133,13 @@ describe('UpgradeBtn', () => { // User Interactions describe('User Interactions', () => { - it('should call custom onClick when provided and premium badge is clicked', async () => { + it('should call custom onClick when provided and premium badge button is clicked', async () => { const user = userEvent.setup() const handleClick = vi.fn() render(<UpgradeBtn onClick={handleClick} />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(handleClick).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).not.toHaveBeenCalled() @@ -156,12 +157,12 @@ describe('UpgradeBtn', () => { expect(mockSetShowPricingModal).not.toHaveBeenCalled() }) - it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => { + it('should open pricing modal when no custom onClick is provided and premium badge button is clicked', async () => { const user = userEvent.setup() render(<UpgradeBtn />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) @@ -176,13 +177,13 @@ describe('UpgradeBtn', () => { expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) - it('should track gtag event when loc is provided and badge is clicked', async () => { + it('should track gtag event when loc is provided and badge button is clicked', async () => { const user = userEvent.setup() const loc = 'header-navigation' render(<UpgradeBtn loc={loc} />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { @@ -208,8 +209,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render(<UpgradeBtn />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).not.toHaveBeenCalled() }) @@ -219,8 +220,8 @@ describe('UpgradeBtn', () => { delete gtagWindow.gtag render(<UpgradeBtn loc="test-location" />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).not.toHaveBeenCalled() }) @@ -231,8 +232,8 @@ describe('UpgradeBtn', () => { const loc = 'settings-page' render(<UpgradeBtn onClick={handleClick} loc={loc} />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(handleClick).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledTimes(1) @@ -260,8 +261,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render(<UpgradeBtn onClick={undefined} />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) @@ -270,8 +271,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render(<UpgradeBtn loc={undefined} />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).not.toHaveBeenCalled() }) @@ -292,8 +293,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render(<UpgradeBtn loc="" />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).not.toHaveBeenCalled() }) @@ -391,19 +392,26 @@ describe('UpgradeBtn', () => { expect(handleClick).toHaveBeenCalledTimes(1) }) - it('should be clickable for premium badge variant', async () => { + it('should be keyboard accessible for premium badge button variant', async () => { const user = userEvent.setup() const handleClick = vi.fn() render(<UpgradeBtn onClick={handleClick} />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - - // Click badge - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.tab() + expect(button).toHaveFocus() + await user.keyboard('{Enter}') expect(handleClick).toHaveBeenCalledTimes(1) }) + it('should have proper button role for premium badge button variant', () => { + render(<UpgradeBtn />) + + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + expect(button).toHaveClass('premium-badge') + }) + it('should have proper button role when isPlain is true', () => { render(<UpgradeBtn isPlain />) @@ -418,8 +426,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render(<UpgradeBtn />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) await waitFor(() => { expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) @@ -431,8 +439,8 @@ describe('UpgradeBtn', () => { const handleClick = vi.fn() render(<UpgradeBtn onClick={handleClick} loc="integration-test" />) - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) await waitFor(() => { expect(handleClick).toHaveBeenCalledTimes(1) diff --git a/web/app/components/billing/upgrade-btn/index.tsx b/web/app/components/billing/upgrade-btn/index.tsx index 5eb1eb1d7f..e5b53555c2 100644 --- a/web/app/components/billing/upgrade-btn/index.tsx +++ b/web/app/components/billing/upgrade-btn/index.tsx @@ -6,7 +6,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { SparklesSoft } from '@/app/components/base/icons/src/public/common' import { useModalContext } from '@/context/modal-context' -import PremiumBadge from '../../base/premium-badge' +import { PremiumBadgeButton } from '../../base/premium-badge' type Props = { className?: string @@ -20,6 +20,8 @@ type Props = { labelKey?: Exclude<I18nKeysWithPrefix<'billing'>, 'plans.community.features' | 'plans.enterprise.features' | 'plans.premium.features'> } +type GtagHandler = (command: 'event', action: 'click_upgrade_btn', payload: { loc: string }) => void + const UpgradeBtn: FC<Props> = ({ className, size = 'm', @@ -36,12 +38,13 @@ const UpgradeBtn: FC<Props> = ({ if (_onClick) _onClick() else - (setShowPricingModal as any)() + setShowPricingModal() } const onClick = () => { handleClick() - if (loc && (window as any).gtag) { - (window as any).gtag('event', 'click_upgrade_btn', { + const gtag = (window as Window & { gtag?: GtagHandler }).gtag + if (loc && gtag) { + gtag('event', 'click_upgrade_btn', { loc, }) } @@ -63,21 +66,20 @@ const UpgradeBtn: FC<Props> = ({ } return ( - <PremiumBadge + <PremiumBadgeButton size={size} color="blue" - allowHover={true} onClick={onClick} className={className} style={style} > - <SparklesSoft className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <SparklesSoft aria-hidden="true" className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> <div className="system-xs-medium"> <span className="p-1"> {label} </span> </div> - </PremiumBadge> + </PremiumBadgeButton> ) } export default React.memo(UpgradeBtn) diff --git a/web/app/components/header/__tests__/index.spec.tsx b/web/app/components/header/__tests__/index.spec.tsx index ba284829f3..0e9ef6493d 100644 --- a/web/app/components/header/__tests__/index.spec.tsx +++ b/web/app/components/header/__tests__/index.spec.tsx @@ -45,7 +45,7 @@ vi.mock('@/app/components/header/tools-nav', () => ({ })) vi.mock('@/app/components/header/plan-badge', () => ({ - default: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => ( + PlanBadge: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => ( <button data-testid="plan-badge" onClick={onClick} data-plan={plan} /> ), })) diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index 75d7200666..e1e134e864 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -65,7 +65,7 @@ function ComplianceDocActionVisual({ disabled={!canShowUpgradeTooltip} render={( <PremiumBadge color="blue" allowHover={true}> - <SparklesSoft className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <SparklesSoft aria-hidden="true" className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> <div className="px-1 system-xs-medium"> {upgradeText} </div> diff --git a/web/app/components/header/account-dropdown/workplace-selector/index.tsx b/web/app/components/header/account-dropdown/workplace-selector/index.tsx index a86da65797..934a003c4c 100644 --- a/web/app/components/header/account-dropdown/workplace-selector/index.tsx +++ b/web/app/components/header/account-dropdown/workplace-selector/index.tsx @@ -12,7 +12,7 @@ import { import { toast } from '@langgenius/dify-ui/toast' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import PlanBadge from '@/app/components/header/plan-badge' +import { PlanBadge } from '@/app/components/header/plan-badge' import { useWorkspacesContext } from '@/context/workspace-context' import { switchWorkspace } from '@/service/common' import { basePath } from '@/utils/var' diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx index c7c12f1e35..8e94a831d4 100644 --- a/web/app/components/header/index.tsx +++ b/web/app/components/header/index.tsx @@ -18,7 +18,7 @@ import DatasetNav from './dataset-nav' import EnvNav from './env-nav' import ExploreNav from './explore-nav' import LicenseNav from './license-env' -import PlanBadge from './plan-badge' +import { PlanBadge } from './plan-badge' import PluginsNav from './plugins-nav' import ToolsNav from './tools-nav' diff --git a/web/app/components/header/license-env/index.tsx b/web/app/components/header/license-env/index.tsx index fd360796b1..e7112a61f1 100644 --- a/web/app/components/header/license-env/index.tsx +++ b/web/app/components/header/license-env/index.tsx @@ -17,7 +17,7 @@ const LicenseNav = () => { const count = dayjs(expiredAt).diff(dayjs(), 'days') return ( <PremiumBadge color="orange" className="select-none"> - <RiHourglass2Fill className="flex size-3 items-center pl-0.5 text-components-premium-badge-indigo-text-stop-0" /> + <RiHourglass2Fill aria-hidden="true" className="flex size-3 items-center pl-0.5 text-components-premium-badge-indigo-text-stop-0" /> {count <= 1 && <span className="px-0.5 system-xs-medium">{t('license.expiring', { ns: 'common', count })}</span>} {count > 1 && <span className="px-0.5 system-xs-medium">{t('license.expiring_plural', { ns: 'common', count })}</span>} </PremiumBadge> diff --git a/web/app/components/header/plan-badge/__tests__/index.spec.tsx b/web/app/components/header/plan-badge/__tests__/index.spec.tsx index 3abb791340..4e562ecf99 100644 --- a/web/app/components/header/plan-badge/__tests__/index.spec.tsx +++ b/web/app/components/header/plan-badge/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import { vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { useProviderContext } from '@/context/provider-context' import { Plan } from '../../../billing/type' -import PlanBadge from '../index' +import { PlanBadge } from '../index' vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), @@ -34,6 +34,20 @@ describe('PlanBadge', () => { expect( screen.getByText('billing.upgradeBtn.encourageShort'), ).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should render upgrade action as a button when onClick is provided', () => { + const handleClick = vi.fn() + mockUseProviderContext.mockReturnValue( + createMockProviderContextValue({ isFetchedPlan: true }), + ) + + render(<PlanBadge plan={Plan.sandbox} sandboxAsUpgrade={true} onClick={handleClick} />) + + const button = screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }) + fireEvent.click(button) + expect(handleClick).toHaveBeenCalledTimes(1) }) it('should render sandbox badge when plan is sandbox and sandboxAsUpgrade is false', () => { @@ -42,6 +56,7 @@ describe('PlanBadge', () => { ) render(<PlanBadge plan={Plan.sandbox} sandboxAsUpgrade={false} />) expect(screen.getByText(Plan.sandbox)).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() }) it('should render professional badge when plan is professional', () => { @@ -87,7 +102,7 @@ describe('PlanBadge', () => { createMockProviderContextValue({ isFetchedPlan: true }), ) render(<PlanBadge plan={Plan.team} onClick={handleClick} />) - fireEvent.click(screen.getByText(Plan.team)) + fireEvent.click(screen.getByRole('button', { name: Plan.team })) expect(handleClick).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/header/plan-badge/index.tsx b/web/app/components/header/plan-badge/index.tsx index 9547ddf6f7..c889e9d5ae 100644 --- a/web/app/components/header/plan-badge/index.tsx +++ b/web/app/components/header/plan-badge/index.tsx @@ -1,11 +1,11 @@ -import type { FC } from 'react' +import type { ReactNode } from 'react' import { RiGraduationCapFill, } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useProviderContext } from '@/context/provider-context' import { SparklesSoft } from '../../base/icons/src/public/common' -import PremiumBadge from '../../base/premium-badge' +import PremiumBadge, { PremiumBadgeButton } from '../../base/premium-badge' import { Plan } from '../../billing/type' type PlanBadgeProps = { @@ -15,7 +15,33 @@ type PlanBadgeProps = { onClick?: () => void } -const PlanBadge: FC<PlanBadgeProps> = ({ plan, allowHover, sandboxAsUpgrade = false, onClick }) => { +function PlanBadgeShell({ + size, + color, + allowHover, + onClick, + children, +}: Pick<PlanBadgeProps, 'allowHover' | 'onClick'> & { + size?: 's' | 'm' + color: 'blue' | 'indigo' | 'gray' + children: ReactNode +}) { + if (onClick) { + return ( + <PremiumBadgeButton className="select-none" size={size} color={color} allowHover={allowHover} onClick={onClick}> + {children} + </PremiumBadgeButton> + ) + } + + return ( + <PremiumBadge className="select-none" size={size} color={color}> + {children} + </PremiumBadge> + ) +} + +export function PlanBadge({ plan, allowHover, sandboxAsUpgrade = false, onClick }: PlanBadgeProps) { const { isFetchedPlan, isEducationWorkspace } = useProviderContext() const { t } = useTranslation() @@ -23,51 +49,49 @@ const PlanBadge: FC<PlanBadgeProps> = ({ plan, allowHover, sandboxAsUpgrade = fa return null if (plan === Plan.sandbox && sandboxAsUpgrade) { return ( - <PremiumBadge className="select-none" color="blue" allowHover={allowHover} onClick={onClick}> - <SparklesSoft className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <PlanBadgeShell color="blue" allowHover={allowHover} onClick={onClick}> + <SparklesSoft aria-hidden="true" className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> <div className="system-xs-medium"> <span className="p-1 whitespace-nowrap"> {t('upgradeBtn.encourageShort', { ns: 'billing' })} </span> </div> - </PremiumBadge> + </PlanBadgeShell> ) } if (plan === Plan.sandbox) { return ( - <PremiumBadge className="select-none" size="s" color="gray" allowHover={allowHover} onClick={onClick}> + <PlanBadgeShell size="s" color="gray" allowHover={allowHover} onClick={onClick}> <div className="system-2xs-medium-uppercase"> <span className="p-1"> {plan} </span> </div> - </PremiumBadge> + </PlanBadgeShell> ) } if (plan === Plan.professional) { return ( - <PremiumBadge className="select-none" size="s" color="blue" allowHover={allowHover} onClick={onClick}> + <PlanBadgeShell size="s" color="blue" allowHover={allowHover} onClick={onClick}> <div className="system-2xs-medium-uppercase"> <span className="inline-flex items-center gap-1 p-1"> - {isEducationWorkspace && <RiGraduationCapFill className="h-3 w-3" />} + {isEducationWorkspace && <RiGraduationCapFill aria-hidden="true" className="h-3 w-3" />} pro </span> </div> - </PremiumBadge> + </PlanBadgeShell> ) } if (plan === Plan.team) { return ( - <PremiumBadge className="select-none" size="s" color="indigo" allowHover={allowHover} onClick={onClick}> + <PlanBadgeShell size="s" color="indigo" allowHover={allowHover} onClick={onClick}> <div className="system-2xs-medium-uppercase"> <span className="p-1"> {plan} </span> </div> - </PremiumBadge> + </PlanBadgeShell> ) } return null } - -export default PlanBadge diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 0970d66cfc..1c2f7177a5 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -246,8 +246,8 @@ const Popup = ({ {t('common.publishAs', { ns: 'pipeline' })} </span> {!isAllowPublishAsCustomKnowledgePipelineTemplate && ( - <PremiumBadge className="shrink-0 cursor-pointer select-none" size="s" color="indigo"> - <SparklesSoft className="flex size-3 items-center text-components-premium-badge-indigo-text-stop-0" /> + <PremiumBadge className="shrink-0 select-none" size="s" color="indigo"> + <SparklesSoft aria-hidden="true" className="flex size-3 items-center text-components-premium-badge-indigo-text-stop-0" /> <span className="p-0.5 system-2xs-medium"> {t('upgradeBtn.encourageShort', { ns: 'billing' })} </span> diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx index 0abae16f9a..b6af667022 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx @@ -8,15 +8,6 @@ vi.mock('@/context/modal-context', () => ({ mockUseModalContextSelector(selector), })) -vi.mock('@/app/components/base/premium-badge', () => ({ - __esModule: true, - default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( - <button type="button" onClick={onClick}> - {children} - </button> - ), -})) - describe('human-input/delivery-method/upgrade-modal', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx index 18a6e90796..3daa347448 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx @@ -2,7 +2,7 @@ import { Button } from '@langgenius/dify-ui/button' import { RiMailSendFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { SparklesSoft } from '@/app/components/base/icons/src/public/common' -import PremiumBadge from '@/app/components/base/premium-badge' +import { PremiumBadgeButton } from '@/app/components/base/premium-badge' import { UpgradeModal as BaseUpgradeModal } from '@/app/components/base/upgrade-modal' import { useModalContextSelector } from '@/context/modal-context' @@ -39,20 +39,19 @@ export function UpgradeModal({ > {t('nodes.humanInput.deliveryMethod.upgradeTipHide', { ns: 'workflow' })} </Button> - <PremiumBadge + <PremiumBadgeButton size="custom" color="blue" - allowHover={true} className="h-8 w-[93px]" onClick={handleUpgrade} > - <SparklesSoft className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <SparklesSoft aria-hidden="true" className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> <div className="system-sm-medium"> <span className="p-1"> {t('upgradeBtn.encourageShort', { ns: 'billing' })} </span> </div> - </PremiumBadge> + </PremiumBadgeButton> </> )} /> diff --git a/web/app/education-apply/applied-education-content.tsx b/web/app/education-apply/applied-education-content.tsx index c3ff35b1b9..cbe2e11fda 100644 --- a/web/app/education-apply/applied-education-content.tsx +++ b/web/app/education-apply/applied-education-content.tsx @@ -10,7 +10,7 @@ import { import { useTranslation } from 'react-i18next' import { Plan } from '@/app/components/billing/type' import { WorkplaceSelectorContent } from '@/app/components/header/account-dropdown/workplace-selector' -import PlanBadge from '@/app/components/header/plan-badge' +import { PlanBadge } from '@/app/components/header/plan-badge' type AppliedEducationContentProps = { workspaces: IWorkspace[] From f1c4c1a5ffc03db60cd761262cf147fb3951fb58 Mon Sep 17 00:00:00 2001 From: SATISH K C <157192662+satishkc7@users.noreply.github.com> Date: Mon, 11 May 2026 01:09:50 -0500 Subject: [PATCH 40/53] refactor: replace dict params with BaseModel in AppService (#35904) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/app.py | 23 +- api/services/app_service.py | 76 ++- .../services/test_agent_service.py | 22 +- .../services/test_annotation_service.py | 22 +- .../services/test_app_dsl_service.py | 22 +- .../services/test_app_generate_service.py | 32 +- .../services/test_app_service.py | 606 ++++++++---------- .../services/test_message_service.py | 22 +- .../services/test_ops_service.py | 18 +- .../services/test_saved_message_service.py | 22 +- .../services/test_web_conversation_service.py | 22 +- .../services/test_workflow_app_service.py | 54 +- .../services/test_workflow_run_service.py | 64 +- .../test_workflow_tools_manage_service.py | 22 +- 14 files changed, 516 insertions(+), 511 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index a8ab5bec48..4429039d79 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -39,7 +39,7 @@ from libs.login import current_account_with_tenant, login_required from models import App, DatasetPermissionEnum, Workflow from models.model import IconType from services.app_dsl_service import AppDslService -from services.app_service import AppService +from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.knowledge_entities.knowledge_entities import ( @@ -478,11 +478,18 @@ class AppListApi(Resource): current_user, current_tenant_id = current_account_with_tenant() args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args)) - args_dict = args.model_dump() + params = AppListParams( + page=args.page, + limit=args.limit, + mode=args.mode, + name=args.name, + tag_ids=args.tag_ids, + is_created_by_me=args.is_created_by_me, + ) # get app list app_service = AppService() - app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, args_dict) + app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params) if not app_pagination: empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json"), 200 @@ -546,9 +553,17 @@ class AppListApi(Resource): """Create app""" current_user, current_tenant_id = current_account_with_tenant() args = CreateAppPayload.model_validate(console_ns.payload) + params = CreateAppParams( + name=args.name, + description=args.description, + mode=args.mode, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + ) app_service = AppService() - app = app_service.create_app(current_tenant_id, args.model_dump(), current_user) + app = app_service.create_app(current_tenant_id, params, current_user) app_detail = AppDetail.model_validate(app, from_attributes=True) return app_detail.model_dump(mode="json"), 201 diff --git a/api/services/app_service.py b/api/services/app_service.py index a046b909b3..6716833f6c 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -1,9 +1,10 @@ import json import logging -from typing import Any, TypedDict, cast +from typing import Any, Literal, TypedDict, cast import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination +from pydantic import BaseModel, Field from sqlalchemy import select from configs import dify_config @@ -31,39 +32,59 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t logger = logging.getLogger(__name__) +class AppListParams(BaseModel): + page: int = Field(default=1, ge=1) + limit: int = Field(default=20, ge=1, le=100) + mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "channel", "all"] = "all" + name: str | None = None + tag_ids: list[str] | None = None + is_created_by_me: bool | None = None + + +class CreateAppParams(BaseModel): + name: str = Field(min_length=1) + description: str | None = None + mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None + api_rph: int = 0 + api_rpm: int = 0 + max_active_requests: int | None = None + + class AppService: - def get_paginate_apps(self, user_id: str, tenant_id: str, args: dict[str, Any]) -> Pagination | None: + def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None: """ Get app list with pagination :param user_id: user id :param tenant_id: tenant id - :param args: request args + :param params: query parameters :return: """ filters = [App.tenant_id == tenant_id, App.is_universal == False] - if args["mode"] == "workflow": + if params.mode == "workflow": filters.append(App.mode == AppMode.WORKFLOW) - elif args["mode"] == "completion": + elif params.mode == "completion": filters.append(App.mode == AppMode.COMPLETION) - elif args["mode"] == "chat": + elif params.mode == "chat": filters.append(App.mode == AppMode.CHAT) - elif args["mode"] == "advanced-chat": + elif params.mode == "advanced-chat": filters.append(App.mode == AppMode.ADVANCED_CHAT) - elif args["mode"] == "agent-chat": + elif params.mode == "agent-chat": filters.append(App.mode == AppMode.AGENT_CHAT) - if args.get("is_created_by_me", False): + if params.is_created_by_me: filters.append(App.created_by == user_id) - if args.get("name"): + if params.name: from libs.helper import escape_like_pattern - name = args["name"][:30] + name = params.name[:30] escaped_name = escape_like_pattern(name) filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) - # Check if tag_ids is not empty to avoid WHERE false condition - if args.get("tag_ids") and len(args["tag_ids"]) > 0: - target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, args["tag_ids"]) + if params.tag_ids and len(params.tag_ids) > 0: + target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids) if target_ids and len(target_ids) > 0: filters.append(App.id.in_(target_ids)) else: @@ -71,21 +92,21 @@ class AppService: app_models = db.paginate( sa.select(App).where(*filters).order_by(App.created_at.desc()), - page=args["page"], - per_page=args["limit"], + page=params.page, + per_page=params.limit, error_out=False, ) return app_models - def create_app(self, tenant_id: str, args: dict[str, Any], account: Account) -> App: + def create_app(self, tenant_id: str, params: CreateAppParams, account: Account) -> App: """ Create app :param tenant_id: tenant id - :param args: request args + :param params: app creation parameters :param account: Account instance """ - app_mode = AppMode.value_of(args["mode"]) + app_mode = AppMode.value_of(params.mode) app_template = default_app_templates[app_mode] # get model config @@ -143,15 +164,16 @@ class AppService: default_model_config["model"] = json.dumps(default_model_dict) app = App(**app_template["app"]) - app.name = args["name"] - app.description = args.get("description", "") - app.mode = args["mode"] - app.icon_type = args.get("icon_type", "emoji") - app.icon = args["icon"] - app.icon_background = args["icon_background"] + app.name = params.name + app.description = params.description or "" + app.mode = app_mode + app.icon_type = IconType(params.icon_type) if params.icon_type else IconType.EMOJI + app.icon = params.icon + app.icon_background = params.icon_background app.tenant_id = tenant_id - app.api_rph = args.get("api_rph", 0) - app.api_rpm = args.get("api_rpm", 0) + app.api_rph = params.api_rph + app.api_rpm = params.api_rpm + app.max_active_requests = params.max_active_requests app.created_by = account.id app.updated_by = account.id diff --git a/api/tests/test_containers_integration_tests/services/test_agent_service.py b/api/tests/test_containers_integration_tests/services/test_agent_service.py index cbd939c7a4..670b4d00da 100644 --- a/api/tests/test_containers_integration_tests/services/test_agent_service.py +++ b/api/tests/test_containers_integration_tests/services/test_agent_service.py @@ -11,7 +11,7 @@ from models.enums import ConversationFromSource, MessageFileBelongsTo from models.model import AppModelConfig, Conversation, EndUser, Message, MessageAgentThought from services.account_service import AccountService, TenantService from services.agent_service import AgentService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -119,16 +119,16 @@ class TestAgentService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "agent-chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="agent-chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_annotation_service.py b/api/tests/test_containers_integration_tests/services/test_annotation_service.py index 95fc73f45a..bc75562d15 100644 --- a/api/tests/test_containers_integration_tests/services/test_annotation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_annotation_service.py @@ -9,7 +9,7 @@ from models import Account from models.enums import ConversationFromSource, InvokeFrom from models.model import MessageAnnotation from services.annotation_service import AppAnnotationService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -86,16 +86,16 @@ class TestAnnotationService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app app_service = AppService() diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index a5ec06dc13..c77bbd3e44 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -37,7 +37,7 @@ from services.app_dsl_service import ( PendingData, _check_version_compatibility, ) -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from tests.test_containers_integration_tests.helpers import generate_valid_password _DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001" @@ -147,16 +147,16 @@ class TestAppDslService: ) TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) return app, account diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index e2fe6c8476..8be4c040b7 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -1,4 +1,5 @@ import uuid +from typing import Literal from unittest.mock import ANY, MagicMock, patch import pytest @@ -133,7 +134,10 @@ class TestAppGenerateService: } def _create_test_app_and_account( - self, db_session_with_containers: Session, mock_external_service_dependencies, mode="chat" + self, + db_session_with_containers: Session, + mock_external_service_dependencies, + mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = "chat", ): """ Helper method to create a test app and account for testing. @@ -165,20 +169,20 @@ class TestAppGenerateService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": mode, - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - "max_active_requests": 5, - } + from services.app_service import AppService, CreateAppParams - from services.app_service import AppService + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode=mode, + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + max_active_requests=5, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index 837b63d1ea..c37fce296f 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -2,6 +2,7 @@ from unittest.mock import create_autospec, patch import pytest from faker import Faker +from pydantic import ValidationError from sqlalchemy.orm import Session from constants.model_template import default_app_templates @@ -12,7 +13,7 @@ from services.account_service import AccountService, TenantService from tests.test_containers_integration_tests.helpers import generate_valid_password # Delay import of AppService to avoid circular dependency -# from services.app_service import AppService +# from services.app_service import AppService, AppListParams, CreateAppParams class TestAppService: @@ -64,34 +65,34 @@ class TestAppService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + # Import here to avoid circular dependency + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app - # Import here to avoid circular dependency - from services.app_service import AppService - app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Verify app was created correctly - assert app.name == app_args["name"] - assert app.description == app_args["description"] - assert app.mode == app_args["mode"] - assert app.icon_type == app_args["icon_type"] - assert app.icon == app_args["icon"] - assert app.icon_background == app_args["icon_background"] + assert app.name == app_params.name + assert app.description == app_params.description + assert app.mode == app_params.mode + assert app.icon_type == app_params.icon_type + assert app.icon == app_params.icon + assert app.icon_background == app_params.icon_background assert app.tenant_id == tenant.id - assert app.api_rph == app_args["api_rph"] - assert app.api_rpm == app_args["api_rpm"] + assert app.api_rph == app_params.api_rph + assert app.api_rpm == app_params.api_rpm assert app.created_by == account.id assert app.updated_by == account.id assert app.status == "normal" @@ -120,7 +121,7 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() @@ -129,20 +130,20 @@ class TestAppService: app_modes = [v.value for v in default_app_templates] for mode in app_modes: - app_args = { - "name": f"{fake.company()} {mode}", - "description": f"Test app for {mode} mode", - "mode": mode, - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_params = CreateAppParams( + name=f"{fake.company()} {mode}", + description=f"Test app for {mode} mode", + mode=mode, + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Verify app mode was set correctly assert app.mode == mode - assert app.name == app_args["name"] + assert app.name == app_params.name assert app.tenant_id == tenant.id assert app.created_by == account.id @@ -163,20 +164,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - created_app = app_service.create_app(tenant.id, app_args, account) + created_app = app_service.create_app(tenant.id, app_params, account) # Get app using the service - needs current_user mock mock_current_user = create_autospec(Account, instance=True) @@ -211,31 +212,27 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create multiple apps app_names = [fake.company() for _ in range(5)] for name in app_names: - app_args = { - "name": name, - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "📱", - "icon_background": "#96CEB4", - } - app_service.create_app(tenant.id, app_args, account) + app_params = CreateAppParams( + name=name, + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="📱", + icon_background="#96CEB4", + ) + app_service.create_app(tenant.id, app_params, account) # Get paginated apps - args = { - "page": 1, - "limit": 10, - "mode": "chat", - } + params = AppListParams(page=1, limit=10, mode="chat") - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Verify pagination results assert paginated_apps is not None @@ -267,60 +264,47 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create apps with different modes - chat_app_args = { - "name": "Chat App", - "description": "A chat application", - "mode": "chat", - "icon_type": "emoji", - "icon": "💬", - "icon_background": "#FF6B6B", - } - completion_app_args = { - "name": "Completion App", - "description": "A completion application", - "mode": "completion", - "icon_type": "emoji", - "icon": "✍️", - "icon_background": "#4ECDC4", - } + chat_app_params = CreateAppParams( + name="Chat App", + description="A chat application", + mode="chat", + icon_type="emoji", + icon="💬", + icon_background="#FF6B6B", + ) + completion_app_params = CreateAppParams( + name="Completion App", + description="A completion application", + mode="completion", + icon_type="emoji", + icon="✍️", + icon_background="#4ECDC4", + ) - chat_app = app_service.create_app(tenant.id, chat_app_args, account) - completion_app = app_service.create_app(tenant.id, completion_app_args, account) + chat_app = app_service.create_app(tenant.id, chat_app_params, account) + completion_app = app_service.create_app(tenant.id, completion_app_params, account) # Test filter by mode - chat_args = { - "page": 1, - "limit": 10, - "mode": "chat", - } - chat_apps = app_service.get_paginate_apps(account.id, tenant.id, chat_args) + chat_apps = app_service.get_paginate_apps(account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")) assert len(chat_apps.items) == 1 assert chat_apps.items[0].mode == "chat" # Test filter by name - name_args = { - "page": 1, - "limit": 10, - "mode": "chat", - "name": "Chat", - } - filtered_apps = app_service.get_paginate_apps(account.id, tenant.id, name_args) + filtered_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", name="Chat") + ) assert len(filtered_apps.items) == 1 assert "Chat" in filtered_apps.items[0].name # Test filter by created_by_me - created_by_me_args = { - "page": 1, - "limit": 10, - "mode": "completion", - "is_created_by_me": True, - } - my_apps = app_service.get_paginate_apps(account.id, tenant.id, created_by_me_args) + my_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="completion", is_created_by_me=True) + ) assert len(my_apps.items) == 1 def test_get_paginate_apps_with_tag_filters( @@ -342,34 +326,29 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create an app - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🏷️", - "icon_background": "#FFEAA7", - } - app = app_service.create_app(tenant.id, app_args, account) + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🏷️", + icon_background="#FFEAA7", + ) + app = app_service.create_app(tenant.id, app_params, account) # Mock TagService to return the app ID for tag filtering with patch("services.app_service.TagService.get_target_ids_by_tag_ids") as mock_tag_service: mock_tag_service.return_value = [app.id] # Test with tag filter - args = { - "page": 1, - "limit": 10, - "mode": "chat", - "tag_ids": ["tag1", "tag2"], - } + params = AppListParams(page=1, limit=10, mode="chat", tag_ids=["tag1", "tag2"]) - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Verify tag service was called mock_tag_service.assert_called_once_with("app", tenant.id, ["tag1", "tag2"]) @@ -383,14 +362,9 @@ class TestAppService: with patch("services.app_service.TagService.get_target_ids_by_tag_ids") as mock_tag_service: mock_tag_service.return_value = [] - args = { - "page": 1, - "limit": 10, - "mode": "chat", - "tag_ids": ["nonexistent_tag"], - } + params = AppListParams(page=1, limit=10, mode="chat", tag_ids=["nonexistent_tag"]) - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Should return None when no apps match tag filter assert paginated_apps is None @@ -412,20 +386,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original values original_name = app.name @@ -481,19 +455,19 @@ class TestAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ), account, ) @@ -533,19 +507,19 @@ class TestAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ), account, ) @@ -584,20 +558,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original name original_name = app.name @@ -637,20 +611,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original values original_icon = app.icon @@ -698,18 +672,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🌐", - "icon_background": "#74B9FF", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🌐", + icon_background="#74B9FF", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -758,18 +731,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔌", - "icon_background": "#A29BFE", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔌", + icon_background="#A29BFE", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -818,18 +790,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔄", - "icon_background": "#FD79A8", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔄", + icon_background="#FD79A8", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -869,18 +840,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🗑️", - "icon_background": "#E17055", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🗑️", + icon_background="#E17055", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -921,18 +891,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🧹", - "icon_background": "#00B894", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🧹", + icon_background="#00B894", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -981,18 +950,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "📊", - "icon_background": "#6C5CE7", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="📊", + icon_background="#6C5CE7", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1020,18 +988,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔗", - "icon_background": "#FDCB6E", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔗", + icon_background="#FDCB6E", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1060,18 +1027,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🆔", - "icon_background": "#E84393", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🆔", + icon_background="#E84393", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1107,26 +1073,20 @@ class TestAppService: password=generate_valid_password(fake), ) TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) - tenant = account.current_tenant - - # Setup app creation arguments with invalid mode - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "invalid_mode", # Invalid mode - "icon_type": "emoji", - "icon": "❌", - "icon_background": "#D63031", - } # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import CreateAppParams - app_service = AppService() - - # Attempt to create app with invalid mode - with pytest.raises(ValueError, match="invalid mode value"): - app_service.create_app(tenant.id, app_args, account) + # Attempt to create app with invalid mode - Pydantic will reject invalid literal + with pytest.raises(ValidationError): + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="invalid_mode", # type: ignore[arg-type] + icon_type="emoji", + icon="❌", + icon_background="#D63031", + ) def test_get_apps_with_special_characters_in_name( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -1152,99 +1112,103 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create apps with special characters in names app_with_percent = app_service.create_app( tenant.id, - { - "name": "App with 50% discount", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="App with 50% discount", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) app_with_underscore = app_service.create_app( tenant.id, - { - "name": "test_data_app", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="test_data_app", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) app_with_backslash = app_service.create_app( tenant.id, - { - "name": "path\\to\\app", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="path\\to\\app", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) # Create app that should NOT match app_no_match = app_service.create_app( tenant.id, - { - "name": "100% different", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="100% different", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) # Test 1: Search with % character - args = {"name": "50%", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "App with 50% discount" # Test 2: Search with _ character - args = {"name": "test_data", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="test_data", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "test_data_app" # Test 3: Search with \ character - args = {"name": "path\\to\\app", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="path\\to\\app", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "path\\to\\app" # Test 4: Search with % should NOT match 100% (verifies escaping works) - args = {"name": "50%", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert all("50%" in app.name for app in paginated_apps.items) diff --git a/api/tests/test_containers_integration_tests/services/test_message_service.py b/api/tests/test_containers_integration_tests/services/test_message_service.py index bdf6d9b951..6d0d281c6b 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from models.enums import ConversationFromSource, FeedbackRating, InvokeFrom from models.model import MessageFeedback -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.errors.message import ( FirstMessageNotExistsError, LastMessageNotExistsError, @@ -103,16 +103,16 @@ class TestMessageService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "advanced-chat", # Use advanced-chat mode to use mocked workflow - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="advanced-chat", # Use advanced-chat mode to use mocked workflow, + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app app_service = AppService() diff --git a/api/tests/test_containers_integration_tests/services/test_ops_service.py b/api/tests/test_containers_integration_tests/services/test_ops_service.py index e2e1a228b2..ff76bce416 100644 --- a/api/tests/test_containers_integration_tests/services/test_ops_service.py +++ b/api/tests/test_containers_integration_tests/services/test_ops_service.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from core.ops.entities.config_entity import TracingProviderEnum from models.model import TraceAppConfig from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.ops_service import OpsService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -57,14 +57,14 @@ class TestOpsService: app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ), account, ) return app, account diff --git a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py index 7b9e9924cd..7368ad4249 100644 --- a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py @@ -8,7 +8,7 @@ from models import App, CreatorUserRole from models.enums import ConversationFromSource from models.model import EndUser, Message from models.web import SavedMessage -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.saved_message_service import SavedMessageService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -73,16 +73,16 @@ class TestSavedMessageService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py index 797731d04b..8e53a2d6cd 100644 --- a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py @@ -11,7 +11,7 @@ from models.enums import ConversationFromSource from models.model import Conversation, EndUser from models.web import PinnedConversation from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.web_conversation_service import WebConversationService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -77,16 +77,16 @@ class TestWebConversationService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py index a2cdddad61..07a49130d0 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py @@ -17,7 +17,7 @@ from models.workflow import WorkflowAppLogCreatedFrom from services.account_service import AccountService, TenantService # Delay import of AppService to avoid circular dependency -# from services.app_service import AppService +# from services.app_service import AppService, CreateAppParams from services.workflow_app_service import LogView, WorkflowAppService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -82,20 +82,20 @@ class TestWorkflowAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -146,20 +146,20 @@ class TestWorkflowAppService: """ fake = Faker() - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py index d02a078281..09fe1570bc 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py @@ -13,7 +13,7 @@ from models.model import ( ) from models.workflow import WorkflowRun from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.workflow_run_service import WorkflowRunService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -79,16 +79,16 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -535,13 +535,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Create workflow run without node executions @@ -586,13 +586,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Use invalid workflow run ID @@ -637,13 +637,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Create workflow run diff --git a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py index 21a1975879..9b574fe2df 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py @@ -11,7 +11,7 @@ from core.tools.errors import WorkflowToolHumanInputNotSupportedError from models.tools import WorkflowToolProvider from models.workflow import Workflow as WorkflowModel from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.tools.workflow_tools_manage_service import WorkflowToolManageService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -94,16 +94,16 @@ class TestWorkflowToolManageService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) From 1082f488a12cc26e28c41a03558547838aeb5eb4 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Mon, 11 May 2026 14:16:56 +0800 Subject: [PATCH 41/53] refactor: enhance modal layouts and scrolling behavior across components (#36033) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- .../config-modal/__tests__/index.spec.tsx | 19 ++++ .../config-var/config-modal/index.tsx | 21 +++-- .../dataset-config/params-config/index.tsx | 2 +- .../annotation-reply/config-param-modal.tsx | 2 +- .../moderation/moderation-setting-modal.tsx | 2 +- .../api-based-extension-page/modal.tsx | 2 +- .../model-load-balancing-modal.tsx | 2 +- .../components/tools/mcp/mcp-server-modal.tsx | 87 ++++++++++--------- .../tools/mcp/mcp-server-param-item.tsx | 10 +-- web/app/components/tools/mcp/modal.tsx | 2 +- .../http/components/authorization/index.tsx | 2 +- .../json-schema-config-modal/index.tsx | 2 +- 12 files changed, 91 insertions(+), 62 deletions(-) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx index 094a293943..a0679a0376 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -71,6 +71,25 @@ describe('ConfigModal', () => { }), undefined) }) + it('should keep scrolling inside the form body so scrollbars do not cover dialog corners', () => { + render( + <ConfigModal + isCreate + isShow + payload={createPayload({ label: 'Question' })} + onClose={vi.fn()} + onConfirm={vi.fn()} + />, + ) + + const dialog = screen.getByRole('dialog') + const scrollArea = screen.getByTestId('config-modal-scroll-area') + + expect(dialog).toHaveClass('overflow-hidden!') + expect(scrollArea).toHaveClass('overflow-y-auto') + expect(scrollArea).toHaveClass('overflow-x-hidden') + }) + it('should block save when the label is missing', () => { render( <ConfigModal diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 1976a4ffa0..7e553092c0 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -148,12 +148,17 @@ const ConfigModal: FC<IConfigModalProps> = ({ onClose() }} > - <DialogContent className="overflow-hidden! border-none text-left align-middle"> - <DialogTitle className="title-2xl-semi-bold text-text-primary"> + <DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden! border-none p-0! text-left align-middle"> + <DialogTitle className="shrink-0 px-6 pt-6 title-2xl-semi-bold text-text-primary"> {t(`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`, { ns: 'appDebug' })} </DialogTitle> - <div className="mb-8" ref={modalRef} tabIndex={-1}> + <div + ref={modalRef} + tabIndex={-1} + data-testid="config-modal-scroll-area" + className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-6 py-4 pb-8" + > <ConfigModalFormFields checkboxDefaultSelectValue={checkboxDefaultSelectValue} isStringInput={isStringInput} @@ -172,10 +177,12 @@ const ConfigModal: FC<IConfigModalProps> = ({ t={t} /> </div> - <ModalFoot - onConfirm={handleConfirm} - onCancel={onClose} - /> + <div className="shrink-0 px-6 pt-2 pb-6"> + <ModalFoot + onConfirm={handleConfirm} + onCancel={onClose} + /> + </div> </DialogContent> </Dialog> ) diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.tsx index 15d34f19d7..edfffd5726 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.tsx @@ -131,7 +131,7 @@ const ParamsConfig = ({ } }} > - <DialogContent className="w-full max-w-[480px] overflow-hidden! border-none text-left align-middle sm:min-w-[528px]"> + <DialogContent className="w-full max-w-[480px] border-none text-left align-middle sm:min-w-[528px]"> <ConfigContent datasetConfigs={tempDataSetConfigs} diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx index 73f76a6ce6..e8932979a6 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx @@ -65,7 +65,7 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a onHide() }} > - <DialogContent className="!mt-14 !w-[640px] !max-w-none overflow-hidden! border-none !p-6 text-left align-middle"> + <DialogContent className="!mt-14 !w-[640px] !max-w-none border-none !p-6 text-left align-middle"> <div className="mb-2 title-2xl-semi-bold text-text-primary"> {t(`initSetup.${isInit ? 'title' : 'configTitle'}`, { ns: 'appAnnotation' })} diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index 7937f05bf4..7ebe539a3d 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -226,7 +226,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ return ( <Dialog open> - <DialogContent className="mt-14! w-[600px]! max-w-none! overflow-hidden! border-none p-6! text-left align-middle"> + <DialogContent className="mt-14! w-[600px]! max-w-none! border-none p-6! text-left align-middle"> <div className="flex items-center justify-between"> <div className="title-2xl-semi-bold text-text-primary">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div> diff --git a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx index f75b42bc45..a838c49001 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx @@ -61,7 +61,7 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({ data, onCance } return ( <Dialog open> - <DialogContent className="w-[640px]! max-w-none! overflow-hidden! border-none p-8! pb-6! text-left align-middle"> + <DialogContent className="w-[640px]! max-w-none! border-none p-8! pb-6! text-left align-middle"> <div className="mb-2 text-xl font-semibold text-text-primary"> {data.name diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx index 15632fb898..4ed93a98e8 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx @@ -187,7 +187,7 @@ const ModelLoadBalancingModal = ({ provider, configurateMethod, currentCustomCon onClose?.() }} > - <DialogContent className="w-[640px] max-w-none overflow-hidden! border-none px-8 pt-8 text-left align-middle"> + <DialogContent className="w-[640px] max-w-none border-none px-8 pt-8 text-left align-middle"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> <div className="pb-3 font-semibold"> <div className="h-[30px]"> diff --git a/web/app/components/tools/mcp/mcp-server-modal.tsx b/web/app/components/tools/mcp/mcp-server-modal.tsx index e575988067..f4653a33eb 100644 --- a/web/app/components/tools/mcp/mcp-server-modal.tsx +++ b/web/app/components/tools/mcp/mcp-server-modal.tsx @@ -134,7 +134,7 @@ const MCPServerModal = ({ onHide() }} > - <DialogContent className="w-[calc(100vw-2rem)] max-w-[520px]! overflow-hidden! border-none p-0! text-left align-middle transition-all duration-100 ease-in"> + <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-[520px]! flex-col overflow-hidden! border-none p-0! text-left align-middle transition-all duration-100 ease-in"> <button type="button" aria-label={t('operation.close', { ns: 'common' })} @@ -143,52 +143,55 @@ const MCPServerModal = ({ > <RiCloseLine className="h-5 w-5 text-text-tertiary" aria-hidden="true" /> </button> - <div className="relative p-6 pb-3 title-2xl-semi-bold text-xl text-text-primary"> + <div className="relative shrink-0 p-6 pr-12 pb-3 title-2xl-semi-bold text-xl wrap-break-word text-text-primary"> {!data ? t('mcp.server.modal.addTitle', { ns: 'tools' }) : t('mcp.server.modal.editTitle', { ns: 'tools' })} </div> - <div className="space-y-5 px-6 py-3"> - <div className="space-y-0.5"> - <div className="flex h-6 items-center gap-1"> - <div className="system-sm-medium text-text-secondary">{t('mcp.server.modal.description', { ns: 'tools' })}</div> - <div className="system-xs-regular text-text-destructive-secondary">*</div> + <div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto"> + <div className="min-w-0 space-y-5 px-6 py-3"> + <div className="space-y-0.5"> + <div className="flex h-6 items-center gap-1"> + <div className="system-sm-medium text-text-secondary">{t('mcp.server.modal.description', { ns: 'tools' })}</div> + <div className="system-xs-regular text-text-destructive-secondary">*</div> + </div> + <Textarea + className="h-[96px] resize-none" + value={description} + placeholder={t('mcp.server.modal.descriptionPlaceholder', { ns: 'tools' })} + onChange={e => setDescription(e.target.value)} + > + </Textarea> </div> - <Textarea - className="h-[96px] resize-none" - value={description} - placeholder={t('mcp.server.modal.descriptionPlaceholder', { ns: 'tools' })} - onChange={e => setDescription(e.target.value)} - > - </Textarea> + + {latestParams.length > 0 && ( + <div className="min-w-0"> + <div className="mb-1 flex items-center gap-2"> + <div className="shrink-0 system-xs-medium-uppercase text-text-primary">{t('mcp.server.modal.parameters', { ns: 'tools' })}</div> + <Divider type="horizontal" className="m-0! h-px! grow bg-divider-subtle" /> + </div> + <div className="mb-2 body-xs-regular text-text-tertiary">{t('mcp.server.modal.parametersTip', { ns: 'tools' })}</div> + <div className="min-w-0 space-y-3"> + {latestParams.map((paramItem) => { + if (!paramItem.variable) + return null + + const { variable } = paramItem + + return ( + <MCPServerParamItem + key={variable} + data={paramItem} + value={params[variable] || ''} + onChange={value => handleParamChange(variable, value)} + /> + ) + })} + </div> + </div> + )} </div> - {latestParams.length > 0 && ( - <div> - <div className="mb-1 flex items-center gap-2"> - <div className="shrink-0 system-xs-medium-uppercase text-text-primary">{t('mcp.server.modal.parameters', { ns: 'tools' })}</div> - <Divider type="horizontal" className="m-0! h-px! grow bg-divider-subtle" /> - </div> - <div className="mb-2 body-xs-regular text-text-tertiary">{t('mcp.server.modal.parametersTip', { ns: 'tools' })}</div> - <div className="space-y-3"> - {latestParams.map((paramItem) => { - if (!paramItem.variable) - return null - - const { variable } = paramItem - - return ( - <MCPServerParamItem - key={variable} - data={paramItem} - value={params[variable] || ''} - onChange={value => handleParamChange(variable, value)} - /> - ) - })} - </div> - </div> - )} </div> - <div className="flex flex-row-reverse p-6 pt-5"> - <Button disabled={!description || creating || updating} className="ml-2" variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.server.modal.confirm', { ns: 'tools' })}</Button> + <div className="flex shrink-0 flex-row-reverse flex-wrap gap-2 p-6 pt-5"> + <Button disabled={!description || creating || updating} variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.server.modal.confirm', { ns: 'tools' })}</Button> <Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button> </div> </DialogContent> diff --git a/web/app/components/tools/mcp/mcp-server-param-item.tsx b/web/app/components/tools/mcp/mcp-server-param-item.tsx index db27cfdf98..316bbca556 100644 --- a/web/app/components/tools/mcp/mcp-server-param-item.tsx +++ b/web/app/components/tools/mcp/mcp-server-param-item.tsx @@ -17,12 +17,12 @@ const MCPServerParamItem = ({ const { t } = useTranslation() return ( - <div className="space-y-0.5"> - <div className="flex h-6 items-center gap-2"> - <div className="system-xs-medium text-text-secondary">{data.label}</div> + <div className="min-w-0 space-y-0.5"> + <div className="flex min-h-6 min-w-0 flex-wrap items-center gap-2"> + <div className="max-w-full min-w-0 system-xs-medium wrap-break-word text-text-secondary">{data.label}</div> <div className="system-xs-medium text-text-quaternary">·</div> - <div className="system-xs-medium text-text-secondary">{data.variable}</div> - <div className="system-xs-medium text-text-tertiary">{data.type}</div> + <div className="max-w-full min-w-0 system-xs-medium break-all text-text-secondary">{data.variable}</div> + <div className="max-w-full min-w-0 system-xs-medium wrap-break-word text-text-tertiary">{data.type}</div> </div> <Textarea className="h-8 resize-none" diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index 8df2d1f635..0fe81589a8 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -285,7 +285,7 @@ const MCPModal: FC<DuplicateAppModalProps> = ({ return ( <Dialog open={show}> - <DialogContent className="w-full max-w-[520px]! overflow-hidden! border-none p-6 text-left align-middle"> + <DialogContent className="w-full max-w-[520px]! border-none p-6 text-left align-middle"> <MCPModalContent key={formKey} data={data} diff --git a/web/app/components/workflow/nodes/http/components/authorization/index.tsx b/web/app/components/workflow/nodes/http/components/authorization/index.tsx index 684f943d5f..a3d8fc7331 100644 --- a/web/app/components/workflow/nodes/http/components/authorization/index.tsx +++ b/web/app/components/workflow/nodes/http/components/authorization/index.tsx @@ -122,7 +122,7 @@ const Authorization: FC<Props> = ({ onHide() }} > - <DialogContent className="overflow-hidden! border-none text-left align-middle"> + <DialogContent className="border-none text-left align-middle"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> {t(`${i18nPrefix}.authorization`, { ns: 'workflow' })} </DialogTitle> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx index f4445fd54d..5b3d187406 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx @@ -23,7 +23,7 @@ export function JsonSchemaConfigModal({ onClose() }} > - <DialogContent className="h-[800px] max-h-none w-full max-w-[960px] overflow-hidden! border-none p-0 text-left align-middle"> + <DialogContent className="h-[calc(100dvh-32px)] max-h-[800px] w-full max-w-[960px] overflow-hidden! border-none p-0 text-left align-middle"> <JsonSchemaConfig defaultSchema={defaultSchema} From d625ac0bf191fab622e82e6fd25de3db0e6f190f Mon Sep 17 00:00:00 2001 From: Asuka Minato <i@asukaminato.eu.org> Date: Mon, 11 May 2026 15:39:59 +0900 Subject: [PATCH 42/53] refactor: port some if else to match (#31841) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/datasets/datasets.py | 96 +++++++++---------- .../console/datasets/datasets_document.py | 45 +++++---- api/core/prompt/simple_prompt_transform.py | 15 +-- .../entities/langsmith_trace_entity.py | 62 ++++++------ .../dify_vdb_opensearch/opensearch_vector.py | 13 +-- 5 files changed, 122 insertions(+), 109 deletions(-) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index d001dfba64..0e91779b2c 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -606,63 +606,63 @@ class DatasetIndexingEstimateApi(Resource): # validate args DocumentService.estimate_args_validate(args) extract_settings = [] - if args["info_list"]["data_source_type"] == "upload_file": - file_ids = args["info_list"]["file_info_list"]["file_ids"] - file_details = db.session.scalars( - select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids)) - ).all() + match args["info_list"]["data_source_type"]: + case "upload_file": + file_ids = args["info_list"]["file_info_list"]["file_ids"] + file_details = db.session.scalars( + select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids)) + ).all() + if file_details is None: + raise NotFound("File not found.") - if file_details is None: - raise NotFound("File not found.") - - if file_details: - for file_detail in file_details: + if file_details: + for file_detail in file_details: + extract_setting = ExtractSetting( + datasource_type=DatasourceType.FILE, + upload_file=file_detail, + document_model=args["doc_form"], + ) + extract_settings.append(extract_setting) + case "notion_import": + notion_info_list = args["info_list"]["notion_info_list"] + for notion_info in notion_info_list: + workspace_id = notion_info["workspace_id"] + credential_id = notion_info.get("credential_id") + for page in notion_info["pages"]: + extract_setting = ExtractSetting( + datasource_type=DatasourceType.NOTION, + notion_info=NotionInfo.model_validate( + { + "credential_id": credential_id, + "notion_workspace_id": workspace_id, + "notion_obj_id": page["page_id"], + "notion_page_type": page["type"], + "tenant_id": current_tenant_id, + } + ), + document_model=args["doc_form"], + ) + extract_settings.append(extract_setting) + case "website_crawl": + website_info_list = args["info_list"]["website_info_list"] + for url in website_info_list["urls"]: extract_setting = ExtractSetting( - datasource_type=DatasourceType.FILE, - upload_file=file_detail, - document_model=args["doc_form"], - ) - extract_settings.append(extract_setting) - elif args["info_list"]["data_source_type"] == "notion_import": - notion_info_list = args["info_list"]["notion_info_list"] - for notion_info in notion_info_list: - workspace_id = notion_info["workspace_id"] - credential_id = notion_info.get("credential_id") - for page in notion_info["pages"]: - extract_setting = ExtractSetting( - datasource_type=DatasourceType.NOTION, - notion_info=NotionInfo.model_validate( + datasource_type=DatasourceType.WEBSITE, + website_info=WebsiteInfo.model_validate( { - "credential_id": credential_id, - "notion_workspace_id": workspace_id, - "notion_obj_id": page["page_id"], - "notion_page_type": page["type"], + "provider": website_info_list["provider"], + "job_id": website_info_list["job_id"], + "url": url, "tenant_id": current_tenant_id, + "mode": "crawl", + "only_main_content": website_info_list["only_main_content"], } ), document_model=args["doc_form"], ) extract_settings.append(extract_setting) - elif args["info_list"]["data_source_type"] == "website_crawl": - website_info_list = args["info_list"]["website_info_list"] - for url in website_info_list["urls"]: - extract_setting = ExtractSetting( - datasource_type=DatasourceType.WEBSITE, - website_info=WebsiteInfo.model_validate( - { - "provider": website_info_list["provider"], - "job_id": website_info_list["job_id"], - "url": url, - "tenant_id": current_tenant_id, - "mode": "crawl", - "only_main_content": website_info_list["only_main_content"], - } - ), - document_model=args["doc_form"], - ) - extract_settings.append(extract_setting) - else: - raise ValueError("Data source type not support") + case _: + raise ValueError("Data source type not support") indexing_runner = IndexingRunner() try: response = indexing_runner.indexing_estimate( diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 3372a967d9..c4e13c41a5 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -369,28 +369,31 @@ class DatasetDocumentListApi(Resource): else: sort_logic = asc - if sort == "hit_count": - sub_query = ( - sa.select(DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count")) - .where(DocumentSegment.dataset_id == str(dataset_id)) - .group_by(DocumentSegment.document_id) - .subquery() - ) + match sort: + case "hit_count": + sub_query = ( + sa.select( + DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count") + ) + .where(DocumentSegment.dataset_id == str(dataset_id)) + .group_by(DocumentSegment.document_id) + .subquery() + ) - query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id).order_by( - sort_logic(sa.func.coalesce(sub_query.c.total_hit_count, 0)), - sort_logic(Document.position), - ) - elif sort == "created_at": - query = query.order_by( - sort_logic(Document.created_at), - sort_logic(Document.position), - ) - else: - query = query.order_by( - desc(Document.created_at), - desc(Document.position), - ) + query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id).order_by( + sort_logic(sa.func.coalesce(sub_query.c.total_hit_count, 0)), + sort_logic(Document.position), + ) + case "created_at": + query = query.order_by( + sort_logic(Document.created_at), + sort_logic(Document.position), + ) + case _: + query = query.order_by( + desc(Document.created_at), + desc(Document.position), + ) paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False) documents = paginated_documents.items diff --git a/api/core/prompt/simple_prompt_transform.py b/api/core/prompt/simple_prompt_transform.py index 1665bdeb52..e836554ca0 100644 --- a/api/core/prompt/simple_prompt_transform.py +++ b/api/core/prompt/simple_prompt_transform.py @@ -123,12 +123,15 @@ class SimplePromptTransform(PromptTransform): for v in special_variable_keys: # support #context#, #query# and #histories# - if v == "#context#": - variables["#context#"] = context or "" - elif v == "#query#": - variables["#query#"] = query or "" - elif v == "#histories#": - variables["#histories#"] = histories or "" + match v: + case "#context#": + variables["#context#"] = context or "" + case "#query#": + variables["#query#"] = query or "" + case "#histories#": + variables["#histories#"] = histories or "" + case _: + pass prompt_template = prompt_template_config["prompt_template"] if not isinstance(prompt_template, PromptTemplateParser): diff --git a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py index f73ba01c8b..be9d64ae01 100644 --- a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py @@ -65,35 +65,18 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel): } file_list = values.get("file_list", []) if isinstance(v, str): - if field_name == "inputs": - return { - "messages": { - "role": "user", - "content": v, - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } - elif field_name == "outputs": - return { - "choices": { - "role": "ai", - "content": v, - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } - elif isinstance(v, list): - data = {} - if len(v) > 0 and isinstance(v[0], dict): - # rename text to content - v = replace_text_with_content(data=v) - if field_name == "inputs": - data = { - "messages": v, + match field_name: + case "inputs": + return { + "messages": { + "role": "user", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, } - elif field_name == "outputs": - data = { + case "outputs": + return { "choices": { "role": "ai", "content": v, @@ -101,6 +84,29 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel): "file_list": file_list, }, } + case _: + pass + elif isinstance(v, list): + data = {} + if len(v) > 0 and isinstance(v[0], dict): + # rename text to content + v = replace_text_with_content(data=v) + match field_name: + case "inputs": + data = { + "messages": v, + } + case "outputs": + data = { + "choices": { + "role": "ai", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, + } + case _: + pass return data else: return { diff --git a/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py index 843c495d82..d6998f6672 100644 --- a/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py +++ b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py @@ -81,14 +81,15 @@ class OpenSearchConfig(BaseModel): pool_maxsize=20, ) - if self.auth_method == "basic": - logger.info("Using basic authentication for OpenSearch Vector DB") + match self.auth_method: + case AuthMethod.BASIC: + logger.info("Using basic authentication for OpenSearch Vector DB") - params["http_auth"] = (self.user, self.password) - elif self.auth_method == "aws_managed_iam": - logger.info("Using AWS managed IAM role for OpenSearch Vector DB") + params["http_auth"] = (self.user, self.password) + case AuthMethod.AWS_MANAGED_IAM: + logger.info("Using AWS managed IAM role for OpenSearch Vector DB") - params["http_auth"] = self.create_aws_managed_iam_auth() + params["http_auth"] = self.create_aws_managed_iam_auth() return params From 7fc40e6c9ef7ec1caf03cd21aa40f52596b3954e Mon Sep 17 00:00:00 2001 From: Blackoutta <37723456+Blackoutta@users.noreply.github.com> Date: Mon, 11 May 2026 16:37:17 +0800 Subject: [PATCH 43/53] feat: improve phoenix workflow tracing (#35605) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: -LAN- <laipz8200@outlook.com> --- api/.env.example | 4 + api/configs/feature/__init__.py | 13 + api/core/app/apps/workflow/app_generator.py | 3 +- api/core/app/workflow/layers/persistence.py | 9 +- api/core/helper/trace_id_helper.py | 35 + api/core/ops/entities/trace_entity.py | 21 +- api/core/ops/exceptions.py | 22 + api/core/ops/ops_trace_manager.py | 24 +- api/core/tools/workflow_as_tool/tool.py | 32 +- api/core/workflow/node_runtime.py | 33 +- .../arize_phoenix_trace.py | 493 ++++++- .../test_arize_phoenix_trace.py | 1194 ++++++++++++++++- .../unit_tests/test_arize_phoenix_trace.py | 36 - api/tasks/ops_trace_task.py | 91 +- .../app/apps/test_workflow_app_generator.py | 71 + .../app/workflow/test_persistence_layer.py | 54 + .../core/helper/test_trace_id_helper.py | 97 +- .../core/tools/workflow_as_tool/test_tool.py | 136 ++ .../workflow/nodes/tool/test_tool_node.py | 25 +- .../nodes/tool/test_tool_node_runtime.py | 63 + .../core/workflow/test_node_runtime.py | 75 ++ .../unit_tests/tasks/test_ops_trace_task.py | 301 +++++ docker/.env.example | 1 + docker/envs/core-services/shared.env.example | 2 + 24 files changed, 2727 insertions(+), 108 deletions(-) create mode 100644 api/core/ops/exceptions.py delete mode 100644 api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py create mode 100644 api/tests/unit_tests/tasks/test_ops_trace_task.py diff --git a/api/.env.example b/api/.env.example index ba153e4c9c..40fed7403c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -88,6 +88,10 @@ REDIS_HEALTH_CHECK_INTERVAL=30 CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1 CELERY_BACKEND=redis +# Ops trace retry configuration +OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60 +OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5 + # Database configuration DB_TYPE=postgresql DB_USERNAME=postgres diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index e9bb34fa75..26b8ea670b 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1137,6 +1137,18 @@ class MultiModalTransferConfig(BaseSettings): ) +class OpsTraceConfig(BaseSettings): + OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field( + description="Maximum retry attempts for transient ops trace provider dispatch failures.", + default=60, + ) + + OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field( + description="Delay in seconds between transient ops trace provider dispatch retry attempts.", + default=5, + ) + + class CeleryBeatConfig(BaseSettings): CELERY_BEAT_SCHEDULER_TIME: int = Field( description="Interval in days for Celery Beat scheduler execution, default to 1 day", @@ -1417,6 +1429,7 @@ class FeatureConfig( ModelLoadBalanceConfig, ModerationConfig, MultiModalTransferConfig, + OpsTraceConfig, PositionConfig, RagEtlConfig, RepositoryConfig, diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index e811c2b2e0..43546d57f5 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -32,7 +32,7 @@ from core.app.entities.task_entities import ( ) from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer from core.db.session_factory import session_factory -from core.helper.trace_id_helper import extract_external_trace_id_from_args +from core.helper.trace_id_helper import extract_external_trace_id_from_args, extract_parent_trace_context_from_args from core.ops.ops_trace_manager import TraceQueueManager from core.repositories import DifyCoreRepositoryFactory from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository @@ -166,6 +166,7 @@ class WorkflowAppGenerator(BaseAppGenerator): extras = { **extract_external_trace_id_from_args(args), + **extract_parent_trace_context_from_args(args), } workflow_run_id = str(workflow_run_id or uuid.uuid4()) # FIXME (Yeuoly): we need to remove the SKIP_PREPARE_USER_INPUTS_KEY from the args diff --git a/api/core/app/workflow/layers/persistence.py b/api/core/app/workflow/layers/persistence.py index d521304615..19152cebae 100644 --- a/api/core/app/workflow/layers/persistence.py +++ b/api/core/app/workflow/layers/persistence.py @@ -15,6 +15,7 @@ from datetime import datetime from typing import Any, Union from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity +from core.helper.trace_id_helper import ParentTraceContext from core.ops.entities.trace_entity import TraceTaskName from core.ops.ops_trace_manager import TraceQueueManager, TraceTask from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository @@ -403,8 +404,13 @@ class WorkflowPersistenceLayer(GraphEngineLayer): conversation_id = self._system_variables().get(SystemVariableKey.CONVERSATION_ID.value) external_trace_id = None + parent_trace_context = None if isinstance(self._application_generate_entity, (WorkflowAppGenerateEntity, AdvancedChatAppGenerateEntity)): - external_trace_id = self._application_generate_entity.extras.get("external_trace_id") + extras = self._application_generate_entity.extras + external_trace_id = extras.get("external_trace_id") + parent_trace_context = extras.get("parent_trace_context") + if isinstance(parent_trace_context, ParentTraceContext): + parent_trace_context = parent_trace_context.model_dump(exclude_none=True) trace_task = TraceTask( TraceTaskName.WORKFLOW_TRACE, @@ -412,6 +418,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer): conversation_id=conversation_id, user_id=self._trace_manager.user_id, external_trace_id=external_trace_id, + parent_trace_context=parent_trace_context, ) self._trace_manager.add_trace_task(trace_task) diff --git a/api/core/helper/trace_id_helper.py b/api/core/helper/trace_id_helper.py index e827859109..e4890c8d4d 100644 --- a/api/core/helper/trace_id_helper.py +++ b/api/core/helper/trace_id_helper.py @@ -3,6 +3,17 @@ import re from collections.abc import Mapping from typing import Any +from pydantic import BaseModel, ConfigDict, StrictStr, ValidationError + + +class ParentTraceContext(BaseModel): + """Typed parent trace context propagated from an outer workflow tool node.""" + + parent_workflow_run_id: StrictStr + parent_node_execution_id: StrictStr | None = None + + model_config = ConfigDict(extra="forbid") + def is_valid_trace_id(trace_id: str) -> bool: """ @@ -61,6 +72,30 @@ def extract_external_trace_id_from_args(args: Mapping[str, Any]): return {} +def extract_parent_trace_context_from_args(args: Mapping[str, Any]) -> dict[str, ParentTraceContext]: + """ + Extract 'parent_trace_context' from args. + + Returns a dict suitable for use in extras when both parent identifiers exist. + Returns an empty dict if the context is missing or incomplete. + """ + parent_trace_context = args.get("parent_trace_context") + if isinstance(parent_trace_context, ParentTraceContext): + context = parent_trace_context + elif isinstance(parent_trace_context, Mapping): + try: + context = ParentTraceContext.model_validate(parent_trace_context) + except ValidationError: + return {} + else: + return {} + + if context.parent_node_execution_id is None: + return {} + + return {"parent_trace_context": context} + + def get_trace_id_from_otel_context() -> str | None: """ Retrieve the current trace ID from the active OpenTelemetry trace context. diff --git a/api/core/ops/entities/trace_entity.py b/api/core/ops/entities/trace_entity.py index 45b2f635ba..98e87a0ceb 100644 --- a/api/core/ops/entities/trace_entity.py +++ b/api/core/ops/entities/trace_entity.py @@ -5,6 +5,8 @@ from typing import Any, Union from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from core.helper.trace_id_helper import ParentTraceContext + class BaseTraceInfo(BaseModel): message_id: str | None = None @@ -51,8 +53,8 @@ class BaseTraceInfo(BaseModel): def resolved_parent_context(self) -> tuple[str | None, str | None]: """Resolve cross-workflow parent linking from metadata. - Extracts typed parent IDs from the untyped ``parent_trace_context`` - metadata dict (set by tool_node when invoking nested workflows). + Extracts typed parent IDs from the ``parent_trace_context`` metadata + payload (set by tool_node when invoking nested workflows). Returns: (trace_correlation_override, parent_span_id_source) where @@ -60,13 +62,18 @@ class BaseTraceInfo(BaseModel): parent_span_id_source is the outer node_execution_id. """ parent_ctx = self.metadata.get("parent_trace_context") - if not isinstance(parent_ctx, dict): + if isinstance(parent_ctx, ParentTraceContext): + context = parent_ctx + elif isinstance(parent_ctx, Mapping): + try: + context = ParentTraceContext.model_validate(parent_ctx) + except ValueError: + return None, None + else: return None, None - trace_override = parent_ctx.get("parent_workflow_run_id") - parent_span = parent_ctx.get("parent_node_execution_id") return ( - trace_override if isinstance(trace_override, str) else None, - parent_span if isinstance(parent_span, str) else None, + context.parent_workflow_run_id, + context.parent_node_execution_id, ) @field_serializer("start_time", "end_time") diff --git a/api/core/ops/exceptions.py b/api/core/ops/exceptions.py new file mode 100644 index 0000000000..4551704687 --- /dev/null +++ b/api/core/ops/exceptions.py @@ -0,0 +1,22 @@ +"""Core exceptions shared by ops trace dispatchers and trace providers. + +Provider packages may raise these types to request generic task behavior, but +generic Celery tasks should not import provider-specific exception classes. +""" + + +class RetryableTraceDispatchError(RuntimeError): + """Base class for transient trace dispatch failures that Celery may retry.""" + + +class PendingTraceParentContextError(RetryableTraceDispatchError): + """Raised when a nested trace arrives before its parent span context is available.""" + + parent_node_execution_id: str + + def __init__(self, parent_node_execution_id: str) -> None: + self.parent_node_execution_id = parent_node_execution_id + super().__init__( + "Pending trace parent context for parent_node_execution_id=" + f"{parent_node_execution_id}. Retry after the parent span context is published." + ) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index bae0016744..61fd0e5c1f 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -16,6 +16,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token +from core.helper.trace_id_helper import ParentTraceContext from core.ops.entities.config_entity import ( OPS_FILE_PATH, BaseTracingConfig, @@ -52,6 +53,17 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _dump_parent_trace_context(parent_trace_context: Any) -> dict[str, str] | None: + if isinstance(parent_trace_context, ParentTraceContext): + return parent_trace_context.model_dump(exclude_none=True) + if isinstance(parent_trace_context, dict): + try: + return ParentTraceContext.model_validate(parent_trace_context).model_dump(exclude_none=True) + except ValueError: + return None + return None + + class _AppTracingConfig(TypedDict, total=False): enabled: bool tracing_provider: str | None @@ -857,8 +869,9 @@ class TraceTask: } parent_trace_context = self.kwargs.get("parent_trace_context") - if parent_trace_context: - metadata["parent_trace_context"] = parent_trace_context + dumped_parent_trace_context = _dump_parent_trace_context(parent_trace_context) + if dumped_parent_trace_context: + metadata["parent_trace_context"] = dumped_parent_trace_context workflow_trace_info = WorkflowTraceInfo( trace_id=self.trace_id, @@ -1371,13 +1384,14 @@ class TraceTask: } parent_trace_context = node_data.get("parent_trace_context") - if parent_trace_context: - metadata["parent_trace_context"] = parent_trace_context + dumped_parent_trace_context = _dump_parent_trace_context(parent_trace_context) + if dumped_parent_trace_context: + metadata["parent_trace_context"] = dumped_parent_trace_context message_id: str | None = None conversation_id = node_data.get("conversation_id") workflow_execution_id = node_data.get("workflow_execution_id") - if conversation_id and workflow_execution_id and not parent_trace_context: + if conversation_id and workflow_execution_id and not dumped_parent_trace_context: with Session(db.engine) as session: msg_id = session.scalar( select(Message.id).where( diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index cd8c6352b5..3fbd456fe5 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -9,6 +9,7 @@ from sqlalchemy import select from core.app.file_access import DatabaseFileAccessController from core.db.session_factory import session_factory +from core.helper.trace_id_helper import ParentTraceContext, extract_parent_trace_context_from_args from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime from core.tools.entities.tool_entities import ( @@ -36,6 +37,8 @@ class WorkflowTool(Tool): Workflow tool. """ + _parent_trace_context: ParentTraceContext | None + def __init__( self, workflow_app_id: str, @@ -54,6 +57,7 @@ class WorkflowTool(Tool): self.workflow_call_depth = workflow_call_depth self.label = label self._latest_usage = LLMUsage.empty_usage() + self._parent_trace_context = None super().__init__(entity=entity, runtime=runtime) @@ -94,11 +98,17 @@ class WorkflowTool(Tool): self._latest_usage = LLMUsage.empty_usage() + generator_args: dict[str, Any] = {"inputs": tool_parameters, "files": files} + if self._parent_trace_context: + generator_args.update( + extract_parent_trace_context_from_args({"parent_trace_context": self._parent_trace_context}) + ) + result = generator.generate( app_model=app, workflow=workflow, user=user, - args={"inputs": tool_parameters, "files": files}, + args=generator_args, invoke_from=self.runtime.invoke_from, streaming=False, call_depth=self.workflow_call_depth + 1, @@ -194,7 +204,7 @@ class WorkflowTool(Tool): :return: the new tool """ - return self.__class__( + forked = self.__class__( entity=self.entity.model_copy(), runtime=runtime, workflow_app_id=self.workflow_app_id, @@ -204,6 +214,24 @@ class WorkflowTool(Tool): version=self.version, label=self.label, ) + forked._parent_trace_context = self._parent_trace_context.model_copy() if self._parent_trace_context else None + return forked + + def set_parent_trace_context( + self, + *, + parent_workflow_run_id: str, + parent_node_execution_id: str, + ) -> None: + """Attach outer workflow trace context without exposing it as tool input.""" + self._parent_trace_context = ParentTraceContext( + parent_workflow_run_id=parent_workflow_run_id, + parent_node_execution_id=parent_node_execution_id, + ) + + def clear_parent_trace_context(self) -> None: + """Remove parent trace context before invoking this tool outside a nested workflow.""" + self._parent_trace_context = None def _resolve_user(self, user_id: str) -> Account | EndUser | None: """ diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index d687d9a6e0..db7d78bf45 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext from core.app.file_access import DatabaseFileAccessController from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler +from core.helper.trace_id_helper import ParentTraceContext from core.llm_generator.output_parser.errors import OutputParserError from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output from core.model_manager import ModelInstance @@ -358,6 +359,7 @@ class _WorkflowToolRuntimeBinding: tool: Tool conversation_id: str | None = None + parent_trace_context: ParentTraceContext | None = None class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): @@ -398,7 +400,25 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): conversation_id = ( None if variable_pool is None else get_system_text(variable_pool, SystemVariableKey.CONVERSATION_ID) ) - return ToolRuntimeHandle(raw=_WorkflowToolRuntimeBinding(tool=tool_runtime, conversation_id=conversation_id)) + parent_trace_context: ParentTraceContext | None = None + if self._is_workflow_tool_provider(node_data): + outer_workflow_run_id = ( + None + if variable_pool is None + else get_system_text(variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID) + ) + if isinstance(outer_workflow_run_id, str) and isinstance(node_execution_id, str): + parent_trace_context = ParentTraceContext( + parent_workflow_run_id=outer_workflow_run_id, + parent_node_execution_id=node_execution_id, + ) + return ToolRuntimeHandle( + raw=_WorkflowToolRuntimeBinding( + tool=tool_runtime, + conversation_id=conversation_id, + parent_trace_context=parent_trace_context, + ) + ) def get_runtime_parameters( self, @@ -422,6 +442,13 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): runtime_binding = self._binding_from_handle(tool_runtime) tool = runtime_binding.tool callback = DifyWorkflowCallbackHandler() + if runtime_binding.parent_trace_context and hasattr(tool, "set_parent_trace_context"): + tool.set_parent_trace_context( + parent_workflow_run_id=runtime_binding.parent_trace_context.parent_workflow_run_id, + parent_node_execution_id=runtime_binding.parent_trace_context.parent_node_execution_id, + ) + elif hasattr(tool, "clear_parent_trace_context"): + tool.clear_parent_trace_context() try: messages = ToolEngine.generic_invoke( @@ -514,6 +541,10 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): credential_id=node_data.credential_id, ) + @staticmethod + def _is_workflow_tool_provider(node_data: ToolNodeData) -> bool: + return node_data.provider_type.value == CoreToolProviderType.WORKFLOW.value + def _adapt_messages( self, messages: Generator[CoreToolInvokeMessage, None, None], diff --git a/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py index 96df49ed0e..a0d150e1b6 100644 --- a/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py @@ -1,9 +1,11 @@ import json import logging import os +import re import traceback +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import Any, Union, cast +from typing import Any, Protocol, Union, cast from urllib.parse import urlparse from openinference.semconv.trace import ( @@ -19,7 +21,7 @@ from opentelemetry.sdk import trace as trace_sdk from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.semconv.attributes import exception_attributes -from opentelemetry.trace import Span, Status, StatusCode, set_span_in_context, use_span +from opentelemetry.trace import Span, Status, StatusCode, get_current_span, set_span_in_context, use_span from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from opentelemetry.util.types import AttributeValue from sqlalchemy.orm import sessionmaker @@ -36,16 +38,106 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) +from core.ops.exceptions import PendingTraceParentContextError from core.ops.utils import JSON_DICT_ADAPTER from core.repositories import DifyCoreRepositoryFactory from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig from extensions.ext_database import db +from extensions.ext_redis import redis_client from graphon.enums import WorkflowNodeExecutionStatus from models.model import EndUser, MessageFile from models.workflow import WorkflowNodeExecutionTriggeredFrom logger = logging.getLogger(__name__) +# This parent-span carrier store is intentionally Phoenix-local for the current +# nested workflow tracing feature. If other trace providers need the same +# cross-task parent restoration behavior, move the storage and retry signaling +# behind a core trace coordination interface instead of duplicating it here. +_PHOENIX_PARENT_SPAN_CONTEXT_TTL_SECONDS = 300 +_TRACEPARENT_PATTERN = re.compile( + r"^(?P<version>[0-9a-f]{2})-(?P<trace_id>[0-9a-f]{32})-(?P<span_id>[0-9a-f]{16})-(?P<flags>[0-9a-f]{2})$" +) + + +def _phoenix_parent_span_redis_key(parent_node_execution_id: str) -> str: + """Build the Redis key that stores a restorable Phoenix parent span carrier.""" + return f"trace:phoenix:parent_span:{parent_node_execution_id}" + + +def _publish_parent_span_context(parent_node_execution_id: str, carrier: Mapping[str, str]) -> None: + """Persist a tracecontext carrier so nested workflow spans can restore the tool span parent.""" + redis_client.setex( + _phoenix_parent_span_redis_key(parent_node_execution_id), + _PHOENIX_PARENT_SPAN_CONTEXT_TTL_SECONDS, + safe_json_dumps(dict(carrier)), + ) + + +def _resolve_published_parent_span_context(parent_node_execution_id: str) -> dict[str, str]: + """Load a previously published tool-span carrier for nested workflow parenting.""" + raw_carrier = redis_client.get(_phoenix_parent_span_redis_key(parent_node_execution_id)) + if raw_carrier is None: + raise PendingTraceParentContextError(parent_node_execution_id) + + if isinstance(raw_carrier, bytes): + raw_carrier = raw_carrier.decode("utf-8") + + carrier = json.loads(raw_carrier) + if not isinstance(carrier, dict): + raise ValueError( + "Phoenix parent span context must be stored as a JSON object: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + normalized_carrier = {str(key): str(value) for key, value in carrier.items()} + if not normalized_carrier: + raise ValueError( + f"Phoenix parent span context payload is empty: parent_node_execution_id={parent_node_execution_id}" + ) + + traceparent = normalized_carrier.get("traceparent") + if not isinstance(traceparent, str): + raise ValueError( + "Phoenix parent span context payload is missing traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + traceparent_match = _TRACEPARENT_PATTERN.fullmatch(traceparent) + if traceparent_match is None: + raise ValueError( + "Phoenix parent span context payload has invalid traceparent format: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("version") == "ff": + raise ValueError( + "Phoenix parent span context payload has unsupported traceparent version: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("trace_id") == "0" * 32: + raise ValueError( + "Phoenix parent span context payload has zero trace_id in traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("span_id") == "0" * 16: + raise ValueError( + "Phoenix parent span context payload has zero span_id in traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + extracted_context = TraceContextTextMapPropagator().extract(carrier=normalized_carrier) + extracted_span_context = get_current_span(extracted_context).get_span_context() + if not extracted_span_context.is_valid or not extracted_span_context.is_remote: + raise ValueError( + "Phoenix parent span context payload could not be restored into a valid parent span: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + return normalized_carrier + def setup_tracer(arize_phoenix_config: ArizeConfig | PhoenixConfig) -> tuple[trace_sdk.Tracer, SimpleSpanProcessor]: """Configure OpenTelemetry tracer with OTLP exporter for Arize/Phoenix.""" @@ -177,6 +269,246 @@ def _get_node_span_kind(node_type: str) -> OpenInferenceSpanKindValues: return _NODE_TYPE_TO_SPAN_KIND.get(node_type, OpenInferenceSpanKindValues.CHAIN) +def _resolve_workflow_session_id(trace_info: WorkflowTraceInfo) -> str: + """Resolve the workflow session ID for Phoenix workflow spans.""" + if trace_info.conversation_id: + return trace_info.conversation_id + + parent_workflow_run_id, _ = _resolve_workflow_parent_context(trace_info) + if parent_workflow_run_id: + return parent_workflow_run_id + + return trace_info.workflow_run_id + + +def _resolve_workflow_parent_context(trace_info: BaseTraceInfo) -> tuple[str | None, str | None]: + """Expose the typed parent context already resolved on the trace info.""" + return trace_info.resolved_parent_context + + +def _resolve_workflow_root_trace_id(trace_info: WorkflowTraceInfo) -> str: + """Resolve the canonical root trace ID for Phoenix workflow spans.""" + trace_correlation_override, _ = _resolve_workflow_parent_context(trace_info) + return trace_correlation_override or trace_info.resolved_trace_id or trace_info.workflow_run_id + + +class _NodeExecutionIdentityLike(Protocol): + @property + def node_execution_id(self) -> str | None: ... + + @property + def node_id(self) -> str: ... + + @property + def predecessor_node_id(self) -> str | None: ... + + +class _NodeExecutionLike(_NodeExecutionIdentityLike, Protocol): + @property + def id(self) -> str: ... + + @property + def node_type(self) -> str: ... + + @property + def title(self) -> str | None: ... + + @property + def inputs(self) -> Mapping[str, Any] | None: ... + + @property + def process_data(self) -> Mapping[str, Any] | None: ... + + @property + def outputs(self) -> Mapping[str, Any] | None: ... + + @property + def status(self) -> WorkflowNodeExecutionStatus: ... + + @property + def error(self) -> str | None: ... + + @property + def elapsed_time(self) -> float | None: ... + + @property + def metadata(self) -> Mapping[Any, Any] | None: ... + + @property + def created_at(self) -> datetime | None: ... + + +_PHOENIX_STRUCTURED_NODE_TYPES = frozenset({"start", "end", "loop", "iteration"}) + + +def _resolve_workflow_span_name(trace_info: WorkflowTraceInfo) -> str: + """Resolve the Phoenix workflow span display name.""" + workflow_run_id = trace_info.workflow_run_id.strip() if trace_info.workflow_run_id else "" + if workflow_run_id: + return f"{TraceTaskName.WORKFLOW_TRACE.value}_{workflow_run_id}" + return TraceTaskName.WORKFLOW_TRACE.value + + +def _build_node_title_by_id(trace_info: WorkflowTraceInfo) -> dict[str, str]: + """Build an authoritative node-title index from the persisted workflow graph.""" + workflow_data = trace_info.workflow_data + workflow_graph = getattr(workflow_data, "graph_dict", None) + if not isinstance(workflow_graph, Mapping): + workflow_graph = workflow_data.get("graph") if isinstance(workflow_data, Mapping) else None + if not isinstance(workflow_graph, Mapping): + return {} + + graph_nodes = workflow_graph.get("nodes") + if not isinstance(graph_nodes, Sequence): + return {} + + node_title_by_id: dict[str, str] = {} + for graph_node in graph_nodes: + if not isinstance(graph_node, Mapping): + continue + node_id = graph_node.get("id") + node_data = graph_node.get("data") + if not isinstance(node_id, str) or not isinstance(node_data, Mapping): + continue + node_title = node_data.get("title") + if isinstance(node_title, str) and node_title.strip(): + node_title_by_id[node_id] = node_title.strip() + + return node_title_by_id + + +def _resolve_workflow_node_span_name( + node_execution: _NodeExecutionLike, + node_title_by_id: Mapping[str, str] | None = None, +) -> str: + """Resolve the Phoenix workflow node span display name.""" + node_type = str(node_execution.node_type or "") + graph_node_title = None + if node_title_by_id is not None and isinstance(node_execution.node_id, str): + graph_node_title = node_title_by_id.get(node_execution.node_id) + + node_title = graph_node_title or (node_execution.title.strip() if isinstance(node_execution.title, str) else "") + if node_title: + return f"{node_type}_{node_title}" + return node_type + + +def _get_node_execution_id(node_execution: _NodeExecutionIdentityLike) -> str: + """Return the stable execution identifier for a workflow node execution.""" + return str(getattr(node_execution, "id", None) or node_execution.node_execution_id) + + +def _build_execution_id_by_node_id(node_executions: Sequence[_NodeExecutionIdentityLike]) -> dict[str, str]: + """Index unique workflow graph node ids by execution id. + + This Phoenix-local hierarchy reconstruction intentionally drops ambiguous + node ids instead of guessing based on repository order. That keeps parent + selection deterministic until upstream tracing exposes explicit parent span + data for repeated executions. + """ + execution_id_by_node_id: dict[str, str] = {} + ambiguous_node_ids: set[str] = set() + + for node_execution in node_executions: + node_id = node_execution.node_id + if not isinstance(node_id, str): + continue + execution_id = _get_node_execution_id(node_execution) + + if node_id in ambiguous_node_ids: + continue + + existing_execution_id = execution_id_by_node_id.get(node_id) + if existing_execution_id is None: + execution_id_by_node_id[node_id] = execution_id + continue + + if existing_execution_id != execution_id: + ambiguous_node_ids.add(node_id) + execution_id_by_node_id.pop(node_id, None) + + return execution_id_by_node_id + + +def _build_graph_parent_index(node_executions: Sequence[_NodeExecutionIdentityLike]) -> dict[str, str]: + """Build an execution-id parent index from predecessor node ids.""" + execution_id_by_node_id = _build_execution_id_by_node_id(node_executions) + graph_parent_index: dict[str, str] = {} + + for node_execution in node_executions: + predecessor_node_id = node_execution.predecessor_node_id + if not isinstance(predecessor_node_id, str): + continue + + predecessor_execution_id = execution_id_by_node_id.get(predecessor_node_id) + if predecessor_execution_id is not None: + execution_id = _get_node_execution_id(node_execution) + graph_parent_index[execution_id] = predecessor_execution_id + + return graph_parent_index + + +def _resolve_structured_parent_execution_id( + node_execution: object, execution_id_by_node_id: Mapping[str, str] +) -> str | None: + """Resolve Phoenix-local structured parents from loop/iteration node ids. + + Any execution carrying ``iteration_id`` or ``loop_id`` belongs to an + enclosing structured node. When predecessor node ids are ambiguous because + the graph node repeats inside that structure, Phoenix can still keep the + child span under the enclosing loop/iteration span without relying on + execution-order heuristics. + """ + execution_metadata = getattr(node_execution, "execution_metadata_dict", None) + if not isinstance(execution_metadata, Mapping): + execution_metadata = getattr(node_execution, "metadata", None) + if not isinstance(execution_metadata, Mapping): + execution_metadata = {} + + for enclosing_node_id in ( + getattr(node_execution, "iteration_id", None), + getattr(node_execution, "loop_id", None), + execution_metadata.get("iteration_id"), + execution_metadata.get("loop_id"), + ): + if not isinstance(enclosing_node_id, str): + continue + + enclosing_execution_id = execution_id_by_node_id.get(enclosing_node_id) + if enclosing_execution_id is not None: + return enclosing_execution_id + + return None + + +def _resolve_node_parent( + execution_id: str, + predecessor_execution_id: str | None, + structured_parent_execution_id: str | None, + span_by_execution_id: Mapping[str, Span], + graph_parent_index: Mapping[str, str], + workflow_span: Span, +) -> Span: + """Resolve the parent span for a workflow node execution.""" + if predecessor_execution_id is not None: + predecessor_span = span_by_execution_id.get(predecessor_execution_id) + if predecessor_span is not None: + return predecessor_span + + graph_parent_execution_id = graph_parent_index.get(execution_id) + if graph_parent_execution_id is not None: + graph_parent_span = span_by_execution_id.get(graph_parent_execution_id) + if graph_parent_span is not None: + return graph_parent_span + + if structured_parent_execution_id is not None: + structured_parent_span = span_by_execution_id.get(structured_parent_execution_id) + if structured_parent_span is not None: + return structured_parent_span + + return workflow_span + + class ArizePhoenixDataTrace(BaseTraceInstance): def __init__( self, @@ -189,6 +521,8 @@ class ArizePhoenixDataTrace(BaseTraceInstance): self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001") self.propagator = TraceContextTextMapPropagator() self.dify_trace_ids: set[str] = set() + self.root_span_carriers: dict[str, dict[str, str]] = {} + self.carrier: dict[str, str] = {} def trace(self, trace_info: BaseTraceInfo): logger.info("[Arize/Phoenix] Trace Entity Info: %s", trace_info) @@ -235,13 +569,41 @@ class ArizePhoenixDataTrace(BaseTraceInstance): file_list=safe_json_dumps(file_list), query=trace_info.query or "", ) + workflow_session_id = _resolve_workflow_session_id(trace_info) + parent_workflow_run_id, parent_node_execution_id = _resolve_workflow_parent_context(trace_info) + logger.info( + "[Arize/Phoenix] Workflow session resolution: workflow_run_id=%s conversation_id=%s " + "parent_workflow_run_id=%s parent_node_execution_id=%s resolved_session_id=%s", + trace_info.workflow_run_id, + trace_info.conversation_id, + parent_workflow_run_id, + parent_node_execution_id, + workflow_session_id, + ) - dify_trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id - self.ensure_root_span(dify_trace_id) - root_span_context = self.propagator.extract(carrier=self.carrier) + if parent_node_execution_id: + workflow_parent_carrier = _resolve_published_parent_span_context(parent_node_execution_id) + else: + root_trace_id = _resolve_workflow_root_trace_id(trace_info) + workflow_root_span_name: str | None = trace_info.workflow_run_id + if not isinstance(workflow_root_span_name, str) or not workflow_root_span_name.strip(): + workflow_root_span_name = None + + workflow_parent_carrier = self.ensure_root_span( + root_trace_id, + root_span_name=workflow_root_span_name, + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, + }, + ) + + workflow_span_context = self.propagator.extract(carrier=workflow_parent_carrier) workflow_span = self.tracer.start_span( - name=TraceTaskName.WORKFLOW_TRACE.value, + name=_resolve_workflow_span_name(trace_info), attributes={ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.workflow_run_inputs), @@ -249,10 +611,10 @@ class ArizePhoenixDataTrace(BaseTraceInstance): SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs), SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, SpanAttributes.METADATA: safe_json_dumps(metadata), - SpanAttributes.SESSION_ID: trace_info.conversation_id or "", + SpanAttributes.SESSION_ID: workflow_session_id or "", }, start_time=datetime_to_nanos(trace_info.start_time), - context=root_span_context, + context=workflow_span_context, ) # Through workflow_run_id, get all_nodes_execution using repository @@ -276,16 +638,50 @@ class ArizePhoenixDataTrace(BaseTraceInstance): workflow_node_executions = workflow_node_execution_repository.get_by_workflow_execution( workflow_execution_id=trace_info.workflow_run_id ) + node_title_by_id = _build_node_title_by_id(trace_info) + execution_id_by_node_id = _build_execution_id_by_node_id(workflow_node_executions) + graph_parent_index = _build_graph_parent_index(workflow_node_executions) + node_execution_by_execution_id = { + _get_node_execution_id(node_execution): node_execution for node_execution in workflow_node_executions + } + span_by_execution_id: dict[str, Span] = {} + emitting_execution_ids: set[str] = set() + workflow_span_error: Exception | str | None = trace_info.error try: - for node_execution in workflow_node_executions: + + def emit_node_span(node_execution: _NodeExecutionLike) -> Span: + execution_id = _get_node_execution_id(node_execution) + existing_span = span_by_execution_id.get(execution_id) + if existing_span is not None: + return existing_span + + graph_parent_execution_id = graph_parent_index.get(execution_id) + structured_parent_execution_id = _resolve_structured_parent_execution_id( + node_execution, execution_id_by_node_id + ) + + if execution_id not in emitting_execution_ids: + emitting_execution_ids.add(execution_id) + try: + for parent_execution_id in (graph_parent_execution_id, structured_parent_execution_id): + if parent_execution_id is None or parent_execution_id == execution_id: + continue + if parent_execution_id in span_by_execution_id: + continue + parent_node_execution = node_execution_by_execution_id.get(parent_execution_id) + if parent_node_execution is not None: + emit_node_span(parent_node_execution) + finally: + emitting_execution_ids.discard(execution_id) + tenant_id = trace_info.tenant_id # Use from trace_info instead app_id = trace_info.metadata.get("app_id") # Use from trace_info instead inputs_value = node_execution.inputs or {} outputs_value = node_execution.outputs or {} created_at = node_execution.created_at or datetime.now() - elapsed_time = node_execution.elapsed_time + elapsed_time = node_execution.elapsed_time or 0 finished_at = created_at + timedelta(seconds=elapsed_time) process_data = node_execution.process_data or {} @@ -324,9 +720,17 @@ class ArizePhoenixDataTrace(BaseTraceInstance): node_metadata["prompt_tokens"] = usage_data.get("prompt_tokens", 0) node_metadata["completion_tokens"] = usage_data.get("completion_tokens", 0) - workflow_span_context = set_span_in_context(workflow_span) + parent_span = _resolve_node_parent( + execution_id=execution_id, + predecessor_execution_id=None, + structured_parent_execution_id=structured_parent_execution_id, + span_by_execution_id=span_by_execution_id, + graph_parent_index=graph_parent_index, + workflow_span=workflow_span, + ) + workflow_span_context = set_span_in_context(parent_span) node_span = self.tracer.start_span( - name=node_execution.node_type, + name=_resolve_workflow_node_span_name(node_execution, node_title_by_id), attributes={ SpanAttributes.OPENINFERENCE_SPAN_KIND: span_kind.value, SpanAttributes.INPUT_VALUE: safe_json_dumps(inputs_value), @@ -334,13 +738,20 @@ class ArizePhoenixDataTrace(BaseTraceInstance): SpanAttributes.OUTPUT_VALUE: safe_json_dumps(outputs_value), SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, SpanAttributes.METADATA: safe_json_dumps(node_metadata), - SpanAttributes.SESSION_ID: trace_info.conversation_id or "", + SpanAttributes.SESSION_ID: workflow_session_id or "", }, start_time=datetime_to_nanos(created_at), context=workflow_span_context, ) - + span_by_execution_id[execution_id] = node_span + node_span_error: Exception | str | None = None try: + if node_execution.node_type == "tool": + parent_span_carrier: dict[str, str] = {} + with use_span(node_span, end_on_exit=False): + self.propagator.inject(carrier=parent_span_carrier) + _publish_parent_span_context(execution_id, parent_span_carrier) + if node_execution.node_type == "llm": llm_attributes: dict[str, Any] = { SpanAttributes.INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False), @@ -362,17 +773,26 @@ class ArizePhoenixDataTrace(BaseTraceInstance): ) llm_attributes.update(self._construct_llm_attributes(process_data.get("prompts", []))) node_span.set_attributes(llm_attributes) + except Exception as e: + node_span_error = e + raise finally: - if node_execution.status == WorkflowNodeExecutionStatus.FAILED: + if node_span_error is not None: + set_span_status(node_span, node_span_error) + elif node_execution.status == WorkflowNodeExecutionStatus.FAILED: set_span_status(node_span, node_execution.error) else: set_span_status(node_span) node_span.end(end_time=datetime_to_nanos(finished_at)) + return node_span + + for node_execution in workflow_node_executions: + emit_node_span(node_execution) + except Exception as e: + workflow_span_error = e + raise finally: - if trace_info.error: - set_span_status(workflow_span, trace_info.error) - else: - set_span_status(workflow_span) + set_span_status(workflow_span, workflow_span_error) workflow_span.end(end_time=datetime_to_nanos(trace_info.end_time)) def message_trace(self, trace_info: MessageTraceInfo): @@ -735,22 +1155,39 @@ class ArizePhoenixDataTrace(BaseTraceInstance): finally: span.end(end_time=datetime_to_nanos(trace_info.end_time)) - def ensure_root_span(self, dify_trace_id: str | None): + def ensure_root_span( + self, + dify_trace_id: str | None, + *, + root_span_name: str | None = None, + root_span_attributes: Mapping[str, AttributeValue] | None = None, + ): """Ensure a unique root span exists for the given Dify trace ID.""" - if str(dify_trace_id) not in self.dify_trace_ids: - self.carrier: dict[str, str] = {} + trace_key = str(dify_trace_id) + if trace_key not in self.dify_trace_ids: + carrier: dict[str, str] = {} - root_span = self.tracer.start_span(name="Dify") - root_span.set_attribute(SpanAttributes.OPENINFERENCE_SPAN_KIND, OpenInferenceSpanKindValues.CHAIN.value) - root_span.set_attribute("dify_project_name", str(self.project)) - root_span.set_attribute("dify_trace_id", str(dify_trace_id)) + span_name = root_span_name.strip() if isinstance(root_span_name, str) and root_span_name.strip() else "Dify" + root_span_attributes_dict: dict[str, AttributeValue] = { + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, + "dify_project_name": str(self.project), + "dify_trace_id": trace_key, + } + if root_span_attributes: + root_span_attributes_dict.update(root_span_attributes) + + root_span = self.tracer.start_span(name=span_name, attributes=root_span_attributes_dict) with use_span(root_span, end_on_exit=False): - self.propagator.inject(carrier=self.carrier) + self.propagator.inject(carrier=carrier) set_span_status(root_span) root_span.end() - self.dify_trace_ids.add(str(dify_trace_id)) + self.dify_trace_ids.add(trace_key) + self.root_span_carriers[trace_key] = carrier + + self.carrier = self.root_span_carriers[trace_key] + return self.carrier def api_check(self): try: diff --git a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py index e9ecc2e083..dd260aeee5 100644 --- a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py @@ -1,10 +1,21 @@ from datetime import UTC, datetime, timedelta -from typing import cast +from types import SimpleNamespace +from typing import Any, cast from unittest.mock import MagicMock, patch +import dify_trace_arize_phoenix.arize_phoenix_trace as arize_phoenix_trace_module import pytest from dify_trace_arize_phoenix.arize_phoenix_trace import ( + _NODE_TYPE_TO_SPAN_KIND, ArizePhoenixDataTrace, + _build_graph_parent_index, + _get_node_span_kind, + _phoenix_parent_span_redis_key, + _resolve_node_parent, + _resolve_published_parent_span_context, + _resolve_structured_parent_execution_id, + _resolve_workflow_parent_context, + _resolve_workflow_session_id, datetime_to_nanos, error_to_string, safe_json_dumps, @@ -13,6 +24,7 @@ from dify_trace_arize_phoenix.arize_phoenix_trace import ( wrap_span_metadata, ) from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig +from openinference.semconv.trace import OpenInferenceSpanKindValues, SpanAttributes from opentelemetry.sdk.trace import Tracer from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes from opentelemetry.trace import StatusCode @@ -24,8 +36,12 @@ from core.ops.entities.trace_entity import ( ModerationTraceInfo, SuggestedQuestionTraceInfo, ToolTraceInfo, + TraceTaskName, + WorkflowNodeTraceInfo, WorkflowTraceInfo, ) +from core.ops.exceptions import PendingTraceParentContextError +from graphon.enums import BUILT_IN_NODE_TYPES, BuiltinNodeTypes # --- Helpers --- @@ -73,6 +89,80 @@ def _make_message_info(**kwargs): return MessageTraceInfo(**defaults) +def _get_start_span_call(start_span_mock, *, span_name: str): + for call in start_span_mock.call_args_list: + if call.kwargs.get("name") == span_name: + return call + raise AssertionError(f"Could not find start_span call with name={span_name!r}") + + +def _make_node_execution(**kwargs): + defaults = { + "node_type": "tool", + "status": "succeeded", + "inputs": {}, + "outputs": {}, + "created_at": _dt(), + "elapsed_time": 1.0, + "process_data": {}, + "metadata": {}, + "title": "Node", + "id": "node-execution-1", + "node_execution_id": "node-execution-1", + "node_id": "node-1", + "predecessor_node_id": None, + "iteration_id": None, + "loop_id": None, + "error": None, + } + defaults.update(kwargs) + node_execution = MagicMock() + for key, value in defaults.items(): + setattr(node_execution, key, value) + return node_execution + + +def _make_workflow_trace_info(**kwargs) -> WorkflowTraceInfo: + defaults = { + "workflow_id": "workflow-1", + "tenant_id": "tenant-1", + "workflow_run_id": "workflow-run-1", + "workflow_run_elapsed_time": 1.0, + "workflow_run_status": "succeeded", + "workflow_run_inputs": {"input": "value"}, + "workflow_run_outputs": {"output": "value"}, + "workflow_run_version": "1.0", + "total_tokens": 10, + "file_list": ["file-1"], + "query": "hello", + "metadata": {"app_id": "app-1"}, + "start_time": _dt(), + "end_time": _dt() + timedelta(seconds=1), + } + defaults.update(kwargs) + return WorkflowTraceInfo(**defaults) + + +def _make_workflow_node_trace_info(**kwargs) -> WorkflowNodeTraceInfo: + defaults = { + "workflow_id": "workflow-1", + "workflow_run_id": "workflow-run-1", + "tenant_id": "tenant-1", + "node_execution_id": "node-execution-1", + "node_id": "node-1", + "node_type": "tool", + "title": "Node 1", + "status": "succeeded", + "elapsed_time": 1.0, + "index": 1, + "metadata": {"app_id": "app-1"}, + "start_time": _dt(), + "end_time": _dt() + timedelta(seconds=1), + } + defaults.update(kwargs) + return WorkflowNodeTraceInfo(**defaults) + + # --- Utility Function Tests --- @@ -143,6 +233,258 @@ def test_wrap_span_metadata(): assert res == {"a": 1, "b": 2, "created_from": "Dify"} +class TestGetNodeSpanKind: + def test_all_node_types_are_mapped_correctly(self): + special_mappings = { + BuiltinNodeTypes.LLM: OpenInferenceSpanKindValues.LLM, + BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: OpenInferenceSpanKindValues.RETRIEVER, + BuiltinNodeTypes.TOOL: OpenInferenceSpanKindValues.TOOL, + BuiltinNodeTypes.AGENT: OpenInferenceSpanKindValues.AGENT, + } + + for node_type in BUILT_IN_NODE_TYPES: + expected_span_kind = special_mappings.get(node_type, OpenInferenceSpanKindValues.CHAIN) + actual_span_kind = _get_node_span_kind(node_type) + assert actual_span_kind == expected_span_kind, ( + f"Node type {node_type!r} was mapped to {actual_span_kind}, but {expected_span_kind} was expected." + ) + + def test_unknown_string_defaults_to_chain(self): + assert _get_node_span_kind("some-future-node-type") == OpenInferenceSpanKindValues.CHAIN + + def test_stale_dataset_retrieval_not_in_mapping(self): + assert "dataset_retrieval" not in _NODE_TYPE_TO_SPAN_KIND + + +class TestWorkflowSessionResolution: + def test_prefers_conversation_id(self): + info = _make_workflow_trace_info(conversation_id="conversation-1") + + assert _resolve_workflow_session_id(info) == "conversation-1" + + def test_nested_workflow_keeps_own_conversation_id_when_parent_context_exists(self): + info = _make_workflow_trace_info( + conversation_id="conversation-1", + metadata={ + "app_id": "app-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + + assert _resolve_workflow_session_id(info) == "conversation-1" + + def test_uses_parent_workflow_run_id_for_nested_parent_trace_context(self): + info = _make_workflow_trace_info( + conversation_id=None, + metadata={ + "app_id": "app-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + + assert _resolve_workflow_session_id(info) == "outer-workflow-run-1" + + def test_falls_back_to_workflow_run_id(self): + info = _make_workflow_trace_info(conversation_id=None) + + assert _resolve_workflow_session_id(info) == "workflow-run-1" + + def test_parent_context_helper_delegates_to_resolved_parent_context(self): + info = MagicMock() + info.resolved_parent_context = ("outer-workflow-run-1", "outer-node-execution-1") + + assert _resolve_workflow_parent_context(info) == info.resolved_parent_context + + +class TestPhoenixParentSpanBridgeHelpers: + def test_parent_span_redis_key_is_stable(self): + assert _phoenix_parent_span_redis_key("outer-node-execution-1") == ( + "trace:phoenix:parent_span:outer-node-execution-1" + ) + + def test_pending_parent_exception_exposes_execution_id(self): + error = PendingTraceParentContextError("outer-node-execution-1") + + assert error.parent_node_execution_id == "outer-node-execution-1" + assert "outer-node-execution-1" in str(error) + + def test_resolve_parent_span_context_rejects_payload_without_traceparent(self, monkeypatch): + mock_redis = MagicMock() + mock_redis.get.return_value = '{"tracestate": "vendor=value"}' + monkeypatch.setattr(arize_phoenix_trace_module, "redis_client", mock_redis) + + with pytest.raises(ValueError, match="traceparent"): + _resolve_published_parent_span_context("outer-node-execution-1") + + @pytest.mark.parametrize( + "stored_payload", + [ + '{"traceparent": ""}', + '{"traceparent": "not-a-traceparent"}', + '{"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb"}', + ], + ) + def test_resolve_parent_span_context_rejects_malformed_traceparent(self, monkeypatch, stored_payload): + mock_redis = MagicMock() + mock_redis.get.return_value = stored_payload + monkeypatch.setattr(arize_phoenix_trace_module, "redis_client", mock_redis) + + with pytest.raises(ValueError, match="traceparent"): + _resolve_published_parent_span_context("outer-node-execution-1") + + +class TestWorkflowHierarchyHelpers: + def test_build_graph_parent_index_uses_predecessor_nodes_without_order_heuristics(self): + later_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-3", + node_id="node-3", + predecessor_node_id="node-2", + index=3, + ) + root_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-1", + node_id="node-1", + predecessor_node_id=None, + index=1, + ) + middle_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-2", + node_id="node-2", + predecessor_node_id="node-1", + index=2, + ) + + graph_parent_index = _build_graph_parent_index([later_node, root_node, middle_node]) + + assert graph_parent_index == { + "node-execution-2": "node-execution-1", + "node-execution-3": "node-execution-2", + } + + def test_build_graph_parent_index_drops_ambiguous_parallel_like_predecessors(self): + first_parallel_node = _make_workflow_node_trace_info( + node_execution_id="parallel-node-execution-1", + node_id="parallel-node-1", + predecessor_node_id=None, + index=1, + parallel_id="parallel-1", + ) + second_parallel_node = _make_workflow_node_trace_info( + node_execution_id="parallel-node-execution-2", + node_id="parallel-node-1", + predecessor_node_id=None, + index=2, + parallel_id="parallel-2", + ) + child_node = _make_workflow_node_trace_info( + node_execution_id="child-node-execution-1", + node_id="child-node-1", + predecessor_node_id="parallel-node-1", + index=3, + ) + + graph_parent_index = _build_graph_parent_index([child_node, first_parallel_node, second_parallel_node]) + + assert graph_parent_index == {} + + def test_resolve_node_parent_prefers_predecessor_span(self): + workflow_span = MagicMock(name="workflow-span") + predecessor_span = MagicMock(name="predecessor-span") + graph_parent_span = MagicMock(name="graph-parent-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id="node-execution-1", + structured_parent_execution_id=None, + span_by_execution_id={ + "node-execution-1": predecessor_span, + "node-execution-0": graph_parent_span, + }, + graph_parent_index={ + "node-execution-2": "node-execution-0", + }, + workflow_span=workflow_span, + ) + + assert parent is predecessor_span + + def test_resolve_node_parent_falls_back_to_graph_parent_span(self): + workflow_span = MagicMock(name="workflow-span") + graph_parent_span = MagicMock(name="graph-parent-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id="missing-predecessor", + structured_parent_execution_id=None, + span_by_execution_id={ + "node-execution-0": graph_parent_span, + }, + graph_parent_index={ + "node-execution-2": "node-execution-0", + }, + workflow_span=workflow_span, + ) + + assert parent is graph_parent_span + + def test_resolve_node_parent_falls_back_to_workflow_span(self): + workflow_span = MagicMock(name="workflow-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id=None, + structured_parent_execution_id=None, + span_by_execution_id={}, + graph_parent_index={}, + workflow_span=workflow_span, + ) + + assert parent is workflow_span + + def test_resolve_structured_parent_execution_id_allows_body_nodes_to_use_enclosing_structure(self): + body_node = _make_workflow_node_trace_info( + node_execution_id="body-execution-1", + node_id="body-node-1", + node_type="tool", + loop_id="loop-node-1", + ) + + structured_parent_execution_id = _resolve_structured_parent_execution_id( + body_node, + execution_id_by_node_id={ + "loop-node-1": "loop-execution-1", + }, + ) + + assert structured_parent_execution_id == "loop-execution-1" + + def test_resolve_structured_parent_execution_id_reads_execution_metadata_dict_for_models(self): + body_node = SimpleNamespace( + node_execution_id="body-execution-1", + node_id="body-node-1", + execution_metadata_dict={ + "iteration_id": "iteration-node-1", + "loop_id": "loop-node-1", + }, + ) + + structured_parent_execution_id = _resolve_structured_parent_execution_id( + body_node, + execution_id_by_node_id={ + "iteration-node-1": "iteration-execution-1", + "loop-node-1": "loop-execution-1", + }, + ) + + assert structured_parent_execution_id == "iteration-execution-1" + + @patch("dify_trace_arize_phoenix.arize_phoenix_trace.GrpcOTLPSpanExporter") @patch("dify_trace_arize_phoenix.arize_phoenix_trace.trace_sdk.TracerProvider") def test_setup_tracer_arize(mock_provider, mock_exporter): @@ -173,12 +515,17 @@ def test_setup_tracer_exception(): @pytest.fixture def trace_instance(): - with patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer") as mock_setup: + with ( + patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer") as mock_setup, + patch("dify_trace_arize_phoenix.arize_phoenix_trace.redis_client", new=MagicMock()) as mock_redis, + ): mock_tracer = MagicMock(spec=Tracer) mock_processor = MagicMock() mock_setup.return_value = (mock_tracer, mock_processor) config = ArizeConfig(endpoint="http://a.com", api_key="k", space_id="s", project="p") - return ArizePhoenixDataTrace(config) + instance = ArizePhoenixDataTrace(config) + cast(Any, instance)._mock_redis_client = mock_redis + yield instance def test_trace_dispatch(trace_instance): @@ -273,23 +620,821 @@ def test_workflow_trace_no_app_id(mock_db, trace_instance): @patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") -def test_message_trace_success(mock_db, trace_instance): +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_canonical_root_context_for_top_level_workflow( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info(message_id="message-1", workflow_run_id="workflow-run-1") + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + root_carrier = {} + root_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=root_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=root_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + info.resolved_trace_id, + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=root_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is root_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_workflow_run_id_for_root_span_and_populates_root_inputs_outputs( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + workflow_run_inputs={"prompt": "hello"}, + workflow_run_outputs={"result": "world"}, + metadata={ + "app_id": "app1", + "app_name": "Workflow Name", + }, + workflow_run_id="workflow-run-xyz", + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + root_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow-run-xyz") + assert root_span_call.kwargs["attributes"][SpanAttributes.INPUT_VALUE] == safe_json_dumps(info.workflow_run_inputs) + assert root_span_call.kwargs["attributes"][SpanAttributes.OUTPUT_VALUE] == safe_json_dumps( + info.workflow_run_outputs + ) + assert root_span_call.kwargs["attributes"][SpanAttributes.INPUT_MIME_TYPE] == "application/json" + assert root_span_call.kwargs["attributes"][SpanAttributes.OUTPUT_MIME_TYPE] == "application/json" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_dify_name_when_workflow_run_id_is_blank( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + metadata={ + "app_id": "app1", + "app_name": "", + }, + workflow_run_id="", + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + root_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="Dify") + assert root_span_call.kwargs["attributes"]["dify_trace_id"] == "" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_reuses_upstream_parent_workflow_context_when_no_parent_node_execution_id_is_available( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + parent_carrier = {} + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=parent_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + "outer-workflow-run-1", + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=parent_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is parent_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_published_parent_node_context_for_nested_workflow( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + stored_carrier = '{"traceparent":"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"}' + trace_instance._mock_redis_client.get.return_value = stored_carrier + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + trace_instance._mock_redis_client.get.assert_called_once_with( + _phoenix_parent_span_redis_key("outer-node-execution-1") + ) + mock_ensure_root_span.assert_not_called() + mock_extract.assert_called_once_with( + carrier={"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"} + ) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is parent_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_raises_pending_parent_error_when_parent_node_context_is_missing( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + trace_instance._mock_redis_client.get.return_value = None + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span, + pytest.raises(PendingTraceParentContextError) as exc_info, + ): + trace_instance.workflow_trace(info) + + assert exc_info.value.parent_node_execution_id == "outer-node-execution-1" + trace_instance._mock_redis_client.get.assert_called_once_with( + _phoenix_parent_span_redis_key("outer-node-execution-1") + ) + mock_ensure_root_span.assert_not_called() + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_parent_workflow_run_id_for_workflow_and_nodes_when_nested_context_is_present( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + conversation_id=None, + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + node_execution = MagicMock() + node_execution.node_type = "tool" + node_execution.status = "succeeded" + node_execution.inputs = {"tool_input": "value"} + node_execution.outputs = {"tool_output": "value"} + node_execution.created_at = _dt() + node_execution.elapsed_time = 1.0 + node_execution.process_data = {} + node_execution.metadata = {} + node_execution.title = "Tool node" + node_execution.id = "node-1" + node_execution.error = None + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_r1") + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Tool node") + + assert workflow_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "outer-workflow-run-1" + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "outer-workflow-run-1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_node_type_when_node_title_is_blank( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + title=" ", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool") + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "r1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_prefers_workflow_graph_node_title_over_execution_title( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + workflow_data={ + "graph": { + "nodes": [ + { + "id": "nested-tool-node", + "data": { + "type": "tool", + "title": "nested workflow tool", + }, + } + ] + } + } + ) + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="nested-tool-node", + node_type="tool", + title="2", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_nested workflow tool") + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "r1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_keeps_nested_conversation_session_while_reusing_parent_root_context( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + conversation_id="conversation-1", + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + parent_carrier = {} + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=parent_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + "outer-workflow-run-1", + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=parent_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Node") + assert workflow_span_call.kwargs["context"] is parent_context + assert workflow_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-1" + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_publishes_tool_node_parent_span_context_to_redis( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="tool-execution-1", + node_execution_id="tool-execution-1", + node_id="tool-node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + tool_span = MagicMock(name="tool-span") + tool_span._context_label = "tool" + trace_instance.tracer.start_span.side_effect = [workflow_span, tool_span] + + def inject_side_effect(carrier): + carrier["traceparent"] = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch.object(trace_instance.propagator, "inject", side_effect=inject_side_effect) as mock_inject, + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + mock_inject.assert_called_once() + trace_instance._mock_redis_client.setex.assert_called_once_with( + _phoenix_parent_span_redis_key("tool-execution-1"), + 300, + '{"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"}', + ) + + +@pytest.mark.parametrize( + ("failing_step", "expected_message"), + [ + ("inject", "inject failed"), + ("publish", "publish failed"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_cleans_up_tool_span_when_parent_context_publish_fails( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + failing_step, + expected_message, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="tool-execution-1", + node_execution_id="tool-execution-1", + node_id="tool-node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + tool_span = MagicMock(name="tool-span") + tool_span._context_label = "tool" + trace_instance.tracer.start_span.side_effect = [workflow_span, tool_span] + + inject_side_effect = None + if failing_step == "inject": + inject_side_effect = RuntimeError(expected_message) + else: + trace_instance._mock_redis_client.setex.side_effect = RuntimeError(expected_message) + + def inject_side_effect(carrier): + carrier["traceparent"] = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch.object(trace_instance.propagator, "inject", side_effect=inject_side_effect), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + pytest.raises(RuntimeError, match=expected_message), + ): + trace_instance.workflow_trace(info) + + tool_span.end.assert_called_once() + workflow_span.end.assert_called_once() + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_parents_serial_nodes_to_resolved_predecessor_span( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + second_node = _make_node_execution( + id="node-execution-2", + node_execution_id="node-execution-2", + node_id="node-2", + node_type="llm", + predecessor_node_id="node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + ) + first_node = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [second_node, first_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + first_node_span = MagicMock(name="first-node-span") + first_node_span._context_label = "node-1" + second_node_span = MagicMock(name="second-node-span") + second_node_span._context_label = "node-2" + trace_instance.tracer.start_span.side_effect = [workflow_span, first_node_span, second_node_span] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + first_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Node") + second_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert first_node_call.kwargs["context"] == "context:workflow" + assert second_node_call.kwargs["context"] == "context:node-1" + + +@pytest.mark.parametrize( + ("enclosing_node_type", "structured_field"), + [ + ("loop", "loop_id"), + ("iteration", "iteration_id"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_parents_structured_start_nodes_to_enclosing_structure_span( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + enclosing_node_type, + structured_field, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + enclosing_node = _make_node_execution( + id=f"{enclosing_node_type}-execution-1", + node_execution_id=f"{enclosing_node_type}-execution-1", + node_id=f"{enclosing_node_type}-node-1", + node_type=enclosing_node_type, + ) + structured_kwargs = {structured_field: f"{enclosing_node_type}-node-1"} + start_node = _make_node_execution( + id="start-execution-1", + node_execution_id="start-execution-1", + node_id="start-node-1", + node_type="start", + **structured_kwargs, + ) + repo.get_by_workflow_execution.return_value = [start_node, enclosing_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + enclosing_node_span = MagicMock(name="enclosing-node-span") + enclosing_node_span._context_label = enclosing_node_type + start_node_span = MagicMock(name="start-node-span") + start_node_span._context_label = "start" + trace_instance.tracer.start_span.side_effect = [workflow_span, enclosing_node_span, start_node_span] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + start_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="start_Node") + assert start_node_call.kwargs["context"] == f"context:{enclosing_node_type}" + + +@pytest.mark.parametrize( + ("enclosing_node_type", "structured_field"), + [ + ("loop", "loop_id"), + ("iteration", "iteration_id"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_keeps_duplicate_body_node_children_under_enclosing_structure( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + enclosing_node_type, + structured_field, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + enclosing_node = _make_node_execution( + id=f"{enclosing_node_type}-execution-1", + node_execution_id=f"{enclosing_node_type}-execution-1", + node_id=f"{enclosing_node_type}-node-1", + node_type=enclosing_node_type, + ) + structured_kwargs = {structured_field: f"{enclosing_node_type}-node-1"} + repeated_body_node_1 = _make_node_execution( + id="body-execution-1", + node_execution_id="body-execution-1", + node_id="body-node-1", + node_type="tool", + **structured_kwargs, + ) + repeated_body_node_2 = _make_node_execution( + id="body-execution-2", + node_execution_id="body-execution-2", + node_id="body-node-1", + node_type="tool", + **structured_kwargs, + ) + child_node = _make_node_execution( + id="child-execution-1", + node_execution_id="child-execution-1", + node_id="child-node-1", + node_type="llm", + predecessor_node_id="body-node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + **structured_kwargs, + ) + repo.get_by_workflow_execution.return_value = [ + child_node, + repeated_body_node_1, + repeated_body_node_2, + enclosing_node, + ] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + enclosing_node_span = MagicMock(name="enclosing-node-span") + enclosing_node_span._context_label = enclosing_node_type + child_node_span = MagicMock(name="child-node-span") + child_node_span._context_label = "child" + repeated_body_node_1_span = MagicMock(name="repeated-body-node-1-span") + repeated_body_node_1_span._context_label = "body-1" + repeated_body_node_2_span = MagicMock(name="repeated-body-node-2-span") + repeated_body_node_2_span._context_label = "body-2" + trace_instance.tracer.start_span.side_effect = [ + workflow_span, + enclosing_node_span, + child_node_span, + repeated_body_node_1_span, + repeated_body_node_2_span, + ] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + child_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert child_node_call.kwargs["context"] == f"context:{enclosing_node_type}" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_workflow_span_for_parallel_like_ambiguous_predecessors( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + child_node = _make_node_execution( + id="child-execution-1", + node_execution_id="child-execution-1", + node_id="child-node-1", + node_type="llm", + predecessor_node_id="parallel-node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + ) + first_parallel_node = _make_node_execution( + id="parallel-execution-1", + node_execution_id="parallel-execution-1", + node_id="parallel-node-1", + node_type="tool", + parallel_id="parallel-1", + ) + second_parallel_node = _make_node_execution( + id="parallel-execution-2", + node_execution_id="parallel-execution-2", + node_id="parallel-node-1", + node_type="tool", + parallel_id="parallel-2", + ) + repo.get_by_workflow_execution.return_value = [child_node, first_parallel_node, second_parallel_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + child_node_span = MagicMock(name="child-node-span") + child_node_span._context_label = "child" + first_parallel_node_span = MagicMock(name="first-parallel-node-span") + first_parallel_node_span._context_label = "parallel-1" + second_parallel_node_span = MagicMock(name="second-parallel-node-span") + second_parallel_node_span._context_label = "parallel-2" + trace_instance.tracer.start_span.side_effect = [ + workflow_span, + child_node_span, + first_parallel_node_span, + second_parallel_node_span, + ] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + child_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert child_node_call.kwargs["context"] == "context:workflow" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +def test_message_trace_keeps_conversation_id_as_session(mock_db, trace_instance): mock_db.engine = MagicMock() info = _make_message_info() info.message_data = MagicMock() - info.message_data.from_account_id = "acc1" + info.message_data.conversation_id = "conversation-2" + info.message_data.from_account_id = "acc2" info.message_data.from_end_user_id = None - info.message_data.query = "q" - info.message_data.answer = "a" - info.message_data.status = "s" - info.message_data.model_id = "m" - info.message_data.model_provider = "p" + info.message_data.query = "q2" + info.message_data.answer = "a2" + info.message_data.status = "s2" + info.message_data.model_id = "m2" + info.message_data.model_provider = "p2" info.message_data.message_metadata = "{}" info.message_data.error = None info.error = None + root_span = MagicMock() + message_span = MagicMock() + llm_span = MagicMock() + trace_instance.tracer.start_span.side_effect = [root_span, message_span, llm_span] + trace_instance.message_trace(info) - assert trace_instance.tracer.start_span.call_count >= 1 + + message_span_call = _get_start_span_call( + trace_instance.tracer.start_span, span_name=TraceTaskName.MESSAGE_TRACE.value + ) + assert message_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-2" @patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") @@ -397,3 +1542,30 @@ def test_api_check_success(trace_instance): def test_ensure_root_span_basic(trace_instance): trace_instance.ensure_root_span("tid") assert "tid" in trace_instance.dify_trace_ids + + +def test_ensure_root_span_uses_custom_name_and_attributes(trace_instance): + root_attributes = { + SpanAttributes.INPUT_VALUE: '{"input":"value"}', + SpanAttributes.OUTPUT_VALUE: '{"output":"value"}', + } + + trace_instance.ensure_root_span("tid", root_span_name="Workflow Name", root_span_attributes=root_attributes) + + trace_instance.tracer.start_span.assert_called_once_with( + name="Workflow Name", + attributes={ + SpanAttributes.OPENINFERENCE_SPAN_KIND: "CHAIN", + "dify_project_name": "p", + "dify_trace_id": "tid", + SpanAttributes.INPUT_VALUE: '{"input":"value"}', + SpanAttributes.OUTPUT_VALUE: '{"output":"value"}', + }, + ) + + +def test_ensure_root_span_falls_back_to_dify_name_when_custom_name_is_blank(trace_instance): + trace_instance.ensure_root_span("tid", root_span_name=" ") + + trace_instance.tracer.start_span.assert_called_once() + assert trace_instance.tracer.start_span.call_args.kwargs["name"] == "Dify" diff --git a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py deleted file mode 100644 index a01c63ae61..0000000000 --- a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py +++ /dev/null @@ -1,36 +0,0 @@ -from dify_trace_arize_phoenix.arize_phoenix_trace import _NODE_TYPE_TO_SPAN_KIND, _get_node_span_kind -from openinference.semconv.trace import OpenInferenceSpanKindValues - -from graphon.enums import BUILT_IN_NODE_TYPES, BuiltinNodeTypes - - -class TestGetNodeSpanKind: - """Tests for _get_node_span_kind helper.""" - - def test_all_node_types_are_mapped_correctly(self): - """Ensure every built-in node type is mapped to the correct span kind.""" - # Mappings for node types that have a specialised span kind. - special_mappings = { - BuiltinNodeTypes.LLM: OpenInferenceSpanKindValues.LLM, - BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: OpenInferenceSpanKindValues.RETRIEVER, - BuiltinNodeTypes.TOOL: OpenInferenceSpanKindValues.TOOL, - BuiltinNodeTypes.AGENT: OpenInferenceSpanKindValues.AGENT, - } - - # Test that every built-in node type is mapped to the correct span kind. - # Node types not in `special_mappings` should default to CHAIN. - for node_type in BUILT_IN_NODE_TYPES: - expected_span_kind = special_mappings.get(node_type, OpenInferenceSpanKindValues.CHAIN) - actual_span_kind = _get_node_span_kind(node_type) - assert actual_span_kind == expected_span_kind, ( - f"Node type {node_type!r} was mapped to {actual_span_kind}, but {expected_span_kind} was expected." - ) - - def test_unknown_string_defaults_to_chain(self): - """An unrecognised node type string should still return CHAIN.""" - assert _get_node_span_kind("some-future-node-type") == OpenInferenceSpanKindValues.CHAIN - - def test_stale_dataset_retrieval_not_in_mapping(self): - """The old 'dataset_retrieval' string was never a valid NodeType value; - make sure it is not present in the mapping dictionary.""" - assert "dataset_retrieval" not in _NODE_TYPE_TO_SPAN_KIND diff --git a/api/tasks/ops_trace_task.py b/api/tasks/ops_trace_task.py index c95b8db078..49fe68ad7e 100644 --- a/api/tasks/ops_trace_task.py +++ b/api/tasks/ops_trace_task.py @@ -1,11 +1,31 @@ +""" +Celery task for asynchronous ops trace dispatch. + +Trace providers may report explicitly retryable dispatch failures through the +core retryable exception contract. The task preserves the payload file only +when Celery accepts the retry request; successful dispatches and terminal +failures clean up the stored payload. + +One concrete producer today is Phoenix nested workflow tracing. The outer +workflow tool span publishes a restorable parent span context asynchronously, +while the nested workflow trace may be picked up by Celery first. In that +ordering window, the provider raises a retryable core exception instead of +dropping the trace or emitting it under the wrong parent. The task intentionally +does not know that the provider is Phoenix; it only honors the core retryable +dispatch contract. +""" + import json import logging from celery import shared_task +from celery.exceptions import Retry from flask import current_app +from configs import dify_config from core.ops.entities.config_entity import OPS_FILE_PATH, OPS_TRACE_FAILED_KEY from core.ops.entities.trace_entity import trace_info_info_map +from core.ops.exceptions import RetryableTraceDispatchError from core.rag.models.document import Document from extensions.ext_redis import redis_client from extensions.ext_storage import storage @@ -14,9 +34,17 @@ from models.workflow import WorkflowRun logger = logging.getLogger(__name__) +_RETRYABLE_TRACE_DISPATCH_LIMIT = dify_config.OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES +_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS = dify_config.OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS -@shared_task(queue="ops_trace") -def process_trace_tasks(file_info): + +@shared_task( + queue="ops_trace", + bind=True, + max_retries=_RETRYABLE_TRACE_DISPATCH_LIMIT, + default_retry_delay=_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS, +) +def process_trace_tasks(self, file_info): """ Async process trace tasks Usage: process_trace_tasks.delay(tasks_data) @@ -29,6 +57,7 @@ def process_trace_tasks(file_info): file_data = json.loads(storage.load(file_path)) trace_info = file_data.get("trace_info") trace_info_type = file_data.get("trace_info_type") + enterprise_trace_dispatched = bool(file_data.get("_enterprise_trace_dispatched")) trace_instance = OpsTraceManager.get_ops_trace_instance(app_id) if trace_info.get("message_data"): @@ -38,6 +67,8 @@ def process_trace_tasks(file_info): if trace_info.get("documents"): trace_info["documents"] = [Document.model_validate(doc) for doc in trace_info["documents"]] + should_delete_file = True + try: trace_type = trace_info_info_map.get(trace_info_type) if trace_type: @@ -45,30 +76,66 @@ def process_trace_tasks(file_info): from extensions.ext_enterprise_telemetry import is_enabled as is_ee_telemetry_enabled - if is_ee_telemetry_enabled(): + if is_ee_telemetry_enabled() and not enterprise_trace_dispatched: from enterprise.telemetry.enterprise_trace import EnterpriseOtelTrace try: EnterpriseOtelTrace().trace(trace_info) except Exception: logger.exception("Enterprise trace failed for app_id: %s", app_id) + else: + file_data["_enterprise_trace_dispatched"] = True + enterprise_trace_dispatched = True if trace_instance: with current_app.app_context(): trace_instance.trace(trace_info) logger.info("Processing trace tasks success, app_id: %s", app_id) + except RetryableTraceDispatchError as e: + # Retryable dispatch failures represent a transient provider-side + # ordering gap, not corrupt payload data. Keep the payload only after + # Celery accepts the retry request; otherwise this attempt becomes a + # terminal failure and the stored file is cleaned up in `finally`. + # + # Enterprise telemetry runs before provider dispatch. If it already ran + # and provider dispatch asks for a retry, persist that private flag so + # the next attempt does not emit the same enterprise trace twice. + if self.request.retries >= _RETRYABLE_TRACE_DISPATCH_LIMIT: + logger.exception("Retryable trace dispatch budget exhausted, app_id: %s", app_id) + failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" + redis_client.incr(failed_key) + else: + logger.warning( + "Retryable trace dispatch failure, scheduling retry %s/%s for app_id %s: %s", + self.request.retries + 1, + _RETRYABLE_TRACE_DISPATCH_LIMIT, + app_id, + e, + ) + try: + if enterprise_trace_dispatched: + storage.save(file_path, json.dumps(file_data).encode("utf-8")) + raise self.retry(exc=e, countdown=_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS) + except Retry: + should_delete_file = False + raise + except Exception: + logger.exception("Failed to schedule trace dispatch retry, app_id: %s", app_id) + failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" + redis_client.incr(failed_key) except Exception as e: logger.exception("Processing trace tasks failed, app_id: %s", app_id) failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" redis_client.incr(failed_key) finally: - try: - storage.delete(file_path) - except Exception as e: - logger.warning( - "Failed to delete trace file %s for app_id %s: %s", - file_path, - app_id, - e, - ) + if should_delete_file: + try: + storage.delete(file_path) + except Exception as e: + logger.warning( + "Failed to delete trace file %s for app_id %s: %s", + file_path, + app_id, + e, + ) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 0e9f8b6f35..2e4e469eb5 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -1,3 +1,4 @@ +import contextlib from types import SimpleNamespace from unittest.mock import MagicMock @@ -24,6 +25,76 @@ def test_should_prepare_user_inputs_keeps_validation_when_flag_false(): assert WorkflowAppGenerator()._should_prepare_user_inputs(args) +def test_generate_includes_parent_trace_context_in_extras(monkeypatch): + generator = WorkflowAppGenerator() + + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppGenerator._bind_file_access_scope", + lambda *args, **kwargs: contextlib.nullcontext(), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config", + lambda *args, **kwargs: SimpleNamespace( + app_id="app-1", tenant_id="tenant-1", workflow_id="workflow-1", variables=[] + ), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.file_factory.build_from_mappings", lambda *args, **kwargs: [] + ) + monkeypatch.setattr("core.app.apps.workflow.app_generator.TraceQueueManager", MagicMock()) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", + MagicMock(return_value=MagicMock()), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + MagicMock(return_value=MagicMock()), + ) + monkeypatch.setattr("core.app.apps.workflow.app_generator.db", SimpleNamespace(engine=MagicMock())) + monkeypatch.setattr(generator, "_prepare_user_inputs", lambda *, user_inputs, **kwargs: user_inputs) + + captured = {} + + def fake_workflow_app_generate_entity(**kwargs): + captured["workflow_app_generate_entity_kwargs"] = kwargs + return SimpleNamespace(**kwargs) + + def fake_generate(**kwargs): + captured["application_generate_entity"] = kwargs["application_generate_entity"] + return {"data": {}} + + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppGenerateEntity", fake_workflow_app_generate_entity + ) + monkeypatch.setattr(generator, "_generate", fake_generate) + + result = generator.generate( + app_model=SimpleNamespace(tenant_id="tenant-1", id="app-1"), + workflow=SimpleNamespace(features_dict={}), + user=SimpleNamespace(id="user-1", session_id="session-1"), + args={ + "inputs": {"query": "hello"}, + "files": [], + "external_trace_id": "trace-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + invoke_from="service-api", + streaming=False, + call_depth=0, + ) + + assert result == {"data": {}} + extras = captured["workflow_app_generate_entity_kwargs"]["extras"] + assert extras["external_trace_id"] == "trace-1" + assert extras["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + def test_resume_delegates_to_generate(mocker: MockerFixture): generator = WorkflowAppGenerator() mock_generate = mocker.patch.object(generator, "_generate", return_value="ok") diff --git a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py index 7e87c088ce..9cefa97bef 100644 --- a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py +++ b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py @@ -7,6 +7,7 @@ import pytest from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer +from core.ops.ops_trace_manager import TraceTask, TraceTaskName from core.workflow.system_variables import SystemVariableKey, build_system_variables from graphon.entities import WorkflowNodeExecution from graphon.entities.pause_reason import SchedulingPause @@ -217,6 +218,59 @@ class TestWorkflowPersistenceLayer: assert exec_repo.saved[-1].status == WorkflowExecutionStatus.FAILED assert trace_tasks + def test_handle_graph_run_succeeded_enqueues_parent_trace_context(self, monkeypatch): + trace_tasks: list[TraceTask] = [] + trace_manager = SimpleNamespace(user_id="user", add_trace_task=lambda task: trace_tasks.append(task)) + layer, _, _, _ = _make_layer( + extras={ + "external_trace_id": "trace", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + trace_manager=trace_manager, + ) + layer._handle_graph_run_started() + + captured: dict[str, object] = {} + + def fake_workflow_trace( + self: TraceTask, + *, + workflow_run_id: str | None, + conversation_id: str | None, + user_id: str | None, + total_tokens_override: int | None = None, + ): + captured["trace_type"] = self.trace_type + captured["external_trace_id"] = self.kwargs.get("external_trace_id") + captured["parent_trace_context"] = self.kwargs.get("parent_trace_context") + captured["workflow_run_id"] = workflow_run_id + return {"ok": True} + + monkeypatch.setattr(TraceTask, "workflow_trace", fake_workflow_trace) + + layer._handle_graph_run_succeeded(GraphRunSucceededEvent(outputs={"ok": True})) + + assert trace_tasks + trace_task = trace_tasks[0] + assert trace_task.trace_type == TraceTaskName.WORKFLOW_TRACE + assert trace_task.kwargs["external_trace_id"] == "trace" + assert trace_task.kwargs["parent_trace_context"] == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + trace_task.execute() + + assert captured["trace_type"] == TraceTaskName.WORKFLOW_TRACE + assert captured["external_trace_id"] == "trace" + assert captured["parent_trace_context"] == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + def test_handle_graph_run_aborted_sets_status(self): layer, exec_repo, _, _ = _make_layer() layer._handle_graph_run_started() diff --git a/api/tests/unit_tests/core/helper/test_trace_id_helper.py b/api/tests/unit_tests/core/helper/test_trace_id_helper.py index 27bfe1af05..96e2d44730 100644 --- a/api/tests/unit_tests/core/helper/test_trace_id_helper.py +++ b/api/tests/unit_tests/core/helper/test_trace_id_helper.py @@ -1,6 +1,12 @@ import pytest -from core.helper.trace_id_helper import extract_external_trace_id_from_args, get_external_trace_id, is_valid_trace_id +from core.helper.trace_id_helper import ( + ParentTraceContext, + extract_external_trace_id_from_args, + extract_parent_trace_context_from_args, + get_external_trace_id, + is_valid_trace_id, +) class DummyRequest: @@ -84,3 +90,92 @@ class TestTraceIdHelper: def test_extract_external_trace_id_from_args(self, args, expected): """Test extraction of external_trace_id from args mapping""" assert extract_external_trace_id_from_args(args) == expected + + @pytest.mark.parametrize( + ("args", "expected"), + [ + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": "node-execution-1", + } + }, + { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id="node-execution-1", + ) + }, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_node_execution_id": "node-execution-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": 123, + "parent_node_execution_id": "node-execution-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": None, + } + }, + {}, + ), + ({}, {}), + ], + ) + def test_extract_parent_trace_context_from_args(self, args, expected): + """Test extraction of parent_trace_context from args mapping""" + assert extract_parent_trace_context_from_args(args) == expected + + def test_extract_parent_trace_context_returns_typed_context(self): + """Parent trace context is parsed into a Pydantic value object.""" + result = extract_parent_trace_context_from_args( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": "node-execution-1", + } + } + ) + + assert result == { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id="node-execution-1", + ) + } + + def test_extract_parent_trace_context_rejects_incomplete_typed_context(self): + """Typed parent trace context follows the same completeness rule as raw mappings.""" + result = extract_parent_trace_context_from_args( + { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id=None, + ) + } + ) + + assert result == {} diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py index 72a73dd936..6c563b0912 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py @@ -147,6 +147,142 @@ def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.Monke assert call_kwargs["pause_state_config"] is None +def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pytest.MonkeyPatch): + """Ensure nested workflow runtime metadata is forwarded as parent trace context.""" + tool = _build_tool() + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert call_kwargs["args"]["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + +def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypatch: pytest.MonkeyPatch): + """Ensure private trace context does not overwrite same-named workflow inputs.""" + tool = _build_tool() + tool.entity.parameters = [ + ToolParameter.get_simple_instance( + name="outer_workflow_run_id", + llm_description="User workflow input", + typ=ToolParameter.ToolParameterType.STRING, + required=False, + ), + ToolParameter.get_simple_instance( + name="outer_node_execution_id", + llm_description="User node input", + typ=ToolParameter.ToolParameterType.STRING, + required=False, + ), + ] + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list( + tool.invoke( + "test_user", + { + "outer_workflow_run_id": "user-workflow-input", + "outer_node_execution_id": "user-node-input", + }, + ) + ) + + call_kwargs = generate_mock.call_args.kwargs + assert call_kwargs["args"]["inputs"]["outer_workflow_run_id"] == "user-workflow-input" + assert call_kwargs["args"]["inputs"]["outer_node_execution_id"] == "user-node-input" + assert call_kwargs["args"]["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + +def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.MonkeyPatch): + """Ensure reused WorkflowTool instances do not keep stale parent trace context.""" + tool = _build_tool() + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + tool.clear_parent_trace_context() + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert "parent_trace_context" not in call_kwargs["args"] + + +@pytest.mark.parametrize( + "runtime_parameters", + [ + {}, + {"outer_workflow_run_id": "outer-workflow-run-1"}, + {"outer_node_execution_id": "outer-node-execution-1"}, + {"outer_workflow_run_id": None, "outer_node_execution_id": None}, + ], +) +def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete( + monkeypatch: pytest.MonkeyPatch, + runtime_parameters: dict[str, Any], +): + """Ensure incomplete runtime metadata does not leak parent trace context into generator args.""" + tool = _build_tool() + tool.runtime.runtime_parameters = runtime_parameters + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert "parent_trace_context" not in call_kwargs["args"] + + def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch: pytest.MonkeyPatch): """Test that WorkflowTool should generate variable messages when there are outputs""" tool = _build_tool() diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py index f17c95fc13..4d30746e5c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -15,9 +15,9 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import StreamChunkEvent, StreamCompletedEvent from graphon.nodes.tool.entities import ToolNodeData from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import GraphRuntimeState from graphon.variables.segments import ArrayFileSegment -from tests.workflow_test_utils import build_test_graph_init_params +from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool if TYPE_CHECKING: # pragma: no cover - imported for type checking only from graphon.nodes.tool.tool_node import ToolNode @@ -106,7 +106,7 @@ def tool_node(monkeypatch) -> ToolNode: call_depth=0, ) - variable_pool = VariablePool.from_bootstrap(system_variables=build_system_variables(user_id="user-id")) + variable_pool = build_test_variable_pool(variables=build_system_variables(user_id="user-id")) graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) config = graph_config["nodes"][0] @@ -234,3 +234,22 @@ def test_image_link_messages_use_tool_file_id_metadata(tool_node: ToolNode): files_segment = completed_events[0].node_run_result.outputs["files"] assert isinstance(files_segment, ArrayFileSegment) assert files_segment.value == [file_obj] + + +def test_tool_node_passes_node_execution_id_when_runtime_accepts_it(tool_node: ToolNode): + runtime_handle = ToolRuntimeHandle(raw=object()) + tool_node._runtime.get_runtime = MagicMock(return_value=runtime_handle) + tool_node.ensure_execution_id = MagicMock(return_value="node-execution-id") + + result = tool_node._get_tool_runtime( + variable_pool=tool_node.graph_runtime_state.variable_pool, + node_execution_id="node-execution-id", + ) + + assert result is runtime_handle + tool_node._runtime.get_runtime.assert_called_once_with( + node_id="node-instance", + node_data=tool_node.node_data, + variable_pool=tool_node.graph_runtime_state.variable_pool, + node_execution_id="node-execution-id", + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py index 438af211f3..aece73ce8c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py @@ -147,6 +147,69 @@ def test_get_runtime_converts_graph_provider_type_for_tool_manager(runtime: Dify assert workflow_tool.provider_type == CoreToolProviderType.BUILT_IN +def test_get_runtime_stores_parent_trace_context_for_workflow_tools( + runtime: DifyToolNodeRuntime, +) -> None: + variable_pool: VariablePool = build_test_variable_pool( + variables=build_system_variables( + conversation_id="conversation-id", + workflow_execution_id="workflow-run-id", + ) + ) + workflow_runtime = MagicMock() + workflow_runtime.runtime.runtime_parameters = {} + node_data = ToolNodeData.model_validate( + { + "type": "tool", + "title": "Tool", + "provider_id": "provider", + "provider_type": ToolProviderType.WORKFLOW, + "provider_name": "provider", + "tool_name": "lookup", + "tool_label": "Lookup", + "tool_configurations": {}, + "tool_parameters": {}, + } + ) + + with patch.object(ToolManager, "get_workflow_tool_runtime", return_value=workflow_runtime): + tool_runtime = runtime.get_runtime( + node_id="node-id", + node_data=node_data, + variable_pool=variable_pool, + node_execution_id="node-execution-id", + ) + + assert tool_runtime.raw.parent_trace_context.model_dump() == { + "parent_workflow_run_id": "workflow-run-id", + "parent_node_execution_id": "node-execution-id", + } + assert workflow_runtime.runtime.runtime_parameters == {} + + +def test_get_runtime_leaves_non_workflow_tool_runtime_parameters_unchanged( + runtime: DifyToolNodeRuntime, +) -> None: + variable_pool: VariablePool = build_test_variable_pool( + variables=build_system_variables( + conversation_id="conversation-id", + workflow_execution_id="workflow-run-id", + ) + ) + builtin_runtime = MagicMock() + builtin_runtime.runtime.runtime_parameters = {} + + with patch.object(ToolManager, "get_workflow_tool_runtime", return_value=builtin_runtime): + runtime.get_runtime( + node_id="node-id", + node_data=_build_tool_node_data(), + variable_pool=variable_pool, + node_execution_id="node-execution-id", + ) + + assert builtin_runtime.runtime.runtime_parameters == {} + + def test_get_runtime_parameters_reads_required_flags(runtime: DifyToolNodeRuntime) -> None: tool_runtime = ToolRuntimeHandle( raw=SimpleNamespace( diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 0d13151f42..d2925fd1a8 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -316,6 +316,81 @@ def test_dify_tool_file_manager_delegates_file_generator_lookup(monkeypatch: pyt get_file_generator.assert_called_once_with("tool-file-id") +def test_dify_tool_node_runtime_injects_outer_workflow_run_id_for_workflow_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) + get_runtime = MagicMock(return_value=runtime_tool) + monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + monkeypatch.setattr( + node_runtime, + "get_system_text", + lambda _pool, key: ( + "outer-workflow-run-id" if key == node_runtime.SystemVariableKey.WORKFLOW_EXECUTION_ID else None + ), + ) + + runtime = node_runtime.DifyToolNodeRuntime(_build_run_context()) + node_data = ToolNodeData( + title="Workflow Tool Node", + desc=None, + provider_id="workflow-provider-id", + provider_type=ToolProviderType.WORKFLOW, + provider_name="workflow-provider", + tool_name="workflow-tool", + tool_label="Workflow Tool", + tool_configurations={}, + tool_parameters={}, + ) + + handle = runtime.get_runtime( + node_id="tool-node", + node_data=node_data, + variable_pool=object(), + node_execution_id="node-execution-id", + ) + + assert handle.raw.tool is runtime_tool + assert handle.raw.parent_trace_context.model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-id", + "parent_node_execution_id": "node-execution-id", + } + assert runtime_tool.runtime.runtime_parameters == {} + get_runtime.assert_called_once() + + +def test_dify_tool_node_runtime_does_not_inject_outer_workflow_run_id_for_non_workflow_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) + get_runtime = MagicMock(return_value=runtime_tool) + monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + monkeypatch.setattr(node_runtime, "get_system_text", lambda _pool, _key: None) + + runtime = node_runtime.DifyToolNodeRuntime(_build_run_context()) + node_data = ToolNodeData( + title="Builtin Tool Node", + desc=None, + provider_id="builtin-provider-id", + provider_type=ToolProviderType.BUILT_IN, + provider_name="builtin-provider", + tool_name="builtin-tool", + tool_label="Builtin Tool", + tool_configurations={}, + tool_parameters={}, + ) + + handle = runtime.get_runtime( + node_id="tool-node", + node_data=node_data, + variable_pool=object(), + ) + + assert handle.raw.tool is runtime_tool + assert "outer_workflow_run_id" not in runtime_tool.runtime.runtime_parameters + get_runtime.assert_called_once() + + def test_dify_human_input_runtime_builds_debug_repository(monkeypatch: pytest.MonkeyPatch) -> None: repository = MagicMock() repository_cls = MagicMock(return_value=repository) diff --git a/api/tests/unit_tests/tasks/test_ops_trace_task.py b/api/tests/unit_tests/tasks/test_ops_trace_task.py new file mode 100644 index 0000000000..5844c55c04 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_ops_trace_task.py @@ -0,0 +1,301 @@ +import json +import sys +from contextlib import contextmanager +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest +from celery.exceptions import Retry + +from core.ops.entities.config_entity import OPS_TRACE_FAILED_KEY +from core.ops.exceptions import RetryableTraceDispatchError +from tasks.ops_trace_task import process_trace_tasks + + +@contextmanager +def fake_app_context(): + yield + + +class FakeCurrentApp: + def app_context(self): + return fake_app_context() + + +def _install_trace_manager( + trace_instance: MagicMock, + *, + enterprise_enabled: bool = False, + enterprise_trace_cls: MagicMock | None = None, +) -> dict[str, ModuleType]: + ops_trace_manager_module = ModuleType("core.ops.ops_trace_manager") + + class StubOpsTraceManager: + @staticmethod + def get_ops_trace_instance(app_id: str) -> MagicMock: + return trace_instance + + telemetry_module = ModuleType("extensions.ext_enterprise_telemetry") + telemetry_module.is_enabled = lambda: enterprise_enabled + + ops_trace_manager_module.OpsTraceManager = StubOpsTraceManager + modules = { + "core.ops.ops_trace_manager": ops_trace_manager_module, + "extensions.ext_enterprise_telemetry": telemetry_module, + } + if enterprise_trace_cls is not None: + enterprise_module = ModuleType("enterprise") + enterprise_telemetry_module = ModuleType("enterprise.telemetry") + enterprise_trace_module = ModuleType("enterprise.telemetry.enterprise_trace") + enterprise_trace_module.EnterpriseOtelTrace = enterprise_trace_cls + modules.update( + { + "enterprise": enterprise_module, + "enterprise.telemetry": enterprise_telemetry_module, + "enterprise.telemetry.enterprise_trace": enterprise_trace_module, + } + ) + return modules + + +def _make_payload() -> str: + return json.dumps({"trace_info": {}, "trace_info_type": None}) + + +def _decode_saved_payload(payload: bytes | str) -> dict[str, object]: + if isinstance(payload, bytes): + payload = payload.decode("utf-8") + return json.loads(payload) + + +def _retryable_dispatch_error() -> RetryableTraceDispatchError: + return RetryableTraceDispatchError("transient trace dispatch failure") + + +def _run_task(file_info: dict[str, str], retries: int = 0) -> None: + process_trace_tasks.push_request(retries=retries) + try: + process_trace_tasks.run(file_info) + finally: + process_trace_tasks.pop_request() + + +def test_process_trace_tasks_retries_retryable_dispatch_failure_and_preserves_payload(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_marks_enterprise_trace_dispatched_before_retryable_dispatch_retry(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + enterprise_tracer = MagicMock() + enterprise_trace_cls = MagicMock(return_value=enterprise_tracer) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + enterprise_tracer.trace.assert_called_once_with({}) + saved_path, saved_payload = mock_save.call_args.args + assert saved_path == "ops_trace/app-id/file-id.json" + assert _decode_saved_payload(saved_payload)["_enterprise_trace_dispatched"] is True + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_does_not_mark_failed_enterprise_trace_as_dispatched_before_retry(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + enterprise_tracer = MagicMock() + enterprise_tracer.trace.side_effect = RuntimeError("enterprise trace failed") + enterprise_trace_cls = MagicMock(return_value=enterprise_tracer) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + enterprise_tracer.trace.assert_called_once_with({}) + mock_save.assert_not_called() + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_skips_enterprise_trace_when_retry_payload_was_already_dispatched(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + enterprise_trace_cls = MagicMock() + payload = json.dumps({"trace_info": {}, "trace_info_type": None, "_enterprise_trace_dispatched": True}) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=payload), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + enterprise_trace_cls.assert_not_called() + trace_instance.trace.assert_called_once_with({}) + mock_save.assert_not_called() + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_not_called() + + +def test_process_trace_tasks_default_retry_window_covers_parent_span_context_ttl(): + assert process_trace_tasks.max_retries * process_trace_tasks.default_retry_delay >= 300 + + +def test_process_trace_tasks_deletes_payload_on_success(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + trace_instance.trace.assert_called_once_with({}) + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_not_called() + + +def test_process_trace_tasks_deletes_payload_and_counts_terminal_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + trace_instance.trace.side_effect = RuntimeError("trace failed") + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") + + +def test_process_trace_tasks_treats_retry_enqueue_failure_as_terminal_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + retry_enqueue_error = RuntimeError("retry enqueue failed") + trace_instance.trace.side_effect = pending_error + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_enqueue_error) as mock_retry, + ): + _run_task(file_info) + + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") + + +def test_process_trace_tasks_deletes_payload_and_counts_exhausted_retryable_dispatch_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry") as mock_retry, + ): + _run_task(file_info, retries=process_trace_tasks.max_retries) + + mock_retry.assert_not_called() + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") diff --git a/docker/.env.example b/docker/.env.example index d9891d842a..5a012973c0 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,5 +1,6 @@ # ------------------------------------------------------------------ # Essential defaults for Docker Compose deployments. +# Only include variables required for services to start. # # For a default deployment, copy this file to .env and run: # docker compose up -d diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index af1c3ce74e..80cfe42c38 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -71,6 +71,8 @@ LOG_TZ=UTC DEBUG=false FLASK_DEBUG=false ENABLE_REQUEST_LOGGING=False +OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60 +OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5 WORKFLOW_LOG_CLEANUP_ENABLED=false WORKFLOW_LOG_RETENTION_DAYS=30 WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 From 6164408da15f5043ad0c4b8ee5378800ddefe9ea Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 11 May 2026 16:42:09 +0800 Subject: [PATCH 44/53] fix(web): align tag filter dropdown icon (#36041) --- web/features/tag-management/components/tag-filter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/features/tag-management/components/tag-filter.tsx b/web/features/tag-management/components/tag-filter.tsx index bca8465730..2c6938dc4d 100644 --- a/web/features/tag-management/components/tag-filter.tsx +++ b/web/features/tag-management/components/tag-filter.tsx @@ -78,11 +78,11 @@ export const TagFilter = ({ !!value.length && 'pr-6 shadow-xs', )} > - <span className="flex min-w-0 items-center gap-1"> + <span className="flex w-full min-w-0 items-center gap-1"> <span className="p-px"> <Tag01Icon className="h-3.5 w-3.5 text-text-tertiary" aria-hidden="true" /> </span> - <span className="min-w-0 truncate text-[13px] leading-4.5 text-text-secondary"> + <span className="min-w-0 grow truncate text-[13px] leading-4.5 text-text-secondary"> {!value.length && t('tag.placeholder', { ns: 'common' })} {!!value.length && currentTagName} </span> From a60cb3b80098234534f245c45c8239e087d20bf9 Mon Sep 17 00:00:00 2001 From: Asuka Minato <i@asukaminato.eu.org> Date: Mon, 11 May 2026 18:17:12 +0900 Subject: [PATCH 45/53] chore: port WorkflowComment (#36039) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/models/comment.py | 65 ++++++++++++------- .../unit_tests/models/test_comment_models.py | 32 ++++++++- 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/api/models/comment.py b/api/models/comment.py index 5d4a08e783..6d151fe13d 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -1,19 +1,22 @@ """Workflow comment models.""" +from __future__ import annotations + from datetime import datetime -from typing import Optional import sqlalchemy as sa from sqlalchemy import Index, func from sqlalchemy.orm import Mapped, mapped_column, relationship +from models.base import TypeBase + from .account import Account -from .base import Base, gen_uuidv7_string +from .base import gen_uuidv7_string from .engine import db from .types import StringUUID -class WorkflowComment(Base): +class WorkflowComment(TypeBase): """Workflow comment model for canvas commenting functionality. Comments are associated with apps rather than specific workflow versions, @@ -42,27 +45,33 @@ class WorkflowComment(Base): Index("workflow_comments_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) position_x: Mapped[float] = mapped_column(sa.Float) position_y: Mapped[float] = mapped_column(sa.Float) content: Mapped[str] = mapped_column(sa.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) updated_at: Mapped[datetime] = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) - resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) - resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime) - resolved_by: Mapped[str | None] = mapped_column(StringUUID) + resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime, default=None) + resolved_by: Mapped[str | None] = mapped_column(StringUUID, default=None) + resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False) # Relationships - replies: Mapped[list["WorkflowCommentReply"]] = relationship( - "WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan" + replies: Mapped[list[WorkflowCommentReply]] = relationship( + lambda: WorkflowCommentReply, back_populates="comment", cascade="all, delete-orphan", init=False ) - mentions: Mapped[list["WorkflowCommentMention"]] = relationship( - "WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan" + mentions: Mapped[list[WorkflowCommentMention]] = relationship( + lambda: WorkflowCommentMention, back_populates="comment", cascade="all, delete-orphan", init=False ) @property @@ -131,7 +140,7 @@ class WorkflowComment(Base): return participants -class WorkflowCommentReply(Base): +class WorkflowCommentReply(TypeBase): """Workflow comment reply model. Attributes: @@ -149,18 +158,24 @@ class WorkflowCommentReply(Base): Index("comment_replies_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) content: Mapped[str] = mapped_column(sa.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) updated_at: Mapped[datetime] = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) # Relationships - comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies") + comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="replies", init=False) @property def created_by_account(self): @@ -174,7 +189,7 @@ class WorkflowCommentReply(Base): self._created_by_account_cache = account -class WorkflowCommentMention(Base): +class WorkflowCommentMention(TypeBase): """Workflow comment mention model. Mentions are only for internal accounts since end users @@ -194,18 +209,18 @@ class WorkflowCommentMention(Base): Index("comment_mentions_user_idx", "mentioned_user_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) - reply_id: Mapped[str | None] = mapped_column( - StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True - ) mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + reply_id: Mapped[str | None] = mapped_column( + StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True, default=None + ) # Relationships - comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions") - reply: Mapped[Optional["WorkflowCommentReply"]] = relationship("WorkflowCommentReply") + comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="mentions", init=False) + reply: Mapped[WorkflowCommentReply | None] = relationship(lambda: WorkflowCommentReply, init=False) @property def mentioned_user_account(self): diff --git a/api/tests/unit_tests/models/test_comment_models.py b/api/tests/unit_tests/models/test_comment_models.py index 277335cbef..8c8985aff8 100644 --- a/api/tests/unit_tests/models/test_comment_models.py +++ b/api/tests/unit_tests/models/test_comment_models.py @@ -4,7 +4,15 @@ from models.comment import WorkflowComment, WorkflowCommentMention, WorkflowComm def test_workflow_comment_account_properties_and_cache() -> None: - comment = WorkflowComment(created_by="user-1", resolved_by="user-2", content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by="user-2", + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) created_account = Mock(id="user-1") resolved_account = Mock(id="user-2") @@ -21,6 +29,8 @@ def test_workflow_comment_account_properties_and_cache() -> None: get_mock.assert_not_called() comment_without_resolver = WorkflowComment( + tenant_id="xxx", + app_id="yyy", created_by="user-1", resolved_by=None, content="hello", @@ -37,7 +47,15 @@ def test_workflow_comment_counts_and_participants() -> None: reply_2 = WorkflowCommentReply(comment_id="comment-1", content="reply-2", created_by="user-2") mention_1 = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-3") mention_2 = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-4") - comment = WorkflowComment(created_by="user-1", resolved_by=None, content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by=None, + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) comment.replies = [reply_1, reply_2] comment.mentions = [mention_1, mention_2] @@ -63,7 +81,15 @@ def test_workflow_comment_counts_and_participants() -> None: def test_workflow_comment_participants_use_cached_accounts() -> None: reply = WorkflowCommentReply(comment_id="comment-1", content="reply-1", created_by="user-2") mention = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-3") - comment = WorkflowComment(created_by="user-1", resolved_by=None, content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by=None, + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) comment.replies = [reply] comment.mentions = [mention] From 59dab7deac1810dd42816cc52450fb2faba0fb2f Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 11 May 2026 17:58:19 +0800 Subject: [PATCH 46/53] refactor(apps): simplify query state and debounce URL writes (#36043) --- .../skills/how-to-write-component/SKILL.md | 14 +- .../components/apps/__tests__/list.spec.tsx | 52 ++-- web/app/components/apps/constants.ts | 1 + .../__tests__/use-apps-query-state.spec.tsx | 291 ++++++++---------- .../apps/hooks/use-apps-query-state.ts | 101 +++--- web/app/components/apps/list.tsx | 83 ++--- 6 files changed, 233 insertions(+), 309 deletions(-) create mode 100644 web/app/components/apps/constants.ts diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index f33a9dd75e..ac77112993 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -55,9 +55,17 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. - Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. -## Navigation, Effects, And Performance +## You Might Not Need An Effect + +- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration. +- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive. +- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known. +- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render. +- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary. +- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components. +- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow. + +## Navigation And Performance - Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission. -- Treat `useEffect` as a last resort. First try deriving values during render, moving event-driven work into handlers, or using existing hooks/APIs for persistence, subscriptions, media queries, timers, and DOM sync. -- Do not use `useEffect` directly in components. If unavoidable, encapsulate it in a purpose-built hook so the component consumes a declarative API. - Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 41d2ccbc80..0c6f1702d7 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -48,16 +48,24 @@ vi.mock('@/context/app-context', () => ({ }), })) -const mockSetQuery = vi.fn() +const mockSetKeywords = vi.fn() +const mockSetTagIDs = vi.fn() +const mockSetIsCreatedByMe = vi.fn() +const mockSetCategory = vi.fn() const mockQueryState = { + category: 'all', tagIDs: [] as string[], keywords: '', isCreatedByMe: false, } vi.mock('../hooks/use-apps-query-state', () => ({ - default: () => ({ + isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum), + useAppsQueryState: () => ({ query: mockQueryState, - setQuery: mockSetQuery, + setCategory: mockSetCategory, + setKeywords: mockSetKeywords, + setTagIDs: mockSetTagIDs, + setIsCreatedByMe: mockSetIsCreatedByMe, }), })) @@ -244,6 +252,7 @@ describe('List', () => { mockServiceState.hasNextPage = false mockServiceState.isLoading = false mockServiceState.isFetchingNextPage = false + mockQueryState.category = 'all' mockQueryState.tagIDs = [] mockQueryState.keywords = '' mockQueryState.isCreatedByMe = false @@ -317,25 +326,21 @@ describe('List', () => { }) describe('Tab Navigation', () => { - it('should update URL when workflow tab is clicked', async () => { - const { onUrlUpdate } = renderList() + it('should update category when workflow tab is clicked', () => { + renderList() fireEvent.click(screen.getByText('app.types.workflow')) - await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW) + expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) }) - it('should update URL when all tab is clicked', async () => { - const { onUrlUpdate } = renderList('?category=workflow') + it('should update category when all tab is clicked', () => { + mockQueryState.category = AppModeEnum.WORKFLOW + renderList() fireEvent.click(screen.getByText('app.types.all')) - await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - // nuqs removes the default value ('all') from URL params - expect(lastCall.searchParams.has('category')).toBe(false) + expect(mockSetCategory).toHaveBeenCalledWith('all') }) }) @@ -351,7 +356,7 @@ describe('List', () => { const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'test search' } }) - expect(mockSetQuery).toHaveBeenCalled() + expect(mockSetKeywords).toHaveBeenCalledWith('test search') }) it('should handle search clear button click', () => { @@ -364,7 +369,7 @@ describe('List', () => { if (clearButton) fireEvent.click(clearButton) - expect(mockSetQuery).toHaveBeenCalled() + expect(mockSetKeywords).toHaveBeenCalledWith('') }) }) @@ -373,8 +378,9 @@ describe('List', () => { mockQueryState.tagIDs = ['tag-1'] mockQueryState.keywords = 'sales' mockQueryState.isCreatedByMe = true + mockQueryState.category = AppModeEnum.WORKFLOW - renderList('?category=workflow') + renderList() const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions @@ -412,7 +418,7 @@ describe('List', () => { const checkbox = screen.getByTestId('checkbox-undefined') fireEvent.click(checkbox) - expect(mockSetQuery).toHaveBeenCalled() + expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true) }) }) @@ -506,8 +512,8 @@ describe('List', () => { expect(screen.getByText('app.types.completion'))!.toBeInTheDocument() }) - it('should update URL for each app type tab click', async () => { - const { onUrlUpdate } = renderList() + it('should update category for each app type tab click', () => { + renderList() const appTypeTexts = [ { mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' }, @@ -518,11 +524,9 @@ describe('List', () => { ] for (const { mode, text } of appTypeTexts) { - onUrlUpdate.mockClear() + mockSetCategory.mockClear() fireEvent.click(screen.getByText(text)) - await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(lastCall.searchParams.get('category')).toBe(mode) + expect(mockSetCategory).toHaveBeenCalledWith(mode) } }) }) diff --git a/web/app/components/apps/constants.ts b/web/app/components/apps/constants.ts new file mode 100644 index 0000000000..95c3dcff42 --- /dev/null +++ b/web/app/components/apps/constants.ts @@ -0,0 +1 @@ +export const APP_LIST_SEARCH_DEBOUNCE_MS = 500 diff --git a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx index 4b0c63f580..782f6ec353 100644 --- a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx +++ b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx @@ -1,6 +1,8 @@ import { act, waitFor } from '@testing-library/react' import { renderHookWithNuqs } from '@/test/nuqs-testing' -import useAppsQueryState from '../use-apps-query-state' +import { AppModeEnum } from '@/types/app' +import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants' +import { useAppsQueryState } from '../use-apps-query-state' const renderWithAdapter = (searchParams = '') => { return renderHookWithNuqs(() => useAppsQueryState(), { searchParams }) @@ -11,214 +13,161 @@ describe('useAppsQueryState', () => { vi.clearAllMocks() }) - describe('Initialization', () => { - it('should expose query and setQuery when initialized', () => { - const { result } = renderWithAdapter() + it('should expose app list query state actions', () => { + const { result } = renderWithAdapter() - expect(result.current.query).toBeDefined() - expect(typeof result.current.setQuery).toBe('function') + expect(result.current.query).toEqual({ + category: 'all', + tagIDs: [], + keywords: '', + isCreatedByMe: false, }) + expect(typeof result.current.setCategory).toBe('function') + expect(typeof result.current.setKeywords).toBe('function') + expect(typeof result.current.setTagIDs).toBe('function') + expect(typeof result.current.setIsCreatedByMe).toBe('function') + }) - it('should default to empty filters when search params are missing', () => { - const { result } = renderWithAdapter() + it('should parse app list filters from URL', () => { + const { result } = renderWithAdapter( + '?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true', + ) - expect(result.current.query.tagIDs).toBeUndefined() - expect(result.current.query.keywords).toBeUndefined() - expect(result.current.query.isCreatedByMe).toBe(false) + expect(result.current.query).toEqual({ + category: AppModeEnum.WORKFLOW, + tagIDs: ['tag1', 'tag2'], + keywords: 'search term', + isCreatedByMe: true, }) }) - describe('Parsing search params', () => { - it('should parse tagIDs when URL includes tagIDs', () => { - const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3') + it('should update category URL state', async () => { + const { result, onUrlUpdate } = renderWithAdapter() - expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3']) + act(() => { + result.current.setCategory(AppModeEnum.WORKFLOW) }) - it('should parse keywords when URL includes keywords', () => { - const { result } = renderWithAdapter('?keywords=search+term') - - expect(result.current.query.keywords).toBe('search term') - }) - - it('should parse isCreatedByMe when URL includes true value', () => { - const { result } = renderWithAdapter('?isCreatedByMe=true') - - expect(result.current.query.isCreatedByMe).toBe(true) - }) - - it('should parse all params when URL includes multiple filters', () => { - const { result } = renderWithAdapter( - '?tagIDs=tag1;tag2&keywords=test&isCreatedByMe=true', - ) - - expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2']) - expect(result.current.query.keywords).toBe('test') - expect(result.current.query.isCreatedByMe).toBe(true) - }) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(result.current.query.category).toBe(AppModeEnum.WORKFLOW) + expect(update.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW) + expect(update.options.history).toBe('push') }) - describe('Updating query state', () => { - it('should update keywords when setQuery receives keywords', () => { - const { result } = renderWithAdapter() + it('should remove category from URL when set to all', async () => { + const { result, onUrlUpdate } = renderWithAdapter('?category=workflow') - act(() => { - result.current.setQuery({ keywords: 'new search' }) - }) - - expect(result.current.query.keywords).toBe('new search') + act(() => { + result.current.setCategory('all') }) - it('should update tagIDs when setQuery receives tagIDs', () => { - const { result } = renderWithAdapter() - - act(() => { - result.current.setQuery({ tagIDs: ['tag1', 'tag2'] }) - }) - - expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2']) - }) - - it('should update isCreatedByMe when setQuery receives true', () => { - const { result } = renderWithAdapter() - - act(() => { - result.current.setQuery({ isCreatedByMe: true }) - }) - - expect(result.current.query.isCreatedByMe).toBe(true) - }) - - it('should support partial updates when setQuery uses callback', () => { - const { result } = renderWithAdapter() - - act(() => { - result.current.setQuery({ keywords: 'initial' }) - }) - - act(() => { - result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true })) - }) - - expect(result.current.query.keywords).toBe('initial') - expect(result.current.query.isCreatedByMe).toBe(true) - }) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(result.current.query.category).toBe('all') + expect(update.searchParams.has('category')).toBe(false) }) - describe('URL synchronization', () => { - it('should sync keywords to URL when keywords change', async () => { + it('should update keywords state immediately while debouncing URL writes', async () => { + vi.useFakeTimers() + try { const { result, onUrlUpdate } = renderWithAdapter() act(() => { - result.current.setQuery({ keywords: 'search' }) + result.current.setKeywords('search') }) - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] + expect(result.current.query.keywords).toBe('search') + expect(onUrlUpdate).not.toHaveBeenCalled() + + await act(async () => { + await vi.advanceTimersByTimeAsync(APP_LIST_SEARCH_DEBOUNCE_MS + 100) + }) + + expect(onUrlUpdate).toHaveBeenCalled() + const update = onUrlUpdate.mock.calls.at(-1)![0] expect(update.searchParams.get('keywords')).toBe('search') - expect(update.options.history).toBe('push') - }) + } + finally { + vi.useRealTimers() + } + }) - it('should sync tagIDs to URL when tagIDs change', async () => { - const { result, onUrlUpdate } = renderWithAdapter() - - act(() => { - result.current.setQuery({ tagIDs: ['tag1', 'tag2'] }) - }) - - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2') - }) - - it('should sync isCreatedByMe to URL when enabled', async () => { - const { result, onUrlUpdate } = renderWithAdapter() - - act(() => { - result.current.setQuery({ isCreatedByMe: true }) - }) - - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(update.searchParams.get('isCreatedByMe')).toBe('true') - }) - - it('should remove keywords from URL when keywords are cleared', async () => { + it('should remove keywords from URL when cleared', async () => { + vi.useFakeTimers() + try { const { result, onUrlUpdate } = renderWithAdapter('?keywords=existing') act(() => { - result.current.setQuery({ keywords: '' }) + result.current.setKeywords('') }) - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] + expect(result.current.query.keywords).toBe('') + + await act(async () => { + await vi.advanceTimersByTimeAsync(APP_LIST_SEARCH_DEBOUNCE_MS + 100) + }) + + expect(onUrlUpdate).toHaveBeenCalled() + const update = onUrlUpdate.mock.calls.at(-1)![0] expect(update.searchParams.has('keywords')).toBe(false) - }) - - it('should remove tagIDs from URL when tagIDs are empty', async () => { - const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2') - - act(() => { - result.current.setQuery({ tagIDs: [] }) - }) - - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(update.searchParams.has('tagIDs')).toBe(false) - }) - - it('should remove isCreatedByMe from URL when disabled', async () => { - const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true') - - act(() => { - result.current.setQuery({ isCreatedByMe: false }) - }) - - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(update.searchParams.has('isCreatedByMe')).toBe(false) - }) + } + finally { + vi.useRealTimers() + } }) - describe('Edge cases', () => { - it('should treat empty tagIDs as empty list when URL param is empty', () => { - const { result } = renderWithAdapter('?tagIDs=') + it('should update tag filter URL state', async () => { + const { result, onUrlUpdate } = renderWithAdapter() - expect(result.current.query.tagIDs).toEqual([]) + act(() => { + result.current.setTagIDs(['tag1', 'tag2']) }) - it('should treat empty keywords as undefined when URL param is empty', () => { - const { result } = renderWithAdapter('?keywords=') - - expect(result.current.query.keywords).toBeUndefined() - }) - - it('should decode keywords with spaces when URL contains encoded spaces', () => { - const { result } = renderWithAdapter('?keywords=test+with+spaces') - - expect(result.current.query.keywords).toBe('test with spaces') - }) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2']) + expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2') + expect(update.options.history).toBe('push') }) - describe('Integration scenarios', () => { - it('should keep accumulated filters when updates are sequential', () => { - const { result } = renderWithAdapter() + it('should remove tagIDs from URL when empty', async () => { + const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2') - act(() => { - result.current.setQuery({ keywords: 'first' }) - }) - - act(() => { - result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] })) - }) - - act(() => { - result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true })) - }) - - expect(result.current.query.keywords).toBe('first') - expect(result.current.query.tagIDs).toEqual(['tag1']) - expect(result.current.query.isCreatedByMe).toBe(true) + act(() => { + result.current.setTagIDs([]) }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(result.current.query.tagIDs).toEqual([]) + expect(update.searchParams.has('tagIDs')).toBe(false) + }) + + it('should update created-by-me URL state', async () => { + const { result, onUrlUpdate } = renderWithAdapter() + + act(() => { + result.current.setIsCreatedByMe(true) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(result.current.query.isCreatedByMe).toBe(true) + expect(update.searchParams.get('isCreatedByMe')).toBe('true') + expect(update.options.history).toBe('push') + }) + + it('should remove isCreatedByMe from URL when disabled', async () => { + const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true') + + act(() => { + result.current.setIsCreatedByMe(false) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(result.current.query.isCreatedByMe).toBe(false) + expect(update.searchParams.has('isCreatedByMe')).toBe(false) }) }) diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts index ecf7707e8a..a0109eb061 100644 --- a/web/app/components/apps/hooks/use-apps-query-state.ts +++ b/web/app/components/apps/hooks/use-apps-query-state.ts @@ -1,57 +1,56 @@ -import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs' +import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs' import { useCallback, useMemo } from 'react' +import { AppModes } from '@/types/app' +import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants' -type AppsQuery = { - tagIDs?: string[] - keywords?: string - isCreatedByMe?: boolean +const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const +export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number] + +const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES) + +export const isAppListCategory = (value: string): value is AppListCategory => { + return appListCategorySet.has(value) } -const normalizeKeywords = (value: string | null) => value || undefined - -function useAppsQueryState() { - const [urlQuery, setUrlQuery] = useQueryStates( - { - tagIDs: parseAsArrayOf(parseAsString, ';'), - keywords: parseAsString, - isCreatedByMe: parseAsBoolean, - }, - { - history: 'push', - }, - ) - - const query = useMemo<AppsQuery>(() => ({ - tagIDs: urlQuery.tagIDs ?? undefined, - keywords: normalizeKeywords(urlQuery.keywords), - isCreatedByMe: urlQuery.isCreatedByMe ?? false, - }), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs]) - - const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => { - const buildPatch = (patch: AppsQuery) => { - const result: Partial<typeof urlQuery> = {} - if ('tagIDs' in patch) - result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null - if ('keywords' in patch) - result.keywords = patch.keywords ? patch.keywords : null - if ('isCreatedByMe' in patch) - result.isCreatedByMe = patch.isCreatedByMe ? true : null - return result - } - - if (typeof next === 'function') { - setUrlQuery(prev => buildPatch(next({ - tagIDs: prev.tagIDs ?? undefined, - keywords: normalizeKeywords(prev.keywords), - isCreatedByMe: prev.isCreatedByMe ?? false, - }))) - return - } - - setUrlQuery(buildPatch(next)) - }, [setUrlQuery]) - - return useMemo(() => ({ query, setQuery }), [query, setQuery]) +const appListQueryParsers = { + category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES) + .withDefault('all') + .withOptions({ history: 'push' }), + tagIDs: parseAsArrayOf(parseAsString, ';') + .withDefault([]) + .withOptions({ history: 'push' }), + keywords: parseAsString.withDefault('').withOptions({ + limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS), + }), + isCreatedByMe: parseAsBoolean + .withDefault(false) + .withOptions({ history: 'push' }), } -export default useAppsQueryState +export function useAppsQueryState() { + const [query, setQuery] = useQueryStates(appListQueryParsers) + + const setCategory = useCallback((category: AppListCategory) => { + setQuery({ category }) + }, [setQuery]) + + const setKeywords = useCallback((keywords: string) => { + setQuery({ keywords }) + }, [setQuery]) + + const setTagIDs = useCallback((tagIDs: string[]) => { + setQuery({ tagIDs }) + }, [setQuery]) + + const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => { + setQuery({ isCreatedByMe }) + }, [setQuery]) + + return useMemo(() => ({ + query, + setCategory, + setKeywords, + setTagIDs, + setIsCreatedByMe, + }), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe]) +} diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 0fd31dfb79..e2e8e737fc 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -4,8 +4,7 @@ import type { FC } from 'react' import type { AppListQuery } from '@/contract/console/apps' import { cn } from '@langgenius/dify-ui/cn' import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query' -import { useDebounceFn } from 'ahooks' -import { parseAsStringLiteral, useQueryState } from 'nuqs' +import { useDebounce } from 'ahooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' @@ -18,12 +17,13 @@ import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' import { consoleQuery } from '@/service/client' import { systemFeaturesQueryOptions } from '@/service/system-features' -import { AppModeEnum, AppModes } from '@/types/app' +import { AppModeEnum } from '@/types/app' import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' +import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants' import Empty from './empty' import Footer from './footer' -import useAppsQueryStateHook from './hooks/use-apps-query-state' +import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users' import NewAppCard from './new-app-card' @@ -35,18 +35,6 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro ssr: false, }) -const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const -type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number] -const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES) - -const isAppListCategory = (value: string): value is AppListCategory => { - return appListCategorySet.has(value) -} - -const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES) - .withDefault('all') - .withOptions({ history: 'push' }) - type Props = { controlRefreshList?: number } @@ -56,28 +44,21 @@ const List: FC<Props> = ({ const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() - const [activeTab, setActiveTab] = useQueryState( - 'category', - parseAsAppListCategory, - ) // eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState - const appsQuery = useAppsQueryStateHook() - const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = appsQuery - const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) - const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs) - const [searchKeywords, setSearchKeywords] = useState(keywords) + const { + query: { category, tagIDs, keywords, isCreatedByMe }, + setCategory, + setKeywords, + setTagIDs, + setIsCreatedByMe, + } = useAppsQueryState() + const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS }) const newAppCardRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>() - const setKeywords = useCallback((keywords: string) => { - setQuery(prev => ({ ...prev, keywords })) - }, [setQuery]) - const setTagIDs = useCallback((tagIDs: string[]) => { - setQuery(prev => ({ ...prev, tagIDs })) - }, [setQuery]) const handleDSLFileDropped = useCallback((file: File) => { setDroppedDSLFile(file) @@ -93,11 +74,11 @@ const List: FC<Props> = ({ const appListQuery = useMemo<AppListQuery>(() => ({ page: 1, limit: 30, - name: searchKeywords, + name: debouncedKeywords, ...(tagIDs.length ? { tag_ids: tagIDs } : {}), ...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}), - ...(activeTab !== 'all' ? { mode: activeTab } : {}), - }), [activeTab, isCreatedByMe, searchKeywords, tagIDs]) + ...(category !== 'all' ? { mode: category } : {}), + }), [category, debouncedKeywords, isCreatedByMe, tagIDs]) const { data, @@ -177,27 +158,9 @@ const List: FC<Props> = ({ return () => observer?.disconnect() }, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator]) - const { run: handleSearch } = useDebounceFn(() => { - setSearchKeywords(keywords) - }, { wait: 500 }) - const handleKeywordsChange = (value: string) => { - setKeywords(value) - handleSearch() - } - - const { run: handleTagsUpdate } = useDebounceFn(() => { - setTagIDs(tagFilterValue) - }, { wait: 500 }) - const handleTagsChange = (value: string[]) => { - setTagFilterValue(value) - handleTagsUpdate() - } - const handleCreatedByMeChange = useCallback(() => { - const newValue = !isCreatedByMe - setIsCreatedByMe(newValue) - setQuery(prev => ({ ...prev, isCreatedByMe: newValue })) - }, [isCreatedByMe, setQuery]) + setIsCreatedByMe(!isCreatedByMe) + }, [isCreatedByMe, setIsCreatedByMe]) const pages = useMemo(() => data?.pages ?? [], [data?.pages]) const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages]) @@ -232,10 +195,10 @@ const List: FC<Props> = ({ <div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5"> <TabSliderNew - value={activeTab} + value={category} onChange={(nextValue) => { if (isAppListCategory(nextValue)) - setActiveTab(nextValue) + setCategory(nextValue) }} options={options} /> @@ -246,14 +209,14 @@ const List: FC<Props> = ({ {t('showMyCreatedAppsOnly', { ns: 'app' })} </div> </label> - <TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} onOpenTagManagement={() => setShowTagManagementModal(true)} /> + <TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} /> <Input showLeftIcon showClearIcon wrapperClassName="w-[200px]" value={keywords} - onChange={e => handleKeywordsChange(e.target.value)} - onClear={() => handleKeywordsChange('')} + onChange={e => setKeywords(e.target.value)} + onClear={() => setKeywords('')} /> </div> </div> @@ -267,7 +230,7 @@ const List: FC<Props> = ({ ref={newAppCardRef} isLoading={isLoadingCurrentWorkspace} onSuccess={refetch} - selectedAppType={activeTab} + selectedAppType={category} className={cn(!hasAnyApp && 'z-10')} /> )} From c7d30bf09a63c26a07044d00fd51e37b3ef88b30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 10:56:40 +0900 Subject: [PATCH 47/53] chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in /api (#36050) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 747bb7d647..6861abdbdc 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -7147,11 +7147,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From 4fd4615c567d9e03e70a578dd5e8ba0563cd23ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= <hjlarry@163.com> Date: Tue, 12 May 2026 11:24:14 +0800 Subject: [PATCH 48/53] fix: avoid trial workflow schema model collision (#36061) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/explore/trial.py | 2 +- api/openapi/markdown/console-swagger.md | 12 ++++++++++-- .../controllers/console/explore/test_trial.py | 5 +++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 025c517d20..26b48ec599 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -106,7 +106,7 @@ app_detail_fields_with_site_copy["tags"] = fields.List(fields.Nested(tag_model)) app_detail_fields_with_site_copy["site"] = fields.Nested(site_model) app_detail_with_site_model = get_or_create_model("TrialAppDetailWithSite", app_detail_fields_with_site_copy) -simple_account_model = get_or_create_model("SimpleAccount", simple_account_fields) +simple_account_model = get_or_create_model("TrialSimpleAccount", simple_account_fields) conversation_variable_model = get_or_create_model("TrialConversationVariable", conversation_variable_fields) pipeline_variable_model = get_or_create_model("TrialPipelineVariable", pipeline_variable_fields) diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index f3c188fc06..e56d5f6fe5 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -13786,6 +13786,14 @@ Tag type | unit | string | | No | | variable | string | | No | +#### TrialSimpleAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | No | +| id | string | | No | +| name | string | | No | + #### TrialSite | Name | Type | Description | Required | @@ -13829,7 +13837,7 @@ Tag type | ---- | ---- | ----------- | -------- | | conversation_variables | [ [TrialConversationVariable](#trialconversationvariable) ] | | No | | created_at | object | | No | -| created_by | [SimpleAccount](#simpleaccount) | | No | +| created_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | | environment_variables | [ object ] | | No | | features | object | | No | | graph | object | | No | @@ -13840,7 +13848,7 @@ Tag type | rag_pipeline_variables | [ [TrialPipelineVariable](#trialpipelinevariable) ] | | No | | tool_published | boolean | | No | | updated_at | object | | No | -| updated_by | [SimpleAccount](#simpleaccount) | | No | +| updated_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | | version | string | | No | #### TrialWorkflowPartial diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index 14f00e6295..82a063307b 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -88,6 +88,11 @@ def valid_parameters(): } +def test_trial_workflow_uses_trial_scoped_simple_account_model(): + assert module.simple_account_model.name == "TrialSimpleAccount" + assert hasattr(module.simple_account_model, "items") + + class TestTrialAppWorkflowRunApi: def test_not_workflow_app(self, app: Flask): api = module.TrialAppWorkflowRunApi() From 4bb987eca35314ddeda76bc8ceae91b77d2d6976 Mon Sep 17 00:00:00 2001 From: juyua9 <suyu_ena@163.com> Date: Tue, 12 May 2026 13:07:03 +0800 Subject: [PATCH 49/53] fix: validate missing text indexing technique (#35941) --- api/controllers/service_api/dataset/document.py | 2 +- .../controllers/service_api/dataset/test_document.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index cb48fe6715..e68eeeca25 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -136,7 +136,7 @@ def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[ if not dataset: raise ValueError("Dataset does not exist.") - if not dataset.indexing_technique and not args["indexing_technique"]: + if not dataset.indexing_technique and not args.get("indexing_technique"): raise ValueError("indexing_technique is required.") embedding_model_provider = payload.embedding_model_provider diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index 230c51161f..738238d10a 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -1057,8 +1057,8 @@ class TestDocumentAddByTextApi: """Test error when both dataset and payload lack indexing_technique. When ``indexing_technique`` is ``None`` in the payload, ``model_dump(exclude_none=True)`` - omits the key. The production code accesses ``args["indexing_technique"]`` which raises - ``KeyError`` before the ``ValueError`` guard can fire. + omits the key. The service API should still raise the same validation error as other + document creation paths instead of leaking a ``KeyError`` from the dumped payload dict. """ # Arrange — neutralise billing decorators self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) @@ -1074,7 +1074,7 @@ class TestDocumentAddByTextApi: headers={"Authorization": "Bearer test_token"}, ): api = DocumentAddByTextApi() - with pytest.raises(KeyError): + with pytest.raises(ValueError, match="indexing_technique is required."): api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) From cd90d7ffc15fc1d4fa29c6b7a6870178d7da80d6 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 12 May 2026 13:34:19 +0800 Subject: [PATCH 50/53] refactor(web): migrate searchable pickers to combobox (#36066) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 5 - packages/dify-ui/AGENTS.md | 1 + .../__tests__/access-control.spec.tsx | 18 +- .../add-member-or-group-pop.spec.tsx | 32 +- .../add-member-or-group-pop.tsx | 336 +++--- .../__tests__/document-list.spec.tsx | 105 +- .../document-picker/__tests__/index.spec.tsx | 979 ++---------------- .../common/document-picker/document-list.tsx | 60 +- .../datasets/common/document-picker/index.tsx | 255 +++-- .../preview-document-picker.tsx | 27 +- .../detail/__tests__/document-title.spec.tsx | 87 +- .../documents/detail/__tests__/index.spec.tsx | 17 +- .../documents/detail/document-title.tsx | 32 +- .../datasets/documents/detail/index.tsx | 20 +- 14 files changed, 735 insertions(+), 1239 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 4adca38aa0..46277d3349 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -246,11 +246,6 @@ "count": 1 } }, - "web/app/components/app/app-access-control/add-member-or-group-pop.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/app-publisher/features-wrapper.tsx": { "ts/no-explicit-any": { "count": 4 diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index 9524394214..6eadd200f0 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -9,6 +9,7 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles - No imports from `web/`. No dependencies on next / i18next / ky / jotai / zustand. - One component per folder: `src/<name>/index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./<name>` subpath to `package.json#exports`. - Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`. +- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props. - When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath. ## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index a3c63f5a0c..52c2a0dd54 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -254,9 +254,7 @@ describe('AddMemberOrGroupDialog', () => { await user.click(expandButton) expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) - const memberLabel = screen.getByText(baseMember.name) - const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement - fireEvent.click(memberCheckbox) + await user.click(screen.getByRole('option', { name: /Member One/ })) expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) }) @@ -277,13 +275,13 @@ describe('AddMemberOrGroupDialog', () => { await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group') expect(document.querySelector('.spin-animation')).toBeInTheDocument() - const groupCheckbox = screen.getByText(baseGroup.name).closest('div')?.previousElementSibling as HTMLElement - fireEvent.click(groupCheckbox) - fireEvent.click(groupCheckbox) + const groupOption = screen.getByRole('option', { name: /Group One/ }) + fireEvent.click(groupOption) + fireEvent.click(groupOption) - const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement - fireEvent.click(memberCheckbox) - fireEvent.click(memberCheckbox) + const memberOption = screen.getByRole('option', { name: /Member One/ }) + fireEvent.click(memberOption) + fireEvent.click(memberOption) fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers')) @@ -307,7 +305,7 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') }) }) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index 725b121d30..d34756e85e 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -1,5 +1,5 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import useAccessControlStore from '@/context/access-control-store' import { SubjectType } from '@/models/access-control' @@ -106,8 +106,7 @@ describe('AddMemberOrGroupDialog', () => { expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) - const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement - fireEvent.click(memberCheckbox) + await user.click(screen.getByRole('option', { name: /Member One/ })) expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) }) @@ -125,6 +124,31 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') + }) + + it('should keep breadcrumbs visible when the current group has no candidates', async () => { + useAccessControlStore.setState({ + selectedGroupsForBreadcrumb: [baseGroup], + }) + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [{ currPage: 1, subjects: [], hasMore: false }] }, + }) + + const user = userEvent.setup() + render(<AddMemberOrGroupDialog />) + + await user.click(screen.getByText('common.operation.add')) + + expect(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })).toBeInTheDocument() + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') + + await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })) + + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index 8d9bf19ea3..1e3a992136 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -1,110 +1,207 @@ 'use client' +import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox' import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control' -import { FloatingOverlay } from '@floating-ui/react' import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxItemText, + ComboboxList, + ComboboxStatus, + ComboboxTrigger, +} from '@langgenius/dify-ui/combobox' import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react' import { useDebounce } from 'ahooks' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from '@/context/app-context' import { SubjectType } from '@/models/access-control' import { useSearchForWhiteListCandidates } from '@/service/access-control' import useAccessControlStore from '../../../../context/access-control-store' -import Checkbox from '../../base/checkbox' -import Input from '../../base/input' import Loading from '../../base/loading' export default function AddMemberOrGroupDialog() { const { t } = useTranslation() const [open, setOpen] = useState(false) const [keyword, setKeyword] = useState('') + const scrollRootRef = useRef<HTMLDivElement>(null) + const anchorRef = useRef<HTMLDivElement>(null) + const specificGroups = useAccessControlStore(s => s.specificGroups) + const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) + const specificMembers = useAccessControlStore(s => s.specificMembers) + const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) const debouncedKeyword = useDebounce(keyword, { wait: 500 }) const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1] const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open) - const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => { - setKeyword(e.target.value) - } + const pages = data?.pages ?? [] + const subjects = pages.flatMap(page => page.subjects ?? []) + const selectedSubjects = [ + ...specificGroups.map(groupToSubject), + ...specificMembers.map(memberToSubject), + ] + const hasResults = pages.length > 0 && subjects.length > 0 + const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0 + const hasMore = pages[pages.length - 1]?.hasMore ?? false - const anchorRef = useRef<HTMLDivElement>(null) useEffect(() => { - const hasMore = data?.pages?.[0]?.hasMore ?? false let observer: IntersectionObserver | undefined if (anchorRef.current) { observer = new IntersectionObserver((entries) => { if (entries[0]!.isIntersecting && !isLoading && hasMore) fetchNextPage() - }, { rootMargin: '20px' }) + }, { root: scrollRootRef.current, rootMargin: '20px' }) observer.observe(anchorRef.current) } return () => observer?.disconnect() - }, [isLoading, fetchNextPage, anchorRef, data]) + }, [isLoading, fetchNextPage, hasMore]) + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) + setKeyword('') + + setOpen(nextOpen) + } + + const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => { + if (details.reason !== 'item-press') + setKeyword(inputValue) + } + + const handleValueChange = (nextSubjects: Subject[]) => { + const nextGroups: AccessControlGroup[] = [] + const nextMembers: AccessControlAccount[] = [] + + for (const subject of nextSubjects) { + if (subject.subjectType === SubjectType.GROUP) + nextGroups.push((subject as SubjectGroup).groupData) + else + nextMembers.push((subject as SubjectAccount).accountData) + } + + setSpecificGroups(nextGroups) + setSpecificMembers(nextMembers) + } return ( - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger - render={( - <Button variant="ghost-accent" size="small" className="flex shrink-0 items-center gap-x-0.5"> - <RiAddCircleFill className="h-4 w-4" /> - <span>{t('operation.add', { ns: 'common' })}</span> - </Button> - )} - /> - {open && <FloatingOverlay />} - <PopoverContent + <Combobox<Subject, true> + multiple + open={open} + value={selectedSubjects} + inputValue={keyword} + items={subjects} + itemToStringLabel={getSubjectLabel} + itemToStringValue={getSubjectValue} + isItemEqualToValue={isSameSubject} + filter={null} + onOpenChange={handleOpenChange} + onInputValueChange={handleInputValueChange} + onValueChange={handleValueChange} + > + <ComboboxTrigger + aria-label={t('operation.add', { ns: 'common' })} + icon={false} + size="small" + className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-open:bg-state-accent-hover" + > + <RiAddCircleFill className="h-4 w-4" aria-hidden="true" /> + <span>{t('operation.add', { ns: 'common' })}</span> + </ComboboxTrigger> + <ComboboxContent placement="bottom-end" alignOffset={300} - popupClassName="border-none bg-transparent shadow-none" + popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]" > - <div className="relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"> + <div ref={scrollRootRef} className="min-h-0 overflow-y-auto"> <div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]"> - <Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' }) as string} /> + <ComboboxInputGroup className="h-8 min-h-8 px-2"> + <span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <ComboboxInput + aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })} + placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })} + className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary" + /> + </ComboboxInputGroup> </div> - { - isLoading - ? <div className="p-1"><Loading /></div> - : (data?.pages?.length ?? 0) > 0 - ? ( - <> - <div className="flex h-7 items-center px-2 py-0.5"> - <SelectedGroupsBreadCrumb /> - </div> - <div className="p-1"> - {renderGroupOrMember(data?.pages ?? [])} + {isLoading + ? ( + <ComboboxStatus className="p-1"> + <Loading /> + </ComboboxStatus> + ) + : ( + <> + {shouldShowBreadcrumb && ( + <div className="flex h-7 items-center px-2 py-0.5"> + <SelectedGroupsBreadCrumb /> + </div> + )} + {hasResults + ? ( + <> + <ComboboxList className="max-h-none p-1"> + {(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />} + </ComboboxList> {isFetchingNextPage && <Loading />} - </div> - <div ref={anchorRef} className="h-0"> </div> - </> - ) - : ( - <div className="flex h-7 items-center justify-center px-2 py-0.5"> - <span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}</span> - </div> - ) - } + <div ref={anchorRef} className="h-0" /> + </> + ) + : ( + <ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5"> + {t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })} + </ComboboxEmpty> + )} + </> + )} </div> - </PopoverContent> - </Popover> + </ComboboxContent> + </Combobox> ) } -type GroupOrMemberData = { subjects: Subject[], currPage: number }[] -function renderGroupOrMember(data: GroupOrMemberData) { - return data?.map((page) => { - return ( - <div key={`search_group_member_page_${page.currPage}`}> - {page.subjects?.map((item, index) => { - if (item.subjectType === SubjectType.GROUP) - return <GroupItem key={index} group={(item as SubjectGroup).groupData} /> - return <MemberItem key={index} member={(item as SubjectAccount).accountData} /> - })} - </div> - ) - }) ?? null +function groupToSubject(group: AccessControlGroup): SubjectGroup { + return { + subjectId: group.id, + subjectType: SubjectType.GROUP, + groupData: group, + } +} + +function memberToSubject(member: AccessControlAccount): SubjectAccount { + return { + subjectId: member.id, + subjectType: SubjectType.ACCOUNT, + accountData: member, + } +} + +function getSubjectLabel(subject: Subject) { + if (subject.subjectType === SubjectType.GROUP) + return (subject as SubjectGroup).groupData.name + + return (subject as SubjectAccount).accountData.name +} + +function getSubjectValue(subject: Subject) { + return `${subject.subjectType}:${subject.subjectId}` +} + +function isSameSubject(item: Subject, value: Subject) { + return item.subjectId === value.subjectId && item.subjectType === value.subjectType +} + +function SubjectItem({ subject }: { subject: Subject }) { + if (subject.subjectType === SubjectType.GROUP) + return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} /> + + return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} /> } function SelectedGroupsBreadCrumb() { @@ -112,13 +209,13 @@ function SelectedGroupsBreadCrumb() { const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) const { t } = useTranslation() - const handleBreadCrumbClick = useCallback((index: number) => { + const handleBreadCrumbClick = (index: number) => { const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1) setSelectedGroupsForBreadcrumb(newGroups) - }, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb]) - const handleReset = useCallback(() => { + } + const handleReset = () => { setSelectedGroupsForBreadcrumb([]) - }, [setSelectedGroupsForBreadcrumb]) + } const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0 return ( @@ -162,104 +259,111 @@ function SelectedGroupsBreadCrumb() { type GroupItemProps = { group: AccessControlGroup + subject: Subject } -function GroupItem({ group }: GroupItemProps) { +function GroupItem({ group, subject }: GroupItemProps) { const { t } = useTranslation() const specificGroups = useAccessControlStore(s => s.specificGroups) - const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) const isChecked = specificGroups.some(g => g.id === group.id) - const handleCheckChange = useCallback(() => { - if (!isChecked) { - const newGroups = [...specificGroups, group] - setSpecificGroups(newGroups) - } - else { - const newGroups = specificGroups.filter(g => g.id !== group.id) - setSpecificGroups(newGroups) - } - }, [specificGroups, setSpecificGroups, group, isChecked]) - const handleExpandClick = useCallback(() => { + const handleExpandClick = () => { setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group]) - }, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group]) + } + return ( - <BaseItem> - <Checkbox checked={isChecked} className="h-4 w-4 shrink-0" onCheck={handleCheckChange} /> - <div className="item-center flex grow"> - <div className="mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid"> - <div className="bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center"> - <RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" /> + <div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover"> + <BaseItem subject={subject}> + <SelectionBox checked={isChecked} /> + <ComboboxItemText className="flex grow items-center px-0"> + <div className="mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid"> + <div className="bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center"> + <RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" /> + </div> </div> - </div> - <p className="mr-1 system-sm-medium text-text-secondary">{group.name}</p> - <p className="system-xs-regular text-text-tertiary">{group.groupSize}</p> - </div> + <span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span> + <span className="system-xs-regular text-text-tertiary">{group.groupSize}</span> + </ComboboxItemText> + </BaseItem> <Button size="small" disabled={isChecked} variant="ghost-accent" - className="flex shrink-0 items-center justify-between px-1.5 py-1" + className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1" + onPointerDown={event => event.preventDefault()} onClick={handleExpandClick} > <span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span> - <RiArrowRightSLine className="h-4 w-4" /> + <RiArrowRightSLine className="h-4 w-4" aria-hidden="true" /> </Button> - </BaseItem> + </div> ) } type MemberItemProps = { member: AccessControlAccount + subject: Subject } -function MemberItem({ member }: MemberItemProps) { +function MemberItem({ member, subject }: MemberItemProps) { const currentUser = useSelector(s => s.userProfile) const { t } = useTranslation() const specificMembers = useAccessControlStore(s => s.specificMembers) - const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) const isChecked = specificMembers.some(m => m.id === member.id) - const handleCheckChange = useCallback(() => { - if (!isChecked) { - const newMembers = [...specificMembers, member] - setSpecificMembers(newMembers) - } - else { - const newMembers = specificMembers.filter(m => m.id !== member.id) - setSpecificMembers(newMembers) - } - }, [specificMembers, setSpecificMembers, member, isChecked]) return ( - <BaseItem className="pr-3"> - <Checkbox checked={isChecked} className="h-4 w-4 shrink-0" onCheck={handleCheckChange} /> - <div className="flex grow items-center"> + <BaseItem subject={subject} className="pr-3"> + <SelectionBox checked={isChecked} /> + <ComboboxItemText className="flex grow items-center px-0"> <div className="mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid"> <div className="bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center"> <Avatar size="xxs" avatar={null} name={member.name} /> </div> </div> - <p className="mr-1 system-sm-medium text-text-secondary">{member.name}</p> + <span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span> {currentUser.email === member.email && ( - <p className="system-xs-regular text-text-tertiary"> + <span className="system-xs-regular text-text-tertiary"> ( {t('you', { ns: 'common' })} ) - </p> + </span> )} - </div> - <p className="system-xs-regular text-text-quaternary">{member.email}</p> + </ComboboxItemText> + <span className="system-xs-regular text-text-quaternary">{member.email}</span> </BaseItem> ) } type BaseItemProps = { className?: string + subject: Subject children: React.ReactNode } -function BaseItem({ children, className }: BaseItemProps) { +function BaseItem({ children, className, subject }: BaseItemProps) { return ( - <div className={cn('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}> + <ComboboxItem + value={subject} + className={cn( + 'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2', + className, + )} + > {children} - </div> + </ComboboxItem> + ) +} + +function SelectionBox({ checked }: { checked: boolean }) { + return ( + <span + aria-hidden="true" + className={cn( + 'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3', + checked + ? 'bg-components-checkbox-bg text-components-checkbox-icon' + : 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked', + )} + > + {checked && <span className="i-ri-check-line size-3" />} + </span> ) } diff --git a/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx index 83063cedf5..fa21f71c9d 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx @@ -1,6 +1,8 @@ -import type { DocumentItem } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SimpleDocumentDetail } from '@/models/datasets' +import { Combobox } from '@langgenius/dify-ui/combobox' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ChunkingMode, DataSourceType } from '@/models/datasets' import DocumentList from '../document-list' vi.mock('../../document-file-icon', () => ({ @@ -13,37 +15,92 @@ vi.mock('../../document-file-icon', () => ({ ), })) +const createDocument = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({ + id: 'doc-1', + batch: 'batch-1', + position: 1, + dataset_id: 'dataset-1', + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { + id: 'file-1', + name: 'report.pdf', + size: 1024, + extension: 'pdf', + mime_type: 'application/pdf', + created_by: 'user-1', + created_at: Date.now(), + }, + job_id: 'job-1', + url: '', + }, + dataset_process_rule_id: 'rule-1', + name: 'report', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + indexing_status: 'completed', + display_status: 'enabled', + doc_form: ChunkingMode.text, + doc_language: 'en', + enabled: true, + word_count: 1000, + archived: false, + updated_at: Date.now(), + hit_count: 0, + data_source_detail_dict: { + upload_file: { + name: 'report.pdf', + extension: 'pdf', + }, + }, + ...overrides, +}) + +const renderDocumentList = (list: SimpleDocumentDetail[], onValueChange = vi.fn()) => ({ + onValueChange, + ...render( + <Combobox + open + items={list} + itemToStringLabel={document => document.name} + itemToStringValue={document => document.id} + onValueChange={onValueChange} + > + <DocumentList /> + </Combobox>, + ), +}) + describe('DocumentList', () => { - const mockList = [ - { id: 'doc-1', name: 'report', extension: 'pdf' }, - { id: 'doc-2', name: 'data', extension: 'csv' }, - ] as DocumentItem[] - - const onChange = vi.fn() - beforeEach(() => { vi.clearAllMocks() }) - it('should render all documents', () => { - render(<DocumentList list={mockList} onChange={onChange} />) - expect(screen.getByText('report')).toBeInTheDocument() - expect(screen.getByText('data')).toBeInTheDocument() - }) + it('should render documents as combobox options', () => { + renderDocumentList([ + createDocument({ id: 'doc-1', name: 'report' }), + createDocument({ id: 'doc-2', name: 'data' }), + ]) - it('should render file icons', () => { - render(<DocumentList list={mockList} onChange={onChange} />) + expect(screen.getByRole('option', { name: /report/ })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /data/ })).toBeInTheDocument() expect(screen.getAllByTestId('file-icon')).toHaveLength(2) }) - it('should call onChange with document on click', () => { - render(<DocumentList list={mockList} onChange={onChange} />) - fireEvent.click(screen.getByText('report')) - expect(onChange).toHaveBeenCalledWith(mockList[0]) + it('should keep item spacing symmetric with the search field', () => { + renderDocumentList([createDocument({ id: 'doc-1', name: 'report' })]) + + expect(screen.getByRole('option', { name: /report/ })).toHaveClass('px-3') }) - it('should render empty list without errors', () => { - const { container } = render(<DocumentList list={[]} onChange={onChange} />) - expect(container.firstChild).toBeInTheDocument() + it('should select a document through combobox value change', async () => { + const user = userEvent.setup() + const selectedDocument = createDocument({ id: 'doc-1', name: 'report' }) + const { onValueChange } = renderDocumentList([selectedDocument]) + + await user.click(screen.getByRole('option', { name: /report/ })) + + expect(onValueChange).toHaveBeenCalledWith(selectedDocument, expect.any(Object)) }) }) diff --git a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx index 1251eab9fb..a6c2078836 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx @@ -1,33 +1,22 @@ -import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' +import type { SimpleDocumentDetail } from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ChunkingMode, DataSourceType } from '@/models/datasets' -import DocumentPicker from '../index' +import { DocumentPicker } from '../index' -vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) - -// Mock useDocumentList hook with controllable return value let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined -let mockDocumentListLoading = false const { mockUseDocumentList } = vi.hoisted(() => ({ mockUseDocumentList: vi.fn(), })) -// Set up the implementation after variables are defined -mockUseDocumentList.mockImplementation(() => ({ - data: mockDocumentListLoading ? undefined : mockDocumentListData, - isLoading: mockDocumentListLoading, -})) - vi.mock('@/service/knowledge/use-document', () => ({ useDocumentList: mockUseDocumentList, })) -// Factory function to create mock SimpleDocumentDetail -const createMockDocument = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({ - id: `doc-${Math.random().toString(36).substr(2, 9)}`, +const createDocument = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({ + id: 'doc-1', batch: 'batch-1', position: 1, dataset_id: 'dataset-1', @@ -35,19 +24,18 @@ const createMockDocument = (overrides: Partial<SimpleDocumentDetail> = {}): Simp data_source_info: { upload_file: { id: 'file-1', - name: 'test-file.txt', + name: 'document.pdf', size: 1024, - extension: 'txt', - mime_type: 'text/plain', + extension: 'pdf', + mime_type: 'application/pdf', created_by: 'user-1', created_at: Date.now(), }, - // Required fields for LegacyDataSourceInfo job_id: 'job-1', url: '', }, dataset_process_rule_id: 'rule-1', - name: 'Test Document', + name: 'Document 1', created_from: 'web', created_by: 'user-1', created_at: Date.now(), @@ -62,937 +50,146 @@ const createMockDocument = (overrides: Partial<SimpleDocumentDetail> = {}): Simp hit_count: 0, data_source_detail_dict: { upload_file: { - name: 'test-file.txt', - extension: 'txt', + name: 'document.pdf', + extension: 'pdf', }, }, ...overrides, }) -// Factory function to create multiple documents -const createMockDocumentList = (count: number): SimpleDocumentDetail[] => { - return Array.from({ length: count }, (_, index) => - createMockDocument({ - id: `doc-${index + 1}`, - name: `Document ${index + 1}`, - data_source_detail_dict: { - upload_file: { - name: `document-${index + 1}.pdf`, - extension: 'pdf', - }, - }, - })) -} - -// Factory function to create props -const createDefaultProps = (overrides: Partial<React.ComponentProps<typeof DocumentPicker>> = {}) => ({ +const createProps = (overrides: Partial<React.ComponentProps<typeof DocumentPicker>> = {}) => ({ datasetId: 'dataset-1', - value: { - name: 'Test Document', - extension: 'txt', - chunkingMode: ChunkingMode.text, - parentMode: undefined as ParentMode | undefined, - }, + value: createDocument({ id: 'doc-1', name: 'Document 1' }), onChange: vi.fn(), ...overrides, }) -// Create a new QueryClient for each test -const createTestQueryClient = () => - new QueryClient({ +const renderDocumentPicker = (props: Partial<React.ComponentProps<typeof DocumentPicker>> = {}) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, - gcTime: 0, - staleTime: 0, }, }, }) - -// Helper to render component with providers -const renderComponent = (props: Partial<React.ComponentProps<typeof DocumentPicker>> = {}) => { - const queryClient = createTestQueryClient() - const defaultProps = createDefaultProps(props) + const defaultProps = createProps(props) return { + props: defaultProps, ...render( <QueryClientProvider client={queryClient}> <DocumentPicker {...defaultProps} /> </QueryClientProvider>, ), - queryClient, - props: defaultProps, } } -const openPopover = () => { - fireEvent.click(screen.getByTestId('popover-trigger')) -} - describe('DocumentPicker', () => { beforeEach(() => { vi.clearAllMocks() - // Reset mock state - mockDocumentListData = { data: createMockDocumentList(5) } - mockDocumentListLoading = false + mockDocumentListData = { + data: [ + createDocument({ id: 'doc-1', name: 'Document 1' }), + createDocument({ id: 'doc-2', name: 'Document 2' }), + ], + } + mockUseDocumentList.mockImplementation(() => ({ + data: mockDocumentListData, + })) }) - // Tests for basic rendering - describe('Rendering', () => { - it('should render without crashing', () => { - renderComponent() - - expect(screen.getByTestId('popover')).toBeInTheDocument() + it('should render the current document and chunking mode', () => { + renderDocumentPicker({ + value: createDocument({ + id: 'current-doc', + name: 'Current Document', + doc_form: ChunkingMode.parentChild, + }), + parentMode: 'paragraph', }) - it('should render document name when provided', () => { - renderComponent({ - value: { - name: 'My Document', - extension: 'pdf', - chunkingMode: ChunkingMode.text, - }, - }) - - expect(screen.getByText('My Document')).toBeInTheDocument() - }) - - it('should render placeholder when name is not provided', () => { - renderComponent({ - value: { - name: undefined, - extension: 'pdf', - chunkingMode: ChunkingMode.text, - }, - }) - - expect(screen.getByText('--')).toBeInTheDocument() - }) - - it('should render general mode label', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) - - expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument() - }) - - it('should render QA mode label', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.qa, - }, - }) - - expect(screen.getByText('dataset.chunkingMode.qa')).toBeInTheDocument() - }) - - it('should render parentChild mode label with paragraph parent mode', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'paragraph', - }, - }) - - expect(screen.getByText(/dataset.chunkingMode.parentChild/)).toBeInTheDocument() - expect(screen.getByText(/dataset.parentMode.paragraph/)).toBeInTheDocument() - }) - - it('should render parentChild mode label with full-doc parent mode', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'full-doc', - }, - }) - - expect(screen.getByText(/dataset.chunkingMode.parentChild/)).toBeInTheDocument() - expect(screen.getByText(/dataset.parentMode.fullDoc/)).toBeInTheDocument() - }) - - it('should render placeholder for parentMode when not provided', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: undefined, - }, - }) - - // parentModeLabel should be '--' when parentMode is not provided - expect(screen.getByText(/--/)).toBeInTheDocument() - }) + expect(screen.getByRole('combobox', { name: 'Current Document' })).toBeInTheDocument() + expect(screen.getByText(/dataset.chunkingMode.parentChild/)).toBeInTheDocument() + expect(screen.getByText(/dataset.parentMode.paragraph/)).toBeInTheDocument() }) - // Tests for props handling - describe('Props', () => { - it('should accept required props', () => { - const onChange = vi.fn() - renderComponent({ - datasetId: 'test-dataset', - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - onChange, - }) + it('should fetch documents with the current dataset and search keyword', async () => { + const user = userEvent.setup() + renderDocumentPicker({ datasetId: 'dataset-custom' }) - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) + await user.click(screen.getByRole('combobox', { name: 'Document 1' })) + await user.type(screen.getByPlaceholderText('common.operation.search'), 'report') - it('should handle value with all fields', () => { - renderComponent({ - value: { - name: 'Full Document', - extension: 'docx', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'paragraph', - }, - }) - - expect(screen.getByText('Full Document')).toBeInTheDocument() - }) - - it('should handle value with minimal fields', () => { - renderComponent({ - value: { - name: undefined, - extension: undefined, - chunkingMode: undefined, - parentMode: undefined, - }, - }) - - expect(screen.getByText('--')).toBeInTheDocument() - }) - - it('should pass datasetId to mockUseDocumentList hook', () => { - renderComponent({ datasetId: 'custom-dataset-id' }) - - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - datasetId: 'custom-dataset-id', - }), - ) - }) - }) - - // Tests for state management and updates - describe('State Management', () => { - it('should initialize with popup closed', () => { - renderComponent() - - expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false') - }) - - it('should open popup when trigger is clicked', () => { - renderComponent() - - const trigger = screen.getByTestId('popover-trigger') - fireEvent.click(trigger) - - // Verify click handler is called - expect(trigger).toBeInTheDocument() - }) - - it('should maintain search query state', async () => { - renderComponent() - - // Initial call should have empty keyword - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - keyword: '', - }), - }), - ) - }) - - it('should update query when search input changes', () => { - renderComponent() - - // Verify the component uses mockUseDocumentList with query parameter - - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - keyword: '', - }), - }), - ) - }) - }) - - // Tests for callback stability and memoization - describe('Callback Stability', () => { - it('should maintain stable onChange callback when value changes', () => { - const onChange = vi.fn() - const value1 = { - name: 'Doc 1', - extension: 'txt', - chunkingMode: ChunkingMode.text, - } - const value2 = { - name: 'Doc 2', - extension: 'pdf', - chunkingMode: ChunkingMode.text, - } - - const queryClient = createTestQueryClient() - const { rerender } = render( - <QueryClientProvider client={queryClient}> - <DocumentPicker - datasetId="dataset-1" - value={value1} - onChange={onChange} - /> - </QueryClientProvider>, - ) - - rerender( - <QueryClientProvider client={queryClient}> - <DocumentPicker - datasetId="dataset-1" - value={value2} - onChange={onChange} - /> - </QueryClientProvider>, - ) - - // Component should still render correctly after rerender - expect(screen.getByText('Doc 2')).toBeInTheDocument() - }) - - it('should use updated onChange callback after rerender', () => { - const onChange1 = vi.fn() - const onChange2 = vi.fn() - const value = { - name: 'Test Doc', - extension: 'txt', - chunkingMode: ChunkingMode.text, - } - - const queryClient = createTestQueryClient() - const { rerender } = render( - <QueryClientProvider client={queryClient}> - <DocumentPicker - datasetId="dataset-1" - value={value} - onChange={onChange1} - /> - </QueryClientProvider>, - ) - - rerender( - <QueryClientProvider client={queryClient}> - <DocumentPicker - datasetId="dataset-1" - value={value} - onChange={onChange2} - /> - </QueryClientProvider>, - ) - - // The component should use the new callback - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should memoize handleChange callback with useCallback', () => { - // The handleChange callback is created with useCallback and depends on - // documentsList, onChange, and setOpen - const onChange = vi.fn() - renderComponent({ onChange }) - - // Verify component renders correctly, callback memoization is internal - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - }) - - // Tests for memoization logic and dependencies - describe('Memoization Logic', () => { - it('should be wrapped with React.memo', () => { - // React.memo components have a $$typeof property - expect((DocumentPicker as unknown as { $$typeof: symbol }).$$typeof).toBeDefined() - }) - - it('should compute parentModeLabel correctly with useMemo', () => { - // Test paragraph mode - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'paragraph', - }, - }) - - expect(screen.getByText(/dataset.parentMode.paragraph/)).toBeInTheDocument() - }) - - it('should update parentModeLabel when parentMode changes', () => { - // Test full-doc mode - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'full-doc', - }, - }) - - expect(screen.getByText(/dataset.parentMode.fullDoc/)).toBeInTheDocument() - }) - - it('should not re-render when props are the same', () => { - const onChange = vi.fn() - const value = { - name: 'Stable Doc', - extension: 'txt', - chunkingMode: ChunkingMode.text, - } - - const queryClient = createTestQueryClient() - const { rerender } = render( - <QueryClientProvider client={queryClient}> - <DocumentPicker - datasetId="dataset-1" - value={value} - onChange={onChange} - /> - </QueryClientProvider>, - ) - - // Rerender with same props reference - rerender( - <QueryClientProvider client={queryClient}> - <DocumentPicker - datasetId="dataset-1" - value={value} - onChange={onChange} - /> - </QueryClientProvider>, - ) - - expect(screen.getByText('Stable Doc')).toBeInTheDocument() - }) - }) - - // Tests for user interactions and event handlers - describe('User Interactions', () => { - it('should toggle popup when trigger is clicked', () => { - renderComponent() - - const trigger = screen.getByTestId('popover-trigger') - fireEvent.click(trigger) - - // Trigger click should be handled - expect(trigger).toBeInTheDocument() - }) - - it('should handle document selection when popup is open', () => { - // Test the handleChange callback logic - const onChange = vi.fn() - const mockDocs = createMockDocumentList(3) - mockDocumentListData = { data: mockDocs } - - renderComponent({ onChange }) - - // The handleChange callback should find the document and call onChange - // We can verify this by checking that mockUseDocumentList was called - - expect(mockUseDocumentList).toHaveBeenCalled() - }) - - it('should handle search input change', () => { - renderComponent() - - // The search input is only visible when popup is open - // We verify that the component initializes with empty query - - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - keyword: '', - }), - }), - ) - }) - - it('should initialize with default query parameters', () => { - renderComponent() - - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - query: { - keyword: '', - page: 1, - limit: 20, - }, - }), - ) - }) - }) - - // Tests for API calls - describe('API Calls', () => { - it('should call mockUseDocumentList with correct parameters', () => { - renderComponent({ datasetId: 'test-dataset-123' }) - - expect(mockUseDocumentList).toHaveBeenCalledWith({ - datasetId: 'test-dataset-123', + await waitFor(() => { + expect(mockUseDocumentList).toHaveBeenLastCalledWith({ + datasetId: 'dataset-custom', query: { - keyword: '', + keyword: 'report', page: 1, limit: 20, }, }) }) - - it('should handle loading state', () => { - mockDocumentListLoading = true - mockDocumentListData = undefined - - renderComponent() - - // When loading, component should still render without crashing - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should fetch documents on mount', () => { - mockDocumentListLoading = false - mockDocumentListData = { data: createMockDocumentList(3) } - - renderComponent() - - // Verify the hook was called - - expect(mockUseDocumentList).toHaveBeenCalled() - }) - - it('should handle empty document list', () => { - mockDocumentListData = { data: [] } - - renderComponent() - - // Component should render without crashing - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should handle undefined data response', () => { - mockDocumentListData = undefined - - renderComponent() - - // Should not crash - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) }) - // Tests for component memoization - describe('Component Memoization', () => { - it('should export as React.memo wrapped component', () => { - // Check that the component is memoized - expect(DocumentPicker).toBeDefined() - expect(typeof DocumentPicker).toBe('object') // React.memo returns an object - }) + it('should keep focus in the search input while deleting quickly', async () => { + const user = userEvent.setup() + renderDocumentPicker() - it('should preserve render output when datasetId is the same', () => { - const queryClient = createTestQueryClient() - const value = { - name: 'Memo Test', - extension: 'txt', - chunkingMode: ChunkingMode.text, - } - const onChange = vi.fn() + const trigger = screen.getByRole('combobox', { name: 'Document 1' }) + await user.click(trigger) - const { rerender } = render( - <QueryClientProvider client={queryClient}> - <DocumentPicker - datasetId="same-dataset" - value={value} - onChange={onChange} - /> - </QueryClientProvider>, - ) + const searchInput = screen.getByPlaceholderText('common.operation.search') + await user.type(searchInput, 'report') + await user.keyboard('{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}') - expect(screen.getByText('Memo Test')).toBeInTheDocument() - - rerender( - <QueryClientProvider client={queryClient}> - <DocumentPicker - datasetId="same-dataset" - value={value} - onChange={onChange} - /> - </QueryClientProvider>, - ) - - expect(screen.getByText('Memo Test')).toBeInTheDocument() - }) + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(searchInput).toHaveFocus() + expect(trigger).not.toHaveFocus() }) - // Tests for edge cases and error handling - describe('Edge Cases', () => { - it('should handle null name', () => { - renderComponent({ - value: { - name: undefined, - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) + it('should keep focus in the search input while typing quickly', async () => { + const user = userEvent.setup() + renderDocumentPicker() - expect(screen.getByText('--')).toBeInTheDocument() - }) + const trigger = screen.getByRole('combobox', { name: 'Document 1' }) + await user.click(trigger) - it('should handle empty string name', () => { - renderComponent({ - value: { - name: '', - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) + const searchInput = screen.getByPlaceholderText('common.operation.search') + await user.keyboard('quarterly-report-final') - // Empty string is falsy, so should show '--' - expect(screen.queryByText('--')).toBeInTheDocument() - }) - - it('should handle undefined extension', () => { - renderComponent({ - value: { - name: 'Test Doc', - extension: undefined, - chunkingMode: ChunkingMode.text, - }, - }) - - // Should not crash - expect(screen.getByText('Test Doc')).toBeInTheDocument() - }) - - it('should handle undefined chunkingMode', () => { - renderComponent({ - value: { - name: 'Test Doc', - extension: 'txt', - chunkingMode: undefined, - }, - }) - - // When chunkingMode is undefined, none of the mode conditions are true - expect(screen.getByText('Test Doc')).toBeInTheDocument() - }) - - it('should handle document without data_source_detail_dict', () => { - const docWithoutDetail = createMockDocument({ - id: 'doc-no-detail', - name: 'Doc Without Detail', - data_source_detail_dict: undefined, - }) - mockDocumentListData = { data: [docWithoutDetail] } - - // Component should handle mapping documents even without data_source_detail_dict - renderComponent() - - // Should not crash - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should handle rapid toggle clicks', () => { - renderComponent() - - const trigger = screen.getByTestId('popover-trigger') - - // Rapid clicks - fireEvent.click(trigger) - fireEvent.click(trigger) - fireEvent.click(trigger) - fireEvent.click(trigger) - - // Should not crash - expect(trigger).toBeInTheDocument() - }) - - it('should handle very long document names in trigger', () => { - const longName = 'A'.repeat(500) - renderComponent({ - value: { - name: longName, - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) - - // Should render long name without crashing - expect(screen.getByText(longName)).toBeInTheDocument() - }) - - it('should handle special characters in document name', () => { - const specialName = '<script>alert("xss")</script>' - renderComponent({ - value: { - name: specialName, - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) - - // React should escape the text - expect(screen.getByText(specialName)).toBeInTheDocument() - }) - - it('should handle documents with missing extension in data_source_detail_dict', () => { - const docWithEmptyExtension = createMockDocument({ - id: 'doc-empty-ext', - name: 'Doc Empty Ext', - data_source_detail_dict: { - upload_file: { - name: 'file-no-ext', - extension: '', - }, - }, - }) - mockDocumentListData = { data: [docWithEmptyExtension] } - - // Component should handle mapping documents with empty extension - renderComponent() - - // Should not crash - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should handle document list mapping with various data_source_detail_dict states', () => { - // Test the mapping logic: d.data_source_detail_dict?.upload_file?.extension || '' - const docs = [ - createMockDocument({ - id: 'doc-1', - name: 'With Extension', - data_source_detail_dict: { - upload_file: { name: 'file.pdf', extension: 'pdf' }, - }, - }), - createMockDocument({ - id: 'doc-2', - name: 'Without Detail Dict', - data_source_detail_dict: undefined, - }), - ] - mockDocumentListData = { data: docs } - - renderComponent() - - // Should not crash during mapping - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(searchInput).toHaveFocus() + expect(trigger).not.toHaveFocus() }) - // Tests for all prop variations - describe('Prop Variations', () => { - describe('datasetId variations', () => { - it('should handle empty datasetId', () => { - renderComponent({ datasetId: '' }) + it('should call onChange with the selected document', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const selectedDocument = createDocument({ id: 'doc-2', name: 'Document 2' }) + mockDocumentListData = { + data: [ + createDocument({ id: 'doc-1', name: 'Document 1' }), + selectedDocument, + ], + } - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) + renderDocumentPicker({ onChange }) - it('should handle UUID format datasetId', () => { - renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' }) + await user.click(screen.getByRole('combobox', { name: 'Document 1' })) + await user.click(await screen.findByRole('option', { name: /Document 2/ })) - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - }) - - describe('value.chunkingMode variations', () => { - const chunkingModes = [ - { mode: ChunkingMode.text, label: 'dataset.chunkingMode.general' }, - { mode: ChunkingMode.qa, label: 'dataset.chunkingMode.qa' }, - { mode: ChunkingMode.parentChild, label: 'dataset.chunkingMode.parentChild' }, - ] - - it.each(chunkingModes)( - 'should display correct label for $mode mode', - ({ mode, label }) => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: mode, - parentMode: mode === ChunkingMode.parentChild ? 'paragraph' : undefined, - }, - }) - - expect(screen.getByText(new RegExp(label))).toBeInTheDocument() - }, - ) - }) - - describe('value.parentMode variations', () => { - const parentModes: Array<{ mode: ParentMode, label: string }> = [ - { mode: 'paragraph', label: 'dataset.parentMode.paragraph' }, - { mode: 'full-doc', label: 'dataset.parentMode.fullDoc' }, - ] - - it.each(parentModes)( - 'should display correct label for $mode parentMode', - ({ mode, label }) => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: mode, - }, - }) - - expect(screen.getByText(new RegExp(label))).toBeInTheDocument() - }, - ) - }) - - describe('value.extension variations', () => { - const extensions = ['txt', 'pdf', 'docx', 'xlsx', 'csv', 'md', 'html'] - - it.each(extensions)('should handle %s extension', (ext) => { - renderComponent({ - value: { - name: `File.${ext}`, - extension: ext, - chunkingMode: ChunkingMode.text, - }, - }) - - expect(screen.getByText(`File.${ext}`)).toBeInTheDocument() - }) - }) + expect(onChange).toHaveBeenCalledWith(selectedDocument) }) - // Tests for document selection - describe('Document Selection', () => { - it('should fetch documents list via mockUseDocumentList', () => { - const mockDoc = createMockDocument({ - id: 'selected-doc', - name: 'Selected Document', - }) - mockDocumentListData = { data: [mockDoc] } - const onChange = vi.fn() + it('should show an empty state when no documents match', async () => { + const user = userEvent.setup() + mockDocumentListData = { data: [] } - renderComponent({ onChange }) + renderDocumentPicker() - // Verify the hook was called + await user.click(screen.getByRole('combobox', { name: 'Document 1' })) - expect(mockUseDocumentList).toHaveBeenCalled() - }) - - it('should call onChange when document is selected', () => { - const docs = createMockDocumentList(3) - mockDocumentListData = { data: docs } - const onChange = vi.fn() - - renderComponent({ onChange }) - openPopover() - - fireEvent.click(screen.getByText('Document 2')) - - // handleChange should find the document and call onChange with full document - expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith(docs[1]) - }) - - it('should map document list items correctly', () => { - const docs = createMockDocumentList(3) - mockDocumentListData = { data: docs } - - renderComponent() - openPopover() - - // Documents should be rendered in the list - expect(screen.getByText('Document 1')).toBeInTheDocument() - expect(screen.getByText('Document 2')).toBeInTheDocument() - expect(screen.getByText('Document 3')).toBeInTheDocument() - }) - }) - - // Tests for integration with child components - describe('Child Component Integration', () => { - it('should pass correct data to DocumentList when popup is open', () => { - const docs = createMockDocumentList(3) - mockDocumentListData = { data: docs } - - renderComponent() - - // DocumentList receives mapped documents: { id, name, extension } - // We verify the data is fetched - - expect(mockUseDocumentList).toHaveBeenCalled() - }) - - it('should map document data_source_detail_dict extension correctly', () => { - const doc = createMockDocument({ - id: 'mapped-doc', - name: 'Mapped Document', - data_source_detail_dict: { - upload_file: { - name: 'mapped.pdf', - extension: 'pdf', - }, - }, - }) - mockDocumentListData = { data: [doc] } - - renderComponent() - - // The mapping: d.data_source_detail_dict?.upload_file?.extension || '' - // Should extract 'pdf' from the document - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should render trigger with SearchInput integration', () => { - renderComponent() - - // The trigger is always rendered - expect(screen.getByTestId('popover-trigger')).toBeInTheDocument() - }) - - it('should integrate FileIcon component', () => { - // Use empty document list to avoid duplicate icons from list - mockDocumentListData = { data: [] } - - renderComponent({ - value: { - name: 'test.pdf', - extension: 'pdf', - chunkingMode: ChunkingMode.text, - }, - }) - - // FileIcon should render an SVG icon for the file extension - const trigger = screen.getByTestId('popover-trigger') - expect(trigger.querySelector('svg')).toBeInTheDocument() - }) - }) - - // Tests for visual states - describe('Visual States', () => { - it('should render portal content for document selection', () => { - renderComponent() - openPopover() - - // Popover content is rendered after opening the trigger in our mock - expect(screen.getByTestId('popover-content')).toBeInTheDocument() - }) + expect(await screen.findByRole('status')).toHaveTextContent('common.noData') }) }) diff --git a/web/app/components/datasets/common/document-picker/document-list.tsx b/web/app/components/datasets/common/document-picker/document-list.tsx index d2d8d1966c..366e744cbd 100644 --- a/web/app/components/datasets/common/document-picker/document-list.tsx +++ b/web/app/components/datasets/common/document-picker/document-list.tsx @@ -1,43 +1,49 @@ 'use client' -import type { FC } from 'react' -import type { DocumentItem } from '@/models/datasets' +import type { SimpleDocumentDetail } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useCallback } from 'react' +import { + ComboboxItem, + ComboboxItemText, + ComboboxList, +} from '@langgenius/dify-ui/combobox' import FileIcon from '../document-file-icon' type Props = { className?: string - list: DocumentItem[] - onChange: (value: DocumentItem) => void } -const DocumentList: FC<Props> = ({ - className, - list, - onChange, -}) => { - const handleChange = useCallback((item: DocumentItem) => { - return () => onChange(item) - }, [onChange]) +function getDocumentExtension(document: SimpleDocumentDetail) { + const detailExtension = document.data_source_detail_dict?.upload_file?.extension + if (detailExtension) + return detailExtension + const dataSourceInfo = document.data_source_info + if (dataSourceInfo && 'upload_file' in dataSourceInfo) + return dataSourceInfo.upload_file.extension + + return '' +} + +export default function DocumentList({ + className, +}: Props) { return ( - <div className={cn('max-h-[calc(100vh-120px)] overflow-auto', className)}> - {list.map((item) => { - const { id, name, extension } = item + <ComboboxList className={cn('max-h-[calc(100vh-120px)] p-0', className)}> + {(item: SimpleDocumentDetail) => { + const extension = getDocumentExtension(item) return ( - <div - key={id} - className="flex h-8 cursor-pointer items-center space-x-2 rounded-lg px-2 hover:bg-state-base-hover" - onClick={handleChange(item)} + <ComboboxItem + key={item.id} + value={item} + className="mx-0 flex h-8 grid-cols-none items-center gap-2 rounded-lg px-3 py-0" > <FileIcon name={item.name} extension={extension} size="lg" /> - <div className="truncate text-sm text-text-secondary">{name}</div> - </div> + <ComboboxItemText className="min-w-0 px-0 system-sm-regular text-text-secondary"> + {item.name} + </ComboboxItemText> + </ComboboxItem> ) - })} - </div> + }} + </ComboboxList> ) } - -export default React.memo(DocumentList) diff --git a/web/app/components/datasets/common/document-picker/index.tsx b/web/app/components/datasets/common/document-picker/index.tsx index 0566b590de..07f7443764 100644 --- a/web/app/components/datasets/common/document-picker/index.tsx +++ b/web/app/components/datasets/common/document-picker/index.tsx @@ -1,20 +1,22 @@ 'use client' -import type { FC } from 'react' -import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets' +import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox' +import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxStatus, + ComboboxTrigger, + ComboboxValue, +} from '@langgenius/dify-ui/combobox' import { RiArrowDownSLine } from '@remixicon/react' -import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' +import { useDeferredValue, useState } from 'react' import { useTranslation } from 'react-i18next' import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge' import Loading from '@/app/components/base/loading' -import SearchInput from '@/app/components/base/search-input' import { ChunkingMode } from '@/models/datasets' import { useDocumentList } from '@/service/knowledge/use-document' import FileIcon from '../document-file-icon' @@ -22,116 +24,177 @@ import DocumentList from './document-list' type Props = { datasetId: string - value: { - name?: string - extension?: string - chunkingMode?: ChunkingMode - parentMode?: ParentMode - } + value?: SimpleDocumentDetail | null + parentMode?: ParentMode onChange: (value: SimpleDocumentDetail) => void } -const DocumentPicker: FC<Props> = ({ +function getDocumentLabel(document: SimpleDocumentDetail) { + return document.name +} + +function getDocumentValue(document: SimpleDocumentDetail) { + return document.id +} + +function isSameDocument(item: SimpleDocumentDetail, value: SimpleDocumentDetail) { + return item.id === value.id +} + +function getDocumentExtension(document?: SimpleDocumentDetail | null) { + if (!document) + return '' + + const detailExtension = document.data_source_detail_dict?.upload_file?.extension + if (detailExtension) + return detailExtension + + const dataSourceInfo = document.data_source_info + if (dataSourceInfo && 'upload_file' in dataSourceInfo) + return dataSourceInfo.upload_file.extension + + return '' +} + +function DocumentPickerTriggerValue({ + document, + parentMode, +}: { + document?: SimpleDocumentDetail | null + parentMode?: ParentMode +}) { + const { t } = useTranslation() + const isGeneralMode = document?.doc_form === ChunkingMode.text + const isParentChild = document?.doc_form === ChunkingMode.parentChild + const isQAMode = document?.doc_form === ChunkingMode.qa + const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk + const ArrowIcon = RiArrowDownSLine + const parentModeLabel = (() => { + if (!parentMode) + return '--' + return parentMode === 'paragraph' ? t('parentMode.paragraph', { ns: 'dataset' }) : t('parentMode.fullDoc', { ns: 'dataset' }) + })() + + return ( + <span className="flex min-w-0 items-center gap-1.5"> + <FileIcon name={document?.name} extension={getDocumentExtension(document)} size="xl" /> + <span className="flex min-w-0 flex-col items-start"> + <span className="flex max-w-full min-w-0 items-center gap-1"> + <span className="max-w-[280px] min-w-0 truncate system-md-semibold text-text-primary"> + {document?.name || '--'} + </span> + <ArrowIcon className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" /> + </span> + <span className="flex h-3 max-w-[300px] items-center gap-0.5 text-text-tertiary"> + <TypeIcon className="h-3 w-3 shrink-0" /> + <span className={cn('truncate system-2xs-medium-uppercase', isParentChild && 'mt-0.5')}> + {isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })} + {isQAMode && t('chunkingMode.qa', { ns: 'dataset' })} + {isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`} + </span> + </span> + </span> + </span> + ) +} + +export function DocumentPicker({ datasetId, value, + parentMode, onChange, -}) => { +}: Props) { const { t } = useTranslation() - const { - name, - extension, - chunkingMode, - parentMode, - } = value - const [query, setQuery] = useState('') + const [searchValue, setSearchValue] = useState('') + const deferredSearchValue = useDeferredValue(searchValue) const { data } = useDocumentList({ datasetId, query: { - keyword: query, + keyword: deferredSearchValue, page: 1, limit: 20, }, }) - const documentsList = data?.data - const isGeneralMode = chunkingMode === ChunkingMode.text - const isParentChild = chunkingMode === ChunkingMode.parentChild - const isQAMode = chunkingMode === ChunkingMode.qa - const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk + const documentsList = data?.data ?? [] - const [open, { - set: setOpen, - }] = useBoolean(false) - const ArrowIcon = RiArrowDownSLine + const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => { + if (details.reason !== 'item-press') + setSearchValue(inputValue) + } - const handleChange = useCallback(({ id }: DocumentItem) => { - onChange(documentsList?.find(item => item.id === id) as SimpleDocumentDetail) - setOpen(false) - }, [documentsList, onChange, setOpen]) + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) + setSearchValue('') + } - const parentModeLabel = useMemo(() => { - if (!parentMode) - return '--' - return parentMode === 'paragraph' ? t('parentMode.paragraph', { ns: 'dataset' }) : t('parentMode.fullDoc', { ns: 'dataset' }) - }, [parentMode, t]) + const handleDocumentChange = (document: SimpleDocumentDetail | null) => { + if (!document) + return + + onChange(document) + setSearchValue('') + } return ( - <Popover - open={open} - onOpenChange={setOpen} + <Combobox<SimpleDocumentDetail> + items={documentsList} + value={value ?? null} + inputValue={searchValue} + onOpenChange={handleOpenChange} + onInputValueChange={handleInputValueChange} + onValueChange={handleDocumentChange} + isItemEqualToValue={isSameDocument} + itemToStringLabel={getDocumentLabel} + itemToStringValue={getDocumentValue} + filter={null} > - <PopoverTrigger - nativeButton={false} - render={( - <div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}> - <FileIcon name={name} extension={extension} size="xl" /> - <div className="mr-0.5 ml-1 flex flex-col items-start"> - <div className="flex items-center space-x-0.5"> - <span className={cn('system-md-semibold text-text-primary')}> - {' '} - {name || '--'} - </span> - <ArrowIcon className="h-4 w-4 text-text-primary" /> - </div> - <div className="flex h-3 items-center space-x-0.5 text-text-tertiary"> - <TypeIcon className="h-3 w-3" /> - <span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}> - {isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })} - {isQAMode && t('chunkingMode.qa', { ns: 'dataset' })} - {isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`} - </span> - </div> - </div> - </div> + <ComboboxTrigger + aria-label={value?.name || t('operation.search', { ns: 'common' })} + icon={false} + className={cn( + 'ml-1 flex h-auto w-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active data-open:bg-state-base-hover', )} - /> - <PopoverContent + > + <ComboboxValue> + {(document: SimpleDocumentDetail | null) => ( + <DocumentPickerTriggerValue document={document} parentMode={parentMode} /> + )} + </ComboboxValue> + </ComboboxTrigger> + <ComboboxContent placement="bottom-start" sideOffset={0} - popupClassName="border-none bg-transparent shadow-none" + popupClassName="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg backdrop-blur-[5px]" > - <div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pt-2 shadow-lg backdrop-blur-[5px]"> - <SearchInput value={query} onChange={setQuery} className="mx-1" /> - {documentsList - ? ( - <DocumentList - className="mt-2" - list={documentsList.map(d => ({ - id: d.id, - name: d.name, - extension: d.data_source_detail_dict?.upload_file?.extension || '', - }))} - onChange={handleChange} - /> - ) - : ( - <div className="mt-2 flex h-[100px] w-[360px] items-center justify-center"> - <Loading /> - </div> - )} - </div> - </PopoverContent> - </Popover> + <ComboboxInputGroup className="h-8 min-h-8 px-2"> + <span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <ComboboxInput + aria-label={t('operation.search', { ns: 'common' })} + placeholder={t('operation.search', { ns: 'common' })} + className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary" + /> + </ComboboxInputGroup> + {data + ? ( + documentsList.length > 0 + ? ( + <DocumentList + className="mt-2" + /> + ) + : ( + <ComboboxEmpty className="mt-2 flex h-[100px] w-full items-center justify-center"> + {t('noData', { ns: 'common' })} + </ComboboxEmpty> + ) + ) + : ( + <ComboboxStatus className="mt-2 flex h-[100px] w-full items-center justify-center"> + <Loading /> + </ComboboxStatus> + )} + </ComboboxContent> + </Combobox> ) } -export default React.memo(DocumentPicker) diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx index 597ceda9a5..fb90bf57f7 100644 --- a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx +++ b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx @@ -14,7 +14,6 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import FileIcon from '../document-file-icon' -import DocumentList from './document-list' type Props = { className?: string @@ -74,7 +73,7 @@ const PreviewDocumentPicker: FC<Props> = ({ {files?.length > 1 && <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary">{t('preprocessDocument', { ns: 'dataset', num: files.length })}</div>} {files?.length > 0 ? ( - <DocumentList + <PreviewDocumentList list={files} onChange={handleChange} /> @@ -90,3 +89,27 @@ const PreviewDocumentPicker: FC<Props> = ({ ) } export default React.memo(PreviewDocumentPicker) + +function PreviewDocumentList({ + list, + onChange, +}: { + list: DocumentItem[] + onChange: (value: DocumentItem) => void +}) { + return ( + <div className="max-h-[calc(100vh-120px)] overflow-auto"> + {list.map(item => ( + <button + key={item.id} + type="button" + className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent px-2 text-left hover:bg-state-base-hover" + onClick={() => onChange(item)} + > + <FileIcon name={item.name} extension={item.extension} size="lg" /> + <span className="truncate text-sm text-text-secondary">{item.name}</span> + </button> + ))} + </div> + ) +} diff --git a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx index 3eb1017b8d..b48575d209 100644 --- a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx @@ -1,6 +1,7 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' import { render } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ChunkingMode } from '@/models/datasets' +import { ChunkingMode, DataSourceType } from '@/models/datasets' import { DocumentTitle } from '../document-title' @@ -11,13 +12,23 @@ vi.mock('@/next/navigation', () => ({ }), })) -// Mock DocumentPicker vi.mock('../../../common/document-picker', () => ({ - default: ({ datasetId, value, onChange }: { datasetId: string, value: unknown, onChange: (doc: { id: string }) => void }) => ( + DocumentPicker: ({ + datasetId, + value, + parentMode, + onChange, + }: { + datasetId: string + value?: SimpleDocumentDetail | null + parentMode?: string + onChange: (doc: { id: string }) => void + }) => ( <div data-testid="document-picker" data-dataset-id={datasetId} - data-value={JSON.stringify(value)} + data-value-id={value?.id ?? ''} + data-parent-mode={parentMode ?? ''} onClick={() => onChange({ id: 'new-doc-id' })} > Document Picker @@ -25,6 +36,42 @@ vi.mock('../../../common/document-picker', () => ({ ), })) +const createDocument = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({ + id: 'doc-1', + batch: 'batch-1', + position: 1, + dataset_id: 'dataset-1', + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { + id: 'file-1', + name: 'document.pdf', + size: 1024, + extension: 'pdf', + mime_type: 'application/pdf', + created_by: 'user-1', + created_at: Date.now(), + }, + job_id: 'job-1', + url: '', + }, + dataset_process_rule_id: 'rule-1', + name: 'Document 1', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + indexing_status: 'completed', + display_status: 'enabled', + doc_form: ChunkingMode.text, + doc_language: 'en', + enabled: true, + word_count: 1000, + archived: false, + updated_at: Date.now(), + hit_count: 0, + ...overrides, +}) + describe('DocumentTitle', () => { beforeEach(() => { vi.clearAllMocks() @@ -69,31 +116,26 @@ describe('DocumentTitle', () => { expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id') }) - it('should pass value props to DocumentPicker', () => { + it('should pass the selected document to DocumentPicker', () => { + const document = createDocument({ id: 'doc-current' }) const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" - name="test-document" - extension="pdf" - chunkingMode={ChunkingMode.text} - parent_mode="paragraph" + document={document} + parentMode="paragraph" />, ) - const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') - expect(value.name).toBe('test-document') - expect(value.extension).toBe('pdf') - expect(value.chunkingMode).toBe(ChunkingMode.text) - expect(value.parentMode).toBe('paragraph') + expect(getByTestId('document-picker')).toHaveAttribute('data-value-id', 'doc-current') + expect(getByTestId('document-picker')).toHaveAttribute('data-parent-mode', 'paragraph') }) - it('should default parentMode to paragraph when parent_mode is undefined', () => { + it('should pass no parent mode when it is undefined', () => { const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') - expect(value.parentMode).toBe('paragraph') + expect(getByTestId('document-picker')).toHaveAttribute('data-parent-mode', '') }) it('should apply custom wrapperCls', () => { @@ -119,24 +161,23 @@ describe('DocumentTitle', () => { }) describe('Edge Cases', () => { - it('should handle undefined optional props', () => { + it('should handle an empty document value', () => { const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') - expect(value.name).toBeUndefined() - expect(value.extension).toBeUndefined() + expect(getByTestId('document-picker')).toHaveAttribute('data-value-id', '') }) it('should maintain structure when rerendered', () => { const { rerender, getByTestId } = render( - <DocumentTitle datasetId="dataset-1" name="doc1" />, + <DocumentTitle datasetId="dataset-1" document={createDocument({ id: 'doc-1' })} />, ) - rerender(<DocumentTitle datasetId="dataset-2" name="doc2" />) + rerender(<DocumentTitle datasetId="dataset-2" document={createDocument({ id: 'doc-2' })} />) expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('dataset-2') + expect(getByTestId('document-picker').getAttribute('data-value-id')).toBe('doc-2') }) }) }) 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 e717475b38..e8946ce584 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -114,9 +114,20 @@ vi.mock('../batch-modal', () => ({ })) vi.mock('../document-title', () => ({ - DocumentTitle: ({ name, extension }: { name?: string, extension?: string }) => ( - <div data-testid="document-title" data-extension={extension}>{name}</div> - ), + DocumentTitle: ({ + document, + }: { + document?: { + name?: string + data_source_detail_dict?: { upload_file?: { extension?: string } } + data_source_info?: { upload_file?: { extension?: string } } + } | null + }) => { + const extension = document?.data_source_detail_dict?.upload_file?.extension + ?? document?.data_source_info?.upload_file?.extension + + return <div data-testid="document-title" data-extension={extension}>{document?.name}</div> + }, })) vi.mock('../segment-add', () => ({ diff --git a/web/app/components/datasets/documents/detail/document-title.tsx b/web/app/components/datasets/documents/detail/document-title.tsx index d5bf5345ae..0a1cfbf61a 100644 --- a/web/app/components/datasets/documents/detail/document-title.tsx +++ b/web/app/components/datasets/documents/detail/document-title.tsx @@ -1,39 +1,29 @@ -import type { FC } from 'react' -import type { ChunkingMode, ParentMode } from '@/models/datasets' +import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { useRouter } from '@/next/navigation' -import DocumentPicker from '../../common/document-picker' +import { DocumentPicker } from '../../common/document-picker' type DocumentTitleProps = { datasetId: string - extension?: string - name?: string - chunkingMode?: ChunkingMode - parent_mode?: ParentMode - iconCls?: string - textCls?: string + document?: SimpleDocumentDetail | null + parentMode?: ParentMode wrapperCls?: string } -export const DocumentTitle: FC<DocumentTitleProps> = ({ +export function DocumentTitle({ datasetId, - extension, - name, - chunkingMode, - parent_mode, + document, + parentMode, wrapperCls, -}) => { +}: DocumentTitleProps) { const router = useRouter() + return ( <div className={cn('flex flex-1 items-center justify-start', wrapperCls)}> <DocumentPicker datasetId={datasetId} - value={{ - name, - extension, - chunkingMode, - parentMode: parent_mode || 'paragraph', - }} + value={document} + parentMode={parentMode} onChange={(doc) => { router.push(`/datasets/${datasetId}/documents/${doc.id}`) }} diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 732d7ffb28..190cf8edf7 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets' +import type { DocumentDisplayStatus, FileItem, FullDocumentDetail } from '@/models/datasets' import type { SegmentImportStatus } from '@/types/dataset' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' @@ -38,10 +38,6 @@ const NON_TERMINAL_DISPLAY_STATUSES = new Set<typeof DisplayStatusList[number]>( DisplayStatusList.filter(s => s === 'queuing' || s === 'indexing' || s === 'paused'), ) -const isLegacyDataSourceInfo = (info?: DataSourceInfo): info is LegacyDataSourceInfo => { - return !!info && 'upload_file' in info -} - const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { const router = useRouter() const searchParams = useSearchParams() @@ -123,14 +119,6 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { const embedding = NON_TERMINAL_DISPLAY_STATUSES.has(documentDetail?.display_status as DocumentDisplayStatus) - const documentUploadFile = useMemo(() => { - if (!documentDetail?.data_source_info) - return undefined - if (isLegacyDataSourceInfo(documentDetail.data_source_info)) - return documentDetail.data_source_info.upload_file - return undefined - }, [documentDetail?.data_source_info]) - const invalidChunkList = useInvalid(useSegmentListKey) const invalidChildChunkList = useInvalid(useChildSegmentListKey) const invalidDocumentList = useInvalidDocumentList(datasetId) @@ -212,11 +200,9 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { </button> <DocumentTitle datasetId={datasetId} - extension={documentUploadFile?.extension} - name={documentDetail?.name} + document={documentDetail} wrapperCls="mr-2" - parent_mode={parentMode} - chunkingMode={documentDetail?.doc_form as ChunkingMode} + parentMode={parentMode} /> <div className="flex flex-wrap items-center"> {embeddingAvailable && documentDetail && !documentDetail.archived && !isFullDocMode && ( From 1a93af5cd0c22c83da98eb2990cdea398aaf1064 Mon Sep 17 00:00:00 2001 From: Deepam Goyal <116721751+Deepam02@users.noreply.github.com> Date: Tue, 12 May 2026 11:04:45 +0530 Subject: [PATCH 51/53] refactor: rewrite estimate_args_validate using Pydantic v2 models (#36036) Signed-off-by: Deepam Goyal <deepam02goyal@gmail.com> Co-authored-by: Asuka Minato <i@asukaminato.eu.org> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/services/dataset_service.py | 181 +++++++++--------- .../services/test_dataset_service_document.py | 4 +- 2 files changed, 94 insertions(+), 91 deletions(-) diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 383474f4f6..4f5a95dcde 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -7,9 +7,10 @@ import time import uuid from collections import Counter from collections.abc import Sequence -from typing import Any, Literal, TypedDict, cast +from typing import Annotated, Any, Literal, TypedDict, cast import sqlalchemy as sa +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from redis.exceptions import LockNotOwnedError from sqlalchemy import delete, exists, func, select, update from sqlalchemy.orm import Session, sessionmaker @@ -117,6 +118,86 @@ class AutoDisableLogsDict(TypedDict): count: int +class _EstimatePreProcessingRule(BaseModel): + id: str = Field(min_length=1) + enabled: bool + + @field_validator("id") + @classmethod + def _validate_id(cls, v: str) -> str: + if v not in DatasetProcessRule.PRE_PROCESSING_RULES: + raise ValueError("Process rule pre_processing_rules id is invalid") + return v + + +class _EstimateSegmentation(BaseModel): + separator: str = Field(min_length=1) + max_tokens: int = Field(gt=0) + + +class _EstimateRules(BaseModel): + pre_processing_rules: list[_EstimatePreProcessingRule] + segmentation: _EstimateSegmentation + + @field_validator("pre_processing_rules") + @classmethod + def _deduplicate(cls, v: list[_EstimatePreProcessingRule]) -> list[_EstimatePreProcessingRule]: + seen: dict[str, _EstimatePreProcessingRule] = {} + for rule in v: + seen[rule.id] = rule + return list(seen.values()) + + +class _SummaryIndexSettingDisabled(BaseModel): + enable: Literal[False] = False + + +class _SummaryIndexSettingEnabled(BaseModel): + enable: Literal[True] + model_name: str = Field(min_length=1) + model_provider_name: str = Field(min_length=1) + + +_SummaryIndexSetting = Annotated[ + _SummaryIndexSettingDisabled | _SummaryIndexSettingEnabled, + Field(discriminator="enable"), +] + + +class _AutomaticProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.AUTOMATIC] + summary_index_setting: _SummaryIndexSetting | None = None + + +class _CustomProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.CUSTOM] + rules: _EstimateRules + summary_index_setting: _SummaryIndexSetting | None = None + + +class _HierarchicalProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.HIERARCHICAL] + rules: _EstimateRules + summary_index_setting: _SummaryIndexSetting | None = None + + +_EstimateProcessRule = Annotated[ + _AutomaticProcessRule | _CustomProcessRule | _HierarchicalProcessRule, + Field(discriminator="mode"), +] + + +class _EstimateArgs(BaseModel): + info_list: dict[str, Any] + process_rule: _EstimateProcessRule + + class DatasetService: @staticmethod def get_datasets(page, per_page, tenant_id=None, user=None, search=None, tag_ids=None, include_all=False): @@ -2851,94 +2932,16 @@ class DocumentService: @classmethod def estimate_args_validate(cls, args: dict[str, Any]): - if "info_list" not in args or not args["info_list"]: - raise ValueError("Data source info is required") - - if not isinstance(args["info_list"], dict): - raise ValueError("Data info is invalid") - - if "process_rule" not in args or not args["process_rule"]: - raise ValueError("Process rule is required") - - if not isinstance(args["process_rule"], dict): - raise ValueError("Process rule is invalid") - - if "mode" not in args["process_rule"] or not args["process_rule"]["mode"]: - raise ValueError("Process rule mode is required") - - if args["process_rule"]["mode"] not in DatasetProcessRule.MODES: - raise ValueError("Process rule mode is invalid") - - if args["process_rule"]["mode"] == ProcessRuleMode.AUTOMATIC: - args["process_rule"]["rules"] = {} - else: - if "rules" not in args["process_rule"] or not args["process_rule"]["rules"]: - raise ValueError("Process rule rules is required") - - if not isinstance(args["process_rule"]["rules"], dict): - raise ValueError("Process rule rules is invalid") - - if ( - "pre_processing_rules" not in args["process_rule"]["rules"] - or args["process_rule"]["rules"]["pre_processing_rules"] is None - ): - raise ValueError("Process rule pre_processing_rules is required") - - if not isinstance(args["process_rule"]["rules"]["pre_processing_rules"], list): - raise ValueError("Process rule pre_processing_rules is invalid") - - unique_pre_processing_rule_dicts = {} - for pre_processing_rule in args["process_rule"]["rules"]["pre_processing_rules"]: - if "id" not in pre_processing_rule or not pre_processing_rule["id"]: - raise ValueError("Process rule pre_processing_rules id is required") - - if pre_processing_rule["id"] not in DatasetProcessRule.PRE_PROCESSING_RULES: - raise ValueError("Process rule pre_processing_rules id is invalid") - - if "enabled" not in pre_processing_rule or pre_processing_rule["enabled"] is None: - raise ValueError("Process rule pre_processing_rules enabled is required") - - if not isinstance(pre_processing_rule["enabled"], bool): - raise ValueError("Process rule pre_processing_rules enabled is invalid") - - unique_pre_processing_rule_dicts[pre_processing_rule["id"]] = pre_processing_rule - - args["process_rule"]["rules"]["pre_processing_rules"] = list(unique_pre_processing_rule_dicts.values()) - - if ( - "segmentation" not in args["process_rule"]["rules"] - or args["process_rule"]["rules"]["segmentation"] is None - ): - raise ValueError("Process rule segmentation is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"], dict): - raise ValueError("Process rule segmentation is invalid") - - if ( - "separator" not in args["process_rule"]["rules"]["segmentation"] - or not args["process_rule"]["rules"]["segmentation"]["separator"] - ): - raise ValueError("Process rule segmentation separator is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"]["separator"], str): - raise ValueError("Process rule segmentation separator is invalid") - - if ( - "max_tokens" not in args["process_rule"]["rules"]["segmentation"] - or not args["process_rule"]["rules"]["segmentation"]["max_tokens"] - ): - raise ValueError("Process rule segmentation max_tokens is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"]["max_tokens"], int): - raise ValueError("Process rule segmentation max_tokens is invalid") - - # valid summary index setting - summary_index_setting = args["process_rule"].get("summary_index_setting") - if summary_index_setting and summary_index_setting.get("enable"): - if "model_name" not in summary_index_setting or not summary_index_setting["model_name"]: - raise ValueError("Summary index model name is required") - if "model_provider_name" not in summary_index_setting or not summary_index_setting["model_provider_name"]: - raise ValueError("Summary index model provider name is required") + try: + validated = _EstimateArgs.model_validate(args) + except ValidationError as e: + first = e.errors()[0] + original = first.get("ctx", {}).get("error") + raise ValueError(str(original) if isinstance(original, ValueError) else first["msg"]) from e + process_rule_dict = validated.process_rule.model_dump(exclude_none=True) + if validated.process_rule.mode == ProcessRuleMode.AUTOMATIC: + process_rule_dict["rules"] = {} + args["process_rule"] = process_rule_dict @staticmethod def batch_update_document_status( diff --git a/api/tests/unit_tests/services/test_dataset_service_document.py b/api/tests/unit_tests/services/test_dataset_service_document.py index 1633194aa8..a78bc7f9d6 100644 --- a/api/tests/unit_tests/services/test_dataset_service_document.py +++ b/api/tests/unit_tests/services/test_dataset_service_document.py @@ -1297,7 +1297,7 @@ class TestDocumentServiceEstimateValidation: """Unit tests for estimate_args_validate branches.""" def test_estimate_args_validate_rejects_missing_info_list(self): - with pytest.raises(ValueError, match="Data source info is required"): + with pytest.raises(ValueError, match="Field required"): DocumentService.estimate_args_validate({}) def test_estimate_args_validate_sets_empty_rules_for_automatic_mode(self): @@ -1357,7 +1357,7 @@ class TestDocumentServiceEstimateValidation: }, } - with pytest.raises(ValueError, match="Summary index model provider name is required"): + with pytest.raises(ValueError, match="Field required"): DocumentService.estimate_args_validate(args) From cbedcd2882ae4f7b2fd597b56647f34dcb87eebd Mon Sep 17 00:00:00 2001 From: -LAN- <laipz8200@outlook.com> Date: Tue, 12 May 2026 13:35:24 +0800 Subject: [PATCH 52/53] fix(security): harden self-hosted SECRET_KEY bootstrap (#36049) Co-authored-by: EndlessLucky <66432853+EndlessLucky@users.noreply.github.com> --- api/app_factory.py | 2 +- api/configs/feature/__init__.py | 6 +- api/configs/secret_key.py | 38 ++++++++++ api/core/app/workflow/file_runtime.py | 2 +- .../datasource/datasource_file_manager.py | 14 +++- api/core/tools/signature.py | 19 +++-- api/core/tools/tool_file_manager.py | 14 +++- api/extensions/ext_set_secretkey.py | 11 ++- api/models/dataset.py | 8 +- .../unit_tests/configs/test_dify_config.py | 41 ++++++++++ .../test_datasource_file_manager.py | 33 --------- .../extensions/test_set_secretkey.py | 74 +++++++++++++++++++ api/tests/unit_tests/libs/test_passport.py | 23 +----- docker/.env.example | 3 +- docker/README.md | 2 +- docker/envs/security.env.example | 3 +- 16 files changed, 209 insertions(+), 84 deletions(-) create mode 100644 api/configs/secret_key.py create mode 100644 api/tests/unit_tests/extensions/test_set_secretkey.py diff --git a/api/app_factory.py b/api/app_factory.py index 48e50ceae9..5583071980 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -181,7 +181,6 @@ def initialize_extensions(app: DifyApp): ext_import_modules, ext_orjson, ext_forward_refs, - ext_set_secretkey, ext_compress, ext_code_based_extension, ext_database, @@ -189,6 +188,7 @@ def initialize_extensions(app: DifyApp): ext_migrate, ext_redis, ext_storage, + ext_set_secretkey, ext_logstore, # Initialize logstore after storage, before celery ext_celery, ext_login, diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 26b8ea670b..ccb97d96ef 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -23,9 +23,9 @@ class SecurityConfig(BaseSettings): """ SECRET_KEY: str = Field( - description="Secret key for secure session cookie signing." - "Make sure you are changing this key for your deployment with a strong key." - "Generate a strong key using `openssl rand -base64 42` or set via the `SECRET_KEY` environment variable.", + description="Secret key for secure session cookie signing. " + "Leave empty to let Dify generate a persistent key in the storage directory, " + "or set a strong value via the `SECRET_KEY` environment variable.", default="", ) diff --git a/api/configs/secret_key.py b/api/configs/secret_key.py new file mode 100644 index 0000000000..f8c33f6a2c --- /dev/null +++ b/api/configs/secret_key.py @@ -0,0 +1,38 @@ +"""SECRET_KEY persistence helpers for runtime setup.""" + +from __future__ import annotations + +import secrets + +from extensions.ext_storage import storage + +GENERATED_SECRET_KEY_FILENAME = ".dify_secret_key" + + +def resolve_secret_key(secret_key: str) -> str: + """Return an explicit SECRET_KEY or a generated key persisted in storage.""" + if secret_key: + return secret_key + + return _load_or_create_secret_key() + + +def _load_or_create_secret_key() -> str: + try: + persisted_key = storage.load_once(GENERATED_SECRET_KEY_FILENAME).decode("utf-8").strip() + if persisted_key: + return persisted_key + except FileNotFoundError: + pass + + generated_key = secrets.token_urlsafe(48) + + try: + storage.save(GENERATED_SECRET_KEY_FILENAME, f"{generated_key}\n".encode()) + except Exception as exc: + raise ValueError( + f"SECRET_KEY is not set and could not be generated at {GENERATED_SECRET_KEY_FILENAME}. " + "Set SECRET_KEY explicitly or make storage writable." + ) from exc + + return generated_key diff --git a/api/core/app/workflow/file_runtime.py b/api/core/app/workflow/file_runtime.py index 3a6f9d575a..587f700286 100644 --- a/api/core/app/workflow/file_runtime.py +++ b/api/core/app/workflow/file_runtime.py @@ -128,7 +128,7 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol): @staticmethod def _secret_key() -> bytes: - return dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + return dify_config.SECRET_KEY.encode() def _sign_query(self, *, payload: str) -> dict[str, str]: timestamp = str(int(time.time())) diff --git a/api/core/datasource/datasource_file_manager.py b/api/core/datasource/datasource_file_manager.py index 492b507aa9..79b84a28be 100644 --- a/api/core/datasource/datasource_file_manager.py +++ b/api/core/datasource/datasource_file_manager.py @@ -35,8 +35,11 @@ class DatasourceFileManager: timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -47,8 +50,11 @@ class DatasourceFileManager: verify signature """ data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature diff --git a/api/core/tools/signature.py b/api/core/tools/signature.py index 3c7b523ff1..ca4756f2a4 100644 --- a/api/core/tools/signature.py +++ b/api/core/tools/signature.py @@ -8,6 +8,10 @@ import urllib.parse from configs import dify_config +def _secret_key() -> bytes: + return dify_config.SECRET_KEY.encode() + + def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str: """ sign file to get a temporary url for plugin access @@ -19,8 +23,7 @@ def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -39,8 +42,7 @@ def sign_upload_file_preview_url(upload_file_id: str, extension: str) -> str: timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -51,8 +53,7 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s verify signature """ data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature @@ -71,8 +72,7 @@ def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str, timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() query = urllib.parse.urlencode( { @@ -92,8 +92,7 @@ def verify_plugin_file_signature( """Verify the signature used by the plugin-facing file upload endpoint.""" data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() if sign != recalculated_encoded_sign: diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py index c87e8a3ae0..f2552e7cbd 100644 --- a/api/core/tools/tool_file_manager.py +++ b/api/core/tools/tool_file_manager.py @@ -51,8 +51,11 @@ class ToolFileManager: timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -63,8 +66,11 @@ class ToolFileManager: verify signature """ data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature diff --git a/api/extensions/ext_set_secretkey.py b/api/extensions/ext_set_secretkey.py index dfb87c0167..ca59a2de4d 100644 --- a/api/extensions/ext_set_secretkey.py +++ b/api/extensions/ext_set_secretkey.py @@ -1,6 +1,13 @@ from configs import dify_config +from configs.secret_key import resolve_secret_key from dify_app import DifyApp -def init_app(app: DifyApp): - app.secret_key = dify_config.SECRET_KEY +def init_app(app: DifyApp) -> None: + """Resolve SECRET_KEY after config loading and before session/login setup.""" + secret_key = dify_config.SECRET_KEY + if not secret_key: + secret_key = resolve_secret_key(secret_key) + dify_config.SECRET_KEY = secret_key + app.config["SECRET_KEY"] = secret_key + app.secret_key = secret_key diff --git a/api/models/dataset.py b/api/models/dataset.py index f823e0aa10..65ea39969c 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -945,7 +945,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -962,7 +962,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -981,7 +981,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -1019,7 +1019,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index 57dbf453de..919ebbc656 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -8,6 +8,47 @@ from yarl import URL from configs.app_config import DifyConfig +def _set_basic_config_env(monkeypatch: pytest.MonkeyPatch) -> None: + os.environ.clear() + monkeypatch.setenv("CONSOLE_API_URL", "https://example.com") + monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com") + monkeypatch.setenv("DB_TYPE", "postgresql") + monkeypatch.setenv("DB_USERNAME", "postgres") + monkeypatch.setenv("DB_PASSWORD", "postgres") + monkeypatch.setenv("DB_HOST", "localhost") + monkeypatch.setenv("DB_PORT", "5432") + monkeypatch.setenv("DB_DATABASE", "dify") + + +def test_dify_config_keeps_secret_key_empty_when_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + _set_basic_config_env(monkeypatch) + monkeypatch.delenv("SECRET_KEY", raising=False) + monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path)) + + config = DifyConfig(_env_file=None) + + assert config.SECRET_KEY == "" + assert not hasattr(config, "OPENDAL_FS_ROOT") + assert not (tmp_path / ".dify_secret_key").exists() + + +def test_dify_config_preserves_explicit_secret_key( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + _set_basic_config_env(monkeypatch) + monkeypatch.setenv("SECRET_KEY", "explicit") + monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path)) + + config = DifyConfig(_env_file=None) + + assert config.SECRET_KEY == "explicit" + assert not (tmp_path / ".dify_secret_key").exists() + + def test_dify_config(monkeypatch: pytest.MonkeyPatch): # clear system environment variables os.environ.clear() diff --git a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py index 4f39d38831..cee7d46083 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py @@ -34,20 +34,6 @@ class TestDatasourceFileManager: assert f"nonce={mock_urandom.return_value.hex()}" in signed_url assert "sign=" in signed_url - @patch("core.datasource.datasource_file_manager.time.time") - @patch("core.datasource.datasource_file_manager.os.urandom") - @patch("core.datasource.datasource_file_manager.dify_config") - def test_sign_file_empty_secret(self, mock_config, mock_urandom, mock_time): - # Setup - mock_config.FILES_URL = "http://localhost:5001" - mock_config.SECRET_KEY = None # Empty secret - mock_time.return_value = 1700000000 - mock_urandom.return_value = b"1234567890abcdef" - - # Execute - signed_url = DatasourceFileManager.sign_file("file_id", ".png") - assert "sign=" in signed_url - @patch("core.datasource.datasource_file_manager.time.time") @patch("core.datasource.datasource_file_manager.dify_config") def test_verify_file(self, mock_config, mock_time): @@ -76,25 +62,6 @@ class TestDatasourceFileManager: mock_time.return_value = 1700000500 # 700 seconds after timestamp (300 is timeout) assert DatasourceFileManager.verify_file(datasource_file_id, timestamp, nonce, encoded_sign) is False - @patch("core.datasource.datasource_file_manager.time.time") - @patch("core.datasource.datasource_file_manager.dify_config") - def test_verify_file_empty_secret(self, mock_config, mock_time): - # Setup - mock_config.SECRET_KEY = "" # Empty string secret - mock_config.FILES_ACCESS_TIMEOUT = 300 - mock_time.return_value = 1700000000 - - datasource_file_id = "file_id_123" - timestamp = "1699999800" - nonce = "some_nonce" - - # Calculate with empty secret - data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - sign = hmac.new(b"", data_to_sign.encode(), hashlib.sha256).digest() - encoded_sign = base64.urlsafe_b64encode(sign).decode() - - assert DatasourceFileManager.verify_file(datasource_file_id, timestamp, nonce, encoded_sign) is True - @patch("core.datasource.datasource_file_manager.db") @patch("core.datasource.datasource_file_manager.storage") @patch("core.datasource.datasource_file_manager.uuid4") diff --git a/api/tests/unit_tests/extensions/test_set_secretkey.py b/api/tests/unit_tests/extensions/test_set_secretkey.py new file mode 100644 index 0000000000..8a8e4e2b19 --- /dev/null +++ b/api/tests/unit_tests/extensions/test_set_secretkey.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest +from flask import Flask + +from extensions import ext_set_secretkey + + +class InMemoryStorage: + def __init__(self, files: dict[str, bytes] | None = None) -> None: + self.files = files or {} + self.saved_files: list[tuple[str, bytes]] = [] + + def load_once(self, filename: str) -> bytes: + try: + return self.files[filename] + except KeyError: + raise FileNotFoundError(filename) + + def save(self, filename: str, data: bytes) -> None: + self.files[filename] = data + self.saved_files.append((filename, data)) + + +def test_init_app_uses_configured_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: + secret_key = "configured-secret-key" + storage = InMemoryStorage() + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", secret_key) + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = secret_key + + ext_set_secretkey.init_app(app) + + assert app.secret_key == secret_key + assert app.config["SECRET_KEY"] == secret_key + assert storage.saved_files == [] + + +def test_init_app_generates_and_persists_secret_key_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + storage = InMemoryStorage() + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = "" + + ext_set_secretkey.init_app(app) + + persisted_key = storage.files[".dify_secret_key"].decode("utf-8").strip() + assert persisted_key + assert storage.saved_files == [(".dify_secret_key", f"{persisted_key}\n".encode())] + assert persisted_key == ext_set_secretkey.dify_config.SECRET_KEY + assert persisted_key == app.config["SECRET_KEY"] + assert persisted_key == app.secret_key + + +def test_init_app_reuses_persisted_secret_key_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + persisted_key = "persisted-secret-key" + storage = InMemoryStorage({".dify_secret_key": f"{persisted_key}\n".encode()}) + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = "" + + ext_set_secretkey.init_app(app) + + assert persisted_key == ext_set_secretkey.dify_config.SECRET_KEY + assert persisted_key == app.config["SECRET_KEY"] + assert persisted_key == app.secret_key + assert storage.saved_files == [] diff --git a/api/tests/unit_tests/libs/test_passport.py b/api/tests/unit_tests/libs/test_passport.py index f33484c18d..90b58ae548 100644 --- a/api/tests/unit_tests/libs/test_passport.py +++ b/api/tests/unit_tests/libs/test_passport.py @@ -143,28 +143,13 @@ class TestPassportService: assert str(exc_info.value) == "401 Unauthorized: Token has expired." # Configuration tests - def test_should_handle_empty_secret_key(self): - """Test behavior when SECRET_KEY is empty""" + def test_should_use_configured_secret_key_without_policy_validation(self): + """Test that policy decisions are owned by config, not PassportService.""" with patch("libs.passport.dify_config") as mock_config: - mock_config.SECRET_KEY = "" + mock_config.SECRET_KEY = "configured" service = PassportService() - # Empty secret key should still work but is insecure - payload = {"test": "data"} - token = service.issue(payload) - decoded = service.verify(token) - assert decoded == payload - - def test_should_handle_none_secret_key(self): - """Test behavior when SECRET_KEY is None""" - with patch("libs.passport.dify_config") as mock_config: - mock_config.SECRET_KEY = None - service = PassportService() - - payload = {"test": "data"} - # JWT library will raise TypeError when secret is None - with pytest.raises((TypeError, jwt.exceptions.InvalidKeyError)): - service.issue(payload) + assert service.sk == "configured" # Boundary condition tests def test_should_handle_large_payload(self, passport_service): diff --git a/docker/.env.example b/docker/.env.example index 5a012973c0..c708a40c15 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -28,7 +28,8 @@ LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONIOENCODING=utf-8 UV_CACHE_DIR=/tmp/.uv-cache -SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U +# Leave empty to auto-generate a persistent key in the storage directory. +SECRET_KEY= INIT_PASSWORD= DEPLOY_ENV=PRODUCTION CHECK_UPDATE_URL=https://updates.dify.ai diff --git a/docker/README.md b/docker/README.md index a2d9b2eeba..26b1dac9ac 100644 --- a/docker/README.md +++ b/docker/README.md @@ -87,7 +87,7 @@ The root `.env.example` file contains the essential startup settings. Optional a 1. **Server Configuration**: - `LOG_LEVEL`, `DEBUG`, `FLASK_DEBUG`: Logging and debug settings. - - `SECRET_KEY`: A key for encrypting session cookies and other sensitive data. + - `SECRET_KEY`: A key for signing sessions, JWTs, and file URLs. Leave it empty to let Dify generate a persistent key in the storage directory, or set a unique value yourself. 1. **Database Configuration**: diff --git a/docker/envs/security.env.example b/docker/envs/security.env.example index 787aef2706..d7556d91e5 100644 --- a/docker/envs/security.env.example +++ b/docker/envs/security.env.example @@ -36,5 +36,6 @@ TIDB_PUBLIC_KEY=dify TIDB_PRIVATE_KEY=dify VIKINGDB_ACCESS_KEY=your-ak VIKINGDB_SECRET_KEY=your-sk -SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U +# Leave empty to auto-generate a persistent key in the storage directory. +SECRET_KEY= INIT_PASSWORD= From 9424bf60b0daf564179446c0b5b189bf8aaf5cc8 Mon Sep 17 00:00:00 2001 From: orbisai0security <mediratta01.pally@gmail.com> Date: Tue, 12 May 2026 11:13:37 +0530 Subject: [PATCH 53/53] fix: the /threads and /db-pool-stat endpoints in api... in... (#35665) --- api/extensions/ext_app_metrics.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/extensions/ext_app_metrics.py b/api/extensions/ext_app_metrics.py index 4a6490b9f0..914baaadaf 100644 --- a/api/extensions/ext_app_metrics.py +++ b/api/extensions/ext_app_metrics.py @@ -5,6 +5,7 @@ import threading from flask import Response from configs import dify_config +from controllers.console.admin import admin_required from dify_app import DifyApp @@ -25,6 +26,7 @@ def init_app(app: DifyApp): ) @app.route("/threads") + @admin_required def threads(): # pyright: ignore[reportUnusedFunction] num_threads = threading.active_count() threads = threading.enumerate() @@ -50,6 +52,7 @@ def init_app(app: DifyApp): } @app.route("/db-pool-stat") + @admin_required def pool_stat(): # pyright: ignore[reportUnusedFunction] from extensions.ext_database import db