diff --git a/api/README.md b/api/README.md index 00562f3f78..a075bc0fa9 100644 --- a/api/README.md +++ b/api/README.md @@ -101,3 +101,11 @@ The scripts resolve paths relative to their location, so you can run them from a uv run ruff format ./ # Format code uv run basedpyright . # Type checking ``` + +## Generate TS stub + +``` +uv run dev/generate_swagger_specs.py --output-dir openapi +``` + +use https://jsontotable.org/openapi-to-typescript to convert to typescript diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py new file mode 100644 index 0000000000..7e9688bfb4 --- /dev/null +++ b/api/dev/generate_swagger_specs.py @@ -0,0 +1,172 @@ +"""Generate Flask-RESTX Swagger 2.0 specs without booting the full backend. + +This helper intentionally avoids `app_factory.create_app()`. The normal backend +startup eagerly initializes database, Redis, Celery, and storage extensions, +which is unnecessary when the goal is only to serialize the Flask-RESTX +`/swagger.json` documents. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +from dataclasses import dataclass +from pathlib import Path + +from flask import Flask +from flask_restx.swagger import Swagger + +logger = logging.getLogger(__name__) + +API_ROOT = Path(__file__).resolve().parents[1] +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + + +@dataclass(frozen=True) +class SpecTarget: + route: str + filename: str + + +SPEC_TARGETS: tuple[SpecTarget, ...] = ( + SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json"), + SpecTarget(route="/api/swagger.json", filename="web-swagger.json"), + SpecTarget(route="/v1/swagger.json", filename="service-swagger.json"), +) + +_ORIGINAL_REGISTER_MODEL = Swagger.register_model +_ORIGINAL_REGISTER_FIELD = Swagger.register_field + + +def _apply_runtime_defaults() -> None: + """Force the small config surface required for Swagger generation.""" + + os.environ.setdefault("SECRET_KEY", "spec-export") + os.environ.setdefault("STORAGE_TYPE", "local") + os.environ.setdefault("STORAGE_LOCAL_PATH", "/tmp/dify-storage") + os.environ.setdefault("SWAGGER_UI_ENABLED", "true") + + from configs import dify_config + + dify_config.SECRET_KEY = os.environ["SECRET_KEY"] + dify_config.STORAGE_TYPE = "local" + dify_config.STORAGE_LOCAL_PATH = os.environ["STORAGE_LOCAL_PATH"] + 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._anonymous_inline_models = anonymous_models + + anonymous_name = anonymous_models.get(id(nested_fields)) + if anonymous_name is None: + anonymous_name = f"_AnonymousInlineModel{len(anonymous_models) + 1}" + anonymous_models[id(nested_fields)] = anonymous_name + 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 isinstance(model, dict): + 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 isinstance(nested, dict): + 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() + + app = Flask(__name__) + + 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 + + app.register_blueprint(console_bp) + app.register_blueprint(web_bp) + app.register_blueprint(service_api_bp) + + return app + + +def generate_specs(output_dir: Path) -> list[Path]: + """Write all Swagger specs to `output_dir` and return the written paths.""" + + output_dir.mkdir(parents=True, exist_ok=True) + + app = create_spec_app() + client = app.test_client() + + written_paths: list[Path] = [] + for target in SPEC_TARGETS: + response = client.get(target.route) + if response.status_code != 200: + raise RuntimeError(f"failed to fetch {target.route}: {response.status_code}") + + payload = response.get_json() + if not isinstance(payload, dict): + raise RuntimeError(f"unexpected response payload for {target.route}") + + output_path = output_dir / target.filename + output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + written_paths.append(output_path) + + return written_paths + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-o", + "--output-dir", + type=Path, + default=Path("openapi"), + help="Directory where the Swagger JSON files will be written.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + written_paths = generate_specs(args.output_dir) + + for path in written_paths: + logger.debug(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/api/models/comment.py b/api/models/comment.py index 1154e16788..5d4a08e783 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -8,7 +8,7 @@ from sqlalchemy import Index, func from sqlalchemy.orm import Mapped, mapped_column, relationship from .account import Account -from .base import Base +from .base import Base, gen_uuidv7_string from .engine import db from .types import StringUUID @@ -42,7 +42,7 @@ class WorkflowComment(Base): Index("workflow_comments_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()")) + id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) 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) @@ -149,7 +149,7 @@ class WorkflowCommentReply(Base): Index("comment_replies_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()")) + id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) @@ -194,7 +194,7 @@ class WorkflowCommentMention(Base): Index("comment_mentions_user_idx", "mentioned_user_id"), ) - id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()")) + id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py new file mode 100644 index 0000000000..e77e875081 --- /dev/null +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -0,0 +1,37 @@ +"""Unit tests for the standalone Swagger export helper.""" + +import importlib.util +import json +import sys +from pathlib import Path + + +def _load_generate_swagger_specs_module(): + api_dir = Path(__file__).resolve().parents[3] + script_path = api_dir / "dev" / "generate_swagger_specs.py" + + spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path) + assert spec + assert spec.loader + + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) # type: ignore[attr-defined] + return module + + +def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path): + module = _load_generate_swagger_specs_module() + + written_paths = module.generate_specs(tmp_path) + + assert [path.name for path in written_paths] == [ + "console-swagger.json", + "web-swagger.json", + "service-swagger.json", + ] + + for path in written_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["swagger"] == "2.0" + assert "paths" in payload diff --git a/e2e/features/apps/app-detail-navigation.feature b/e2e/features/apps/app-detail-navigation.feature new file mode 100644 index 0000000000..7ac32039ec --- /dev/null +++ b/e2e/features/apps/app-detail-navigation.feature @@ -0,0 +1,26 @@ +@apps @authenticated @core +Feature: App detail navigation + + Scenario: Opening a workflow app navigates to the workflow editor + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + When I open the app from the app list + Then I should land on the workflow editor + + Scenario: Opening a chatbot app navigates to the configuration page + Given I am signed in as the default E2E admin + And a "chat" app has been created via API + When I open the app from the app list + Then I should land on the app configuration page + + Scenario: The develop tab is accessible from a workflow app + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + When I navigate to the app develop page + Then I should be on the app develop page + + Scenario: The overview tab is accessible from a workflow app + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + When I navigate to the app overview page + Then I should be on the app overview page diff --git a/e2e/features/apps/create-app.feature b/e2e/features/apps/create-app.feature index c0ca8ea4e0..d980bb9eb9 100644 --- a/e2e/features/apps/create-app.feature +++ b/e2e/features/apps/create-app.feature @@ -1,4 +1,4 @@ -@apps @authenticated +@apps @authenticated @core Feature: Create app Scenario: Create a new blank app and redirect to the editor Given I am signed in as the default E2E admin diff --git a/e2e/features/apps/create-chatbot-app.feature b/e2e/features/apps/create-chatbot-app.feature index 4f506e4f40..45f66aaa52 100644 --- a/e2e/features/apps/create-chatbot-app.feature +++ b/e2e/features/apps/create-chatbot-app.feature @@ -1,4 +1,4 @@ -@apps @authenticated +@apps @authenticated @core @mode-matrix Feature: Create Chatbot app Scenario: Create a new Chatbot app and redirect to the configuration page Given I am signed in as the default E2E admin diff --git a/e2e/features/apps/create-workflow-app.feature b/e2e/features/apps/create-workflow-app.feature index b88d94d899..2c11cf7a7a 100644 --- a/e2e/features/apps/create-workflow-app.feature +++ b/e2e/features/apps/create-workflow-app.feature @@ -1,4 +1,4 @@ -@apps @authenticated +@apps @authenticated @core @mode-matrix Feature: Create Workflow app Scenario: Create a new Workflow app and redirect to the workflow editor Given I am signed in as the default E2E admin diff --git a/e2e/features/apps/publish-app.feature b/e2e/features/apps/publish-app.feature new file mode 100644 index 0000000000..2d002d3cb7 --- /dev/null +++ b/e2e/features/apps/publish-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated @core +Feature: Publish app + + Scenario: Publish a workflow app for the first time + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + And a minimal workflow draft has been synced + When I open the app from the app list + And I open the publish panel + And I publish the app + Then the app should be marked as published diff --git a/e2e/features/auth/sign-in.feature b/e2e/features/auth/sign-in.feature new file mode 100644 index 0000000000..a9a1e13626 --- /dev/null +++ b/e2e/features/auth/sign-in.feature @@ -0,0 +1,8 @@ +@auth @smoke @core @unauthenticated +Feature: Sign in + + Scenario: Sign in with valid credentials and reach the apps console + Given I am not signed in + When I open the sign-in page + And I sign in as the default E2E admin + Then I should be on the apps console diff --git a/e2e/features/auth/sign-out.feature b/e2e/features/auth/sign-out.feature index 9112f1220a..4446beaf76 100644 --- a/e2e/features/auth/sign-out.feature +++ b/e2e/features/auth/sign-out.feature @@ -1,4 +1,4 @@ -@auth @authenticated +@auth @authenticated @core Feature: Sign out Scenario: Sign out from the apps console Given I am signed in as the default E2E admin diff --git a/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts b/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts new file mode 100644 index 0000000000..c7f30b3e1b --- /dev/null +++ b/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts @@ -0,0 +1,21 @@ +import type { DifyWorld } from '../../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' + +When('I navigate to the app develop page', async function (this: DifyWorld) { + const appId = this.createdAppIds.at(-1) + await this.getPage().goto(`/app/${appId}/develop`) +}) + +When('I navigate to the app overview page', async function (this: DifyWorld) { + const appId = this.createdAppIds.at(-1) + await this.getPage().goto(`/app/${appId}/overview`) +}) + +Then('I should be on the app develop page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/develop(?:\?.*)?$/, { timeout: 30_000 }) +}) + +Then('I should be on the app overview page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/overview(?:\?.*)?$/, { timeout: 30_000 }) +}) diff --git a/e2e/features/step-definitions/apps/publish-app.steps.ts b/e2e/features/step-definitions/apps/publish-app.steps.ts new file mode 100644 index 0000000000..de4f5ee63f --- /dev/null +++ b/e2e/features/step-definitions/apps/publish-app.steps.ts @@ -0,0 +1,15 @@ +import type { DifyWorld } from '../../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' + +When('I open the publish panel', async function (this: DifyWorld) { + await this.getPage().getByRole('button', { name: 'Publish' }).first().click() +}) + +When('I publish the app', async function (this: DifyWorld) { + await this.getPage().getByRole('button', { name: /Publish Update/ }).click() +}) + +Then('the app should be marked as published', async function (this: DifyWorld) { + await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 }) +}) diff --git a/e2e/features/step-definitions/auth/sign-in.steps.ts b/e2e/features/step-definitions/auth/sign-in.steps.ts new file mode 100644 index 0000000000..8f9e8e765c --- /dev/null +++ b/e2e/features/step-definitions/auth/sign-in.steps.ts @@ -0,0 +1,20 @@ +import type { DifyWorld } from '../../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { adminCredentials } from '../../../fixtures/auth' + +When('I open the sign-in page', async function (this: DifyWorld) { + await this.getPage().goto('/signin') +}) + +When('I sign in as the default E2E admin', async function (this: DifyWorld) { + const page = this.getPage() + + await page.getByLabel('Email address').fill(adminCredentials.email) + await page.getByLabel('Password').fill(adminCredentials.password) + await page.getByRole('button', { name: 'Sign in' }).click() +}) + +Then('I should be on the apps console', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/, { timeout: 30_000 }) +}) diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts new file mode 100644 index 0000000000..93e808e3c5 --- /dev/null +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -0,0 +1,22 @@ +import type { DifyWorld } from '../../support/world' +import { Given, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { createTestApp, syncMinimalWorkflowDraft } from '../../../support/api' + +Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) { + const app = await createTestApp(`E2E ${Date.now()}`, mode) + this.createdAppIds.push(app.id) + this.lastCreatedAppName = app.name +}) + +Given('a minimal workflow draft has been synced', async function (this: DifyWorld) { + const appId = this.createdAppIds.at(-1)! + await syncMinimalWorkflowDraft(appId) +}) + +When('I open the app from the app list', async function (this: DifyWorld) { + const page = this.getPage() + await page.goto('/apps') + await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible() + await page.getByText(this.lastCreatedAppName!).click() +}) diff --git a/e2e/support/api.ts b/e2e/support/api.ts index c6d6c98bde..7d9fd0264f 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -43,6 +43,34 @@ export async function createTestApp(name: string, mode = 'workflow'): Promise { + const ctx = await createApiContext() + try { + await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { + data: { + graph: { + nodes: [ + { + id: '1', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: '1', type: 'start', title: 'Start', variables: [] }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + }, + }) + } + finally { + await ctx.dispose() + } +} + export async function deleteTestApp(id: string): Promise { const ctx = await createApiContext() try { diff --git a/eslint-suppressions.json b/eslint-suppressions.json index dcb58d4b57..405ce77400 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3542,11 +3542,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-page/debug-info.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-page/empty/index.tsx": { "react/set-state-in-effect": { "count": 2 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 8cc22693b6..83e873ab89 100644 --- a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx @@ -40,11 +40,11 @@ describe('CopyFeedback', () => { expect(mockCopy).toHaveBeenCalledWith('test content') }) - it('calls reset on mouse leave', () => { + it('does not reset on mouse leave (relies on hook timeout)', () => { render() const button = screen.getByRole('button') fireEvent.mouseLeave(button.firstChild as Element) - expect(mockReset).toHaveBeenCalledTimes(1) + expect(mockReset).not.toHaveBeenCalled() }) }) }) @@ -88,11 +88,11 @@ describe('CopyFeedbackNew', () => { expect(mockCopy).toHaveBeenCalledWith('test content') }) - it('calls reset on mouse leave', () => { + it('does not reset on mouse leave (relies on hook timeout)', () => { const { container } = render() const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement fireEvent.mouseLeave(clickableArea) - expect(mockReset).toHaveBeenCalledTimes(1) + 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 5210066670..431b697a6a 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -19,7 +19,10 @@ const prefixEmbedded = 'overview.appInfo.embedded' const CopyFeedback = ({ content }: Props) => { const { t } = useTranslation() - const { copied, copy, reset } = useClipboard() + // Rely on useClipboard's own timer to flip `copied` back to false so the + // "Copied" tooltip stays visible long enough to be read, matching the + // KeyValueItem pattern. Do NOT reset on mouse leave. + const { copied, copy } = useClipboard({ timeout: 2000 }) const tooltipText = copied ? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' }) @@ -36,10 +39,7 @@ const CopyFeedback = ({ content }: Props) => { popupContent={safeText} > -
+
{copied && } {!copied && }
@@ -52,7 +52,7 @@ export default CopyFeedback export const CopyFeedbackNew = ({ content, className }: Pick) => { const { t } = useTranslation() - const { copied, copy, reset } = useClipboard() + const { copied, copy } = useClipboard({ timeout: 2000 }) const tooltipText = copied ? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' }) @@ -73,7 +73,6 @@ export const CopyFeedbackNew = ({ content, className }: Pick
diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx index 30f5f095eb..678434b8c0 100644 --- a/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx +++ b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx @@ -1,3 +1,4 @@ +import { Popover } from '@langgenius/dify-ui/popover' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,18 +8,15 @@ vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', })) -vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ - default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => - isShow ?
: null, -})) - const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) return ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + ) } @@ -28,8 +26,10 @@ const renderWithProviders = (ui: React.ReactElement) => { } describe('Card (Service API)', () => { + const onOpenSecretKeyModal = vi.fn() const defaultProps = { apiBaseUrl: 'https://api.dify.ai/v1', + onOpenSecretKeyModal, } beforeEach(() => { @@ -77,48 +77,33 @@ describe('Card (Service API)', () => { // Props: tests different apiBaseUrl values describe('Props', () => { it('should display provided apiBaseUrl', () => { - renderWithProviders() + renderWithProviders() expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument() }) it('should show green indicator when apiBaseUrl is provided', () => { - renderWithProviders() + renderWithProviders() // The Indicator component receives color="green" when apiBaseUrl is truthy const statusText = screen.getByText(/serviceApi\.enabled/) expect(statusText).toHaveClass('text-text-success') }) it('should show yellow indicator when apiBaseUrl is empty', () => { - renderWithProviders() + renderWithProviders() // Still shows "enabled" text but indicator color differs expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument() }) }) - // User Interactions: tests button clicks and modal + // User Interactions: tests button clicks describe('User Interactions', () => { - it('should open secret key modal when API key button is clicked', () => { - renderWithProviders() - - // Modal should not be visible before clicking - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - - const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button') - fireEvent.click(apiKeyButton!) - - // Modal should appear after clicking - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - - it('should close secret key modal when onClose is called', () => { + it('should call onOpenSecretKeyModal when API key button is clicked', () => { renderWithProviders() const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button') fireEvent.click(apiKeyButton!) - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - fireEvent.click(screen.getByText('close')) - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + expect(onOpenSecretKeyModal).toHaveBeenCalledTimes(1) }) it('should render API reference as a link', () => { @@ -148,20 +133,20 @@ describe('Card (Service API)', () => { // Edge Cases: tests empty/long URLs describe('Edge Cases', () => { it('should handle empty apiBaseUrl', () => { - renderWithProviders() + renderWithProviders() // Should still render the structure expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument() }) it('should handle very long apiBaseUrl', () => { const longUrl = `https://api.dify.ai/${'path/'.repeat(50)}` - renderWithProviders() + renderWithProviders() expect(screen.getByText(longUrl)).toBeInTheDocument() }) it('should handle apiBaseUrl with special characters', () => { const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar' - renderWithProviders() + renderWithProviders() expect(screen.getByText(specialUrl)).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx index 8137052383..93b752d9a0 100644 --- a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx +++ b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx @@ -1,9 +1,8 @@ +import { Popover } from '@langgenius/dify-ui/popover' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -// Component Imports (after mocks) - import Card from '../card' import ServiceApi from '../index' @@ -43,7 +42,8 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ ), })) -// ServiceApi Component Tests +const renderCard = (ui: React.ReactElement) => + render({ui}) describe('ServiceApi', () => { beforeEach(() => { @@ -80,18 +80,15 @@ describe('ServiceApi', () => { }) }) - // Props Variations Tests describe('Props Variations', () => { - it('should show green Indicator when apiBaseUrl is provided', () => { + it('should show Indicator when apiBaseUrl is provided', () => { const { container } = render() - // When apiBaseUrl is truthy, Indicator color is green const triggerContainer = container.querySelector('.relative.flex.h-8') expect(triggerContainer).toBeInTheDocument() }) - it('should show yellow Indicator when apiBaseUrl is empty', () => { + it('should show Indicator when apiBaseUrl is empty', () => { const { container } = render() - // When apiBaseUrl is falsy, Indicator color is yellow const triggerContainer = container.querySelector('.relative.flex.h-8') expect(triggerContainer).toBeInTheDocument() }) @@ -110,28 +107,7 @@ describe('ServiceApi', () => { }) describe('User Interactions', () => { - it('should toggle popup open state on click', async () => { - const user = userEvent.setup() - - render() - - const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - expect(trigger).toBeInTheDocument() - - if (trigger) - await user.click(trigger) - - // After click, the Card should be rendered - }) - - it('should apply hover styles on trigger', () => { - render() - - const trigger = screen.getByText(/serviceApi\.title/i).closest('div[class*="cursor-pointer"]') - expect(trigger).toHaveClass('cursor-pointer') - }) - - it('should toggle open state from false to true on first click', async () => { + it('should open popup on trigger click', async () => { const user = userEvent.setup() render() @@ -140,56 +116,13 @@ describe('ServiceApi', () => { if (trigger) await user.click(trigger) - // Card should be visible after clicking await waitFor(() => { expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() }) }) - - it('should toggle open state back to false on second click', async () => { - const user = userEvent.setup() - - render() - - const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) { - await user.click(trigger) // open - await user.click(trigger) // close - } - - // Component should handle the toggle without errors - }) - - it('should apply open state styling when popup is open', async () => { - const user = userEvent.setup() - - render() - - const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) - - // When open, the trigger should have hover background class - }) }) - // Portal and Card Integration Tests describe('Portal and Card Integration', () => { - it('should render Card component inside portal when open', async () => { - const user = userEvent.setup() - - render() - - const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) - - // Wait for portal content to appear - await waitFor(() => { - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - }) - }) - it('should pass apiBaseUrl prop to Card component', async () => { const user = userEvent.setup() const testUrl = 'https://test-api.example.com' @@ -204,38 +137,9 @@ describe('ServiceApi', () => { expect(screen.getByText(testUrl)).toBeInTheDocument() }) }) - - it('should use correct portal placement configuration', () => { - render() - // PortalToFollowElem is configured with placement="top-start" - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() - }) - - it('should use correct portal offset configuration', () => { - render() - // PortalToFollowElem is configured with offset={{ mainAxis: 4, crossAxis: -4 }} - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() - }) }) describe('Edge Cases', () => { - it('should handle rapid toggle clicks gracefully', async () => { - const user = userEvent.setup() - - render() - - const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) { - // Rapid clicks - await user.click(trigger) - await user.click(trigger) - await user.click(trigger) - } - - // Component should handle state changes without errors - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() - }) - it('should render correctly with empty apiBaseUrl', () => { render() expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() @@ -248,391 +152,60 @@ describe('ServiceApi', () => { rerender() - // Component should still render after prop change - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() - }) - - it('should handle undefined-like apiBaseUrl values', () => { - // Empty string is the closest to undefined for this prop - render() - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() - }) - }) - - describe('Memoization', () => { - it('should be memoized with React.memo', () => { - const { rerender } = render() - - rerender() - - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() - }) - - it('should not re-render unnecessarily with same props', () => { - const { rerender } = render() - - rerender() - rerender() - - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() - }) - - it('should update when apiBaseUrl prop changes', () => { - const { rerender } = render() - - rerender() - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() }) }) }) -// Card Component Tests - describe('Card (service-api)', () => { + const onOpenSecretKeyModal = vi.fn() + beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { - render() + renderCard() expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() }) - it('should display card title', () => { - render() - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - }) - - it('should display enabled status', () => { - render() - expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() - }) - - it('should render endpoint label', () => { - render() - expect(screen.getByText(/serviceApi\.card\.endpoint/i)).toBeInTheDocument() - }) - it('should display apiBaseUrl in endpoint field', () => { const testUrl = 'https://api.example.com' - render() + renderCard() expect(screen.getByText(testUrl)).toBeInTheDocument() }) - it('should render Indicator component', () => { - const { container } = render() - // Card container should be present - const cardContainer = container.querySelector('.flex.w-\\[360px\\]') - expect(cardContainer).toBeInTheDocument() - }) - it('should render API Key button', () => { - render() + renderCard() expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument() }) it('should render API Reference button', () => { - render() + renderCard() expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument() }) - - it('should render CopyFeedback component for endpoint', () => { - const { container } = render() - // CopyFeedback should be in the endpoint section - const copyButton = container.querySelector('[class*="bg-components-input-bg-normal"]') - expect(copyButton).toBeInTheDocument() - }) - - it('should render ApiAggregate icon in header', () => { - const { container } = render() - const icon = container.querySelector('svg') - expect(icon).toBeInTheDocument() - }) - }) - - // Props Variations Tests - describe('Props Variations', () => { - it('should show green Indicator when apiBaseUrl is provided', () => { - const { container } = render() - const cardContainer = container.querySelector('.flex.w-\\[360px\\]') - expect(cardContainer).toBeInTheDocument() - }) - - it('should show yellow Indicator when apiBaseUrl is empty', () => { - const { container } = render() - const cardContainer = container.querySelector('.flex.w-\\[360px\\]') - expect(cardContainer).toBeInTheDocument() - }) - - it('should display different apiBaseUrl values correctly', () => { - const testUrls = [ - 'https://api.example.com', - 'https://localhost:3000', - 'https://api.production.example.com/v1', - ] - - testUrls.forEach((url) => { - const { unmount } = render() - expect(screen.getByText(url)).toBeInTheDocument() - unmount() - }) - }) - - it('should handle empty apiBaseUrl', () => { - render() - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - }) - - it('should truncate long apiBaseUrl', () => { - const longUrl = 'https://api.example.com/v1/very/long/path/to/endpoint/that/should/truncate' - const { container } = render() - const truncateElement = container.querySelector('.truncate') - expect(truncateElement).toBeInTheDocument() - }) }) describe('User Interactions', () => { - it('should open SecretKeyModal when API Key button is clicked', async () => { + it('should call onOpenSecretKeyModal when API Key button is clicked', async () => { const user = userEvent.setup() - render() - - const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - expect(apiKeyButton).toBeInTheDocument() - - if (apiKeyButton) - await user.click(apiKeyButton) - - await waitFor(() => { - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - }) - - it('should close SecretKeyModal when close button is clicked', async () => { - const user = userEvent.setup() - - render() + renderCard() const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') if (apiKeyButton) await user.click(apiKeyButton) - await waitFor(() => { - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - - const closeButton = screen.getByTestId('close-modal-btn') - await user.click(closeButton) - - await waitFor(() => { - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - }) + expect(onOpenSecretKeyModal).toHaveBeenCalledTimes(1) }) it('should have correct href for API Reference link', () => { - render() + renderCard() const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a') expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') }) - - it('should open API Reference in new tab', () => { - render() - - const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a') - expect(apiRefLink).toHaveAttribute('target', '_blank') - expect(apiRefLink).toHaveAttribute('rel', 'noopener noreferrer') - }) - - it('should toggle modal visibility correctly', async () => { - const user = userEvent.setup() - - render() - - // Initially modal should not be visible - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - - const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - if (apiKeyButton) - await user.click(apiKeyButton) - - // Modal should be visible - await waitFor(() => { - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - - const closeButton = screen.getByTestId('close-modal-btn') - await user.click(closeButton) - - // Modal should not be visible again - await waitFor(() => { - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - }) - }) - }) - - // Modal State Tests - describe('Modal State', () => { - it('should initialize with modal closed', () => { - render() - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - }) - - it('should open modal on handleOpenSecretKeyModal', async () => { - const user = userEvent.setup() - - render() - - const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - if (apiKeyButton) - await user.click(apiKeyButton) - - await waitFor(() => { - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - }) - - it('should close modal on handleCloseSecretKeyModal', async () => { - const user = userEvent.setup() - - render() - - // Open modal first - const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - if (apiKeyButton) - await user.click(apiKeyButton) - - await waitFor(() => { - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - - const closeButton = screen.getByTestId('close-modal-btn') - await user.click(closeButton) - - await waitFor(() => { - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - }) - }) - - it('should handle multiple open/close cycles', async () => { - const user = userEvent.setup() - - render() - - const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - - // First cycle - if (apiKeyButton) - await user.click(apiKeyButton) - - await waitFor(() => { - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - - await user.click(screen.getByTestId('close-modal-btn')) - - await waitFor(() => { - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - }) - - // Second cycle - if (apiKeyButton) - await user.click(apiKeyButton) - - await waitFor(() => { - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - }) - }) - - describe('Edge Cases', () => { - it('should handle empty apiBaseUrl gracefully', () => { - render() - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - // Endpoint field should show empty string - }) - - it('should handle very long apiBaseUrl', () => { - const longUrl = 'https://'.concat('a'.repeat(500), '.com') - render() - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - }) - - it('should handle special characters in apiBaseUrl', () => { - const specialUrl = 'https://api.example.com/path?query=test¶m=value#anchor' - render() - expect(screen.getByText(specialUrl)).toBeInTheDocument() - }) - - it('should render without errors when all buttons are clickable', async () => { - const user = userEvent.setup() - - render() - - const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - if (apiKeyButton) - await user.click(apiKeyButton) - - await waitFor(() => { - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - }) - - await user.click(screen.getByTestId('close-modal-btn')) - - await waitFor(() => { - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - }) - - // Component should still be functional - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - }) - }) - - describe('Memoization', () => { - it('should be memoized with React.memo', () => { - const { rerender } = render() - - rerender() - - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - }) - - it('should use useCallback for handlers', () => { - const { rerender } = render() - - rerender() - - // Component should render without issues with memoized callbacks - expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument() - }) - - it('should update when apiBaseUrl prop changes', () => { - const { rerender } = render() - - expect(screen.getByText('https://api.example.com')).toBeInTheDocument() - - rerender() - - expect(screen.getByText('https://new-api.example.com')).toBeInTheDocument() - }) - }) - - // Copy Functionality Tests - describe('Copy Functionality', () => { - it('should render CopyFeedback component for apiBaseUrl', () => { - const { container } = render() - const copyContainer = container.querySelector('[class*="bg-components-input-bg-normal"]') - expect(copyContainer).toBeInTheDocument() - }) - - it('should pass apiBaseUrl to CopyFeedback component', () => { - const testUrl = 'https://api.example.com' - render() - // The URL should be displayed in the copy section - expect(screen.getByText(testUrl)).toBeInTheDocument() - }) }) }) @@ -641,78 +214,33 @@ describe('ServiceApi Integration', () => { vi.clearAllMocks() }) - it('should open Card popup and display endpoint', async () => { - const user = userEvent.setup() - const testUrl = 'https://api.example.com' - - render() - - // Open popup - const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) - - // Wait for Card to appear - await waitFor(() => { - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - expect(screen.getByText(testUrl)).toBeInTheDocument() - }) - }) - - it('should complete full workflow: open -> view endpoint -> access API key', async () => { + it('should close popover and open modal when API Key button is clicked', async () => { const user = userEvent.setup() render() - // Open popup + // Open popover const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') if (trigger) await user.click(trigger) - // Verify Card content await waitFor(() => { - expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() - expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument() }) - // Open API Key modal + // Click API Key button (wrapped by PopoverClose) const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') if (apiKeyButton) await user.click(apiKeyButton) - // Verify modal appears + // Modal should appear await waitFor(() => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) - }) - it('should navigate to API Reference from Card', async () => { - const user = userEvent.setup() - - render() - - // Open popup - const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) - - // Wait for Card to appear + // Popover should be closed — Card title no longer in document await waitFor(() => { - expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument() + expect(screen.queryByText(/serviceApi\.card\.title/i)).not.toBeInTheDocument() }) - - // Verify link - const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a') - expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') - }) - - it('should reflect apiBaseUrl status in Indicator color', () => { - // With URL - should be green - const { rerender } = render() - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() - - // Without URL - should be yellow - rerender() - expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/extra-info/service-api/card.tsx b/web/app/components/datasets/extra-info/service-api/card.tsx index 6f024c3f5d..efa5cf7f6a 100644 --- a/web/app/components/datasets/extra-info/service-api/card.tsx +++ b/web/app/components/datasets/extra-info/service-api/card.tsx @@ -1,35 +1,27 @@ import { Button } from '@langgenius/dify-ui/button' +import { PopoverClose } from '@langgenius/dify-ui/popover' import { RiBookOpenLine, RiKey2Line } from '@remixicon/react' import * as React from 'react' -import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import CopyFeedback from '@/app/components/base/copy-feedback' import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge' -import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal' import Indicator from '@/app/components/header/indicator' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import Link from '@/next/link' type CardProps = { apiBaseUrl: string + onOpenSecretKeyModal: () => void } const Card = ({ apiBaseUrl, + onOpenSecretKeyModal, }: CardProps) => { const { t } = useTranslation() - const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false) const apiReferenceUrl = useDatasetApiAccessUrl() - const handleOpenSecretKeyModal = useCallback(() => { - setIsSecretKeyModalVisible(true) - }, []) - - const handleCloseSecretKeyModal = useCallback(() => { - setIsSecretKeyModalVisible(false) - }, []) - return (
@@ -74,17 +66,21 @@ const Card = ({
{/* Actions */}
- + + + + {t('serviceApi.card.apiKey', { ns: 'dataset' })} + + + )} + />
-
) } diff --git a/web/app/components/datasets/extra-info/service-api/index.tsx b/web/app/components/datasets/extra-info/service-api/index.tsx index c3fe05248d..453715bfe3 100644 --- a/web/app/components/datasets/extra-info/service-api/index.tsx +++ b/web/app/components/datasets/extra-info/service-api/index.tsx @@ -1,8 +1,9 @@ import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' +import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal' import Indicator from '@/app/components/header/indicator' import Card from './card' @@ -15,6 +16,15 @@ const ServiceApi = ({ }: ServiceApiProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) + const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false) + + const handleOpenSecretKeyModal = useCallback(() => { + setIsSecretKeyModalVisible(true) + }, []) + + const handleCloseSecretKeyModal = useCallback(() => { + setIsSecretKeyModalVisible(false) + }, []) return (
@@ -49,9 +59,14 @@ const ServiceApi = ({ > +
) } diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index beee35b06d..adc53debbd 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -99,6 +99,14 @@ vi.mock('../../external-api/external-api-panel', () => ({ ), })) +// Mock SecretKeyModal — it depends on user profile context and service APIs +// not configured in this test. ServiceApi always mounts the modal (controlled +// by `isShow`) so we provide a lightweight stub. +vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ + default: ({ isShow }: { isShow: boolean }) => + isShow ?
: null, +})) + // Mock TagManagementModal vi.mock('@/app/components/base/tag-management', () => ({ default: () =>
, diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx index fca64ac096..327e0bddcf 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import SetURL from '../setURL' @@ -53,6 +53,15 @@ describe('SetURL', () => { const input = screen.getByLabelText('plugin.installFromGitHub.gitHubRepo') expect(input).toBeInTheDocument() }) + + it('should auto-focus the input on mount', async () => { + render() + + const input = screen.getByRole('textbox') + await waitFor(() => { + expect(input).toHaveFocus() + }) + }) }) // ================================ diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx index f1b149a8bf..b4f0741eb2 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx @@ -13,6 +13,18 @@ type SetURLProps = { const SetURL: React.FC = ({ repoUrl, onChange, onNext, onCancel }) => { const { t } = useTranslation() + const inputRef = React.useRef(null) + + // Focus the input after the dropdown's focus-return animation settles. + // Using rAF avoids racing the DropdownMenu FloatingFocusManager that returns + // focus to the trigger on close. + React.useEffect(() => { + const frame = requestAnimationFrame(() => { + inputRef.current?.focus() + }) + return () => cancelAnimationFrame(frame) + }, []) + return ( <> ({ useDebugKey: () => mockDebugKey, })) -vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children }: { children: React.ReactNode }) => , -})) - -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ - children, - disabled, - popupContent, - }: { - children: React.ReactNode - disabled?: boolean - popupContent: React.ReactNode - }) => ( -
- {children} - {!disabled &&
{popupContent}
} -
- ), -})) - vi.mock('../../base/key-value-item', () => ({ default: ({ label, @@ -68,16 +48,31 @@ describe('DebugInfo', () => { expect(container.innerHTML).toBe('') }) - it('renders debug metadata and masks the key when info is available', () => { + it('renders a disabled trigger when debug info is unavailable', () => { + render() + + const trigger = screen.getByRole('button') + expect(trigger).toBeDisabled() + }) + + it('opens a popover with debug metadata and masks the key when info is available', async () => { mockDebugKey.data = { host: '127.0.0.1', port: 5001, key: '12345678abcdefghijklmnopqrst87654321', } + const user = userEvent.setup() render() - expect(screen.getByTestId('debug-button')).toBeInTheDocument() + const trigger = screen.getByRole('button') + expect(trigger).toBeEnabled() + + // Popover is closed initially — content not rendered yet + expect(screen.queryByText('plugin.debugInfo.title')).not.toBeInTheDocument() + + await user.click(trigger) + expect(screen.getByText('plugin.debugInfo.title')).toBeInTheDocument() expect(screen.getByRole('link')).toHaveAttribute( 'href', diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx index 6d6784dea8..9f883af1b2 100644 --- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -199,6 +199,7 @@ describe('InstallPluginDropdown', () => { const { container } = render() fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.local')) fireEvent.change(container.querySelector('input[type="file"]')!, { target: { files: [new File(['content'], 'plugin.difypkg')], @@ -235,6 +236,7 @@ describe('InstallPluginDropdown', () => { const { container } = render() fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.local')) fireEvent.change(container.querySelector('input[type="file"]')!, { target: { files: [new File(['content'], 'plugin.difypkg')], diff --git a/web/app/components/plugins/plugin-page/debug-info.tsx b/web/app/components/plugins/plugin-page/debug-info.tsx index 4e02fdc2be..9e4404c39c 100644 --- a/web/app/components/plugins/plugin-page/debug-info.tsx +++ b/web/app/components/plugins/plugin-page/debug-info.tsx @@ -1,13 +1,13 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowRightUpLine, RiBugLine, } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { useDocLink } from '@/context/i18n' import { useDebugKey } from '@/service/use-plugins' import KeyValueItem from '../base/key-value-item' @@ -25,41 +25,54 @@ const DebugInfo: FC = () => { if (isLoading) return null - return ( - -
- {t(`${i18nPrefix}.title`, { ns: 'plugin' })} - - {t(`${i18nPrefix}.viewDocs`, { ns: 'plugin' })} - - -
-
- - -
- - )} - popupClassName="flex flex-col items-start w-[256px] px-4 py-3.5 gap-1 border border-components-panel-border - rounded-xl bg-components-tooltip-bg shadows-shadow-lg z-50" - asChild={false} - position="bottom" - > - -
+ ) + } + + return ( + + + + + )} + /> + +
+ + {t(`${i18nPrefix}.title`, { ns: 'plugin' })} + + + {t(`${i18nPrefix}.viewDocs`, { ns: 'plugin' })} + + +
+
+ + +
+
+
) } diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index f74de31159..a972c9891c 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -49,7 +49,8 @@ const InstallPluginDropdown = ({ }) const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0] + const file = event.target.files?.[0] ?? null + event.target.value = '' if (file) { setSelectedFile(file) setSelectedAction('local') @@ -57,6 +58,13 @@ const InstallPluginDropdown = ({ } } + const handleCloseLocalInstaller = () => { + setSelectedAction(null) + setSelectedFile(null) + if (fileInputRef.current) + fileInputRef.current.value = '' + } + // TODO TEST INSTALL : uninstall // const [pluginLists, setPluginLists] = useState([]) // useEffect(() => { @@ -105,6 +113,13 @@ const InstallPluginDropdown = ({ return (
+ {t('installFrom', { ns: 'plugin' })} - {installMethods.map(({ icon: Icon, text, action }) => ( setSelectedAction(null)} + onClose={handleCloseLocalInstaller} onSuccess={noop} /> )} diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index 0f057f0787..17e1de3feb 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -72,10 +72,10 @@ const OnlineUsers = () => { const isCurrentUser = user.user_id === currentUserId return ( - - {baseName} + + {baseName} {isCurrentUser && ( - + {currentUserSuffix} )} @@ -156,11 +156,11 @@ const OnlineUsers = () => { {renderDisplayName( user, - 'system-xs-medium text-text-secondary', + 'max-w-full system-xs-medium text-text-secondary', 'text-text-quaternary', )} diff --git a/web/hooks/use-clipboard.ts b/web/hooks/use-clipboard.ts index 6d24c04027..60ceeb4f18 100644 --- a/web/hooks/use-clipboard.ts +++ b/web/hooks/use-clipboard.ts @@ -43,12 +43,14 @@ export function useClipboard({ const copy = useCallback(async (valueToCopy: string) => { try { await writeTextToClipboard(valueToCopy) + handleCopyResult(true) } catch (e) { if (usePromptAsFallback) { try { // eslint-disable-next-line no-alert -- prompt as fallback in case of copy error window.prompt(promptFallbackText, valueToCopy) + handleCopyResult(true) } catch (e2) { handleCopyError(e2 as Error) diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index 1c175f9bbe..04a618fb3b 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "المحتوى المخزن", "chatVariable.updatedAt": "تم التحديث في ", "collaboration.historyAction.generic": "قام متعاون بعملية تراجع/إعادة", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "أضف تعليقًا", "comments.actions.deleteReply": "حذف الرد", "comments.actions.editComment": "تعديل التعليق", "comments.actions.editReply": "تعديل الرد", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "فشل حذف الإصدار", "versionHistory.action.deleteSuccess": "تم حذف الإصدار", "versionHistory.action.restoreFailure": "فشل استعادة الإصدار", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} يقوم بالاستعادة إلى الإصدار {{versionName}}...", "versionHistory.action.restoreSuccess": "تم استعادة الإصدار", "versionHistory.action.updateFailure": "فشل تحديث الإصدار", "versionHistory.action.updateSuccess": "تم تحديث الإصدار", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index d29b0d426e..fe50c09651 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Gespeicherter Inhalt", "chatVariable.updatedAt": "Aktualisiert am ", "collaboration.historyAction.generic": "Ein Mitbearbeiter hat Rückgängig/Wiederholen ausgeführt", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Kommentar hinzufügen", "comments.actions.deleteReply": "Antwort löschen", "comments.actions.editComment": "Kommentar bearbeiten", "comments.actions.editReply": "Antwort bearbeiten", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Version löschen fehlgeschlagen", "versionHistory.action.deleteSuccess": "Version gelöscht", "versionHistory.action.restoreFailure": "Wiederherstellung der Version fehlgeschlagen", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} stellt Version {{versionName}} wieder her...", "versionHistory.action.restoreSuccess": "Version wiederhergestellt", "versionHistory.action.updateFailure": "Aktualisierung der Version fehlgeschlagen", "versionHistory.action.updateSuccess": "Version aktualisiert", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index ac62b01571..5da69241e7 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Contenido almacenado", "chatVariable.updatedAt": "Actualizado el ", "collaboration.historyAction.generic": "Un colaborador realizó deshacer/rehacer", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Añadir un comentario", "comments.actions.deleteReply": "Eliminar respuesta", "comments.actions.editComment": "Editar comentario", "comments.actions.editReply": "Editar respuesta", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Error al eliminar la versión", "versionHistory.action.deleteSuccess": "Versión eliminada", "versionHistory.action.restoreFailure": "Error al restaurar la versión", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} está restaurando a la versión {{versionName}}...", "versionHistory.action.restoreSuccess": "Versión restaurada", "versionHistory.action.updateFailure": "Error al actualizar la versión", "versionHistory.action.updateSuccess": "Versión actualizada", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index 1a39adc295..3210cf8919 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "محتوای ذخیره‌شده", "chatVariable.updatedAt": "به‌روزرسانی شده در ", "collaboration.historyAction.generic": "یک همکار عملیات برگرداندن/انجام مجدد را انجام داد", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "افزودن دیدگاه", "comments.actions.deleteReply": "حذف پاسخ", "comments.actions.editComment": "ویرایش دیدگاه", "comments.actions.editReply": "ویرایش پاسخ", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index eb79aa74b5..da3e69dab3 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Contenu stocké", "chatVariable.updatedAt": "Mis à jour le ", "collaboration.historyAction.generic": "Un collaborateur a effectué une annulation/une réexécution", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Ajouter un commentaire", "comments.actions.deleteReply": "Supprimer la réponse", "comments.actions.editComment": "Modifier le commentaire", "comments.actions.editReply": "Modifier la réponse", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Échec de la suppression de la version", "versionHistory.action.deleteSuccess": "Version supprimée", "versionHistory.action.restoreFailure": "Échec de la restauration de la version", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} restaure la version {{versionName}}...", "versionHistory.action.restoreSuccess": "Version restaurée", "versionHistory.action.updateFailure": "Échec de la mise à jour de la version", "versionHistory.action.updateSuccess": "Version mise à jour", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index 7c11307eb1..20845af0b8 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "संग्रहीत सामग्री", "chatVariable.updatedAt": "अपडेट किया गया ", "collaboration.historyAction.generic": "एक सहयोगी ने Undo/Redo किया", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "टिप्पणी जोड़ें", "comments.actions.deleteReply": "जवाब हटाएं", "comments.actions.editComment": "टिप्पणी संपादित करें", "comments.actions.editReply": "जवाब संपादित करें", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "संस्करण को हटाने में विफल", "versionHistory.action.deleteSuccess": "संस्करण हटाया गया", "versionHistory.action.restoreFailure": "संस्करण को पुनर्स्थापित करने में विफल", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} संस्करण {{versionName}} को पुनर्स्थापित कर रहे हैं...", "versionHistory.action.restoreSuccess": "संस्करण पुनर्स्थापित किया गया", "versionHistory.action.updateFailure": "संस्करण अपडेट करने में विफल", "versionHistory.action.updateSuccess": "संस्करण अपडेट किया गया", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 8fa635a535..2c32f25aab 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Konten yang disimpan", "chatVariable.updatedAt": "Diperbarui pada", "collaboration.historyAction.generic": "Seorang kolaborator melakukan urungkan/ulang", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Tambahkan komentar", "comments.actions.deleteReply": "Hapus balasan", "comments.actions.editComment": "Edit komentar", "comments.actions.editReply": "Edit balasan", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Gagal menghapus versi", "versionHistory.action.deleteSuccess": "Versi dihapus", "versionHistory.action.restoreFailure": "Gagal memulihkan versi", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} sedang memulihkan ke versi {{versionName}}...", "versionHistory.action.restoreSuccess": "Versi dipulihkan", "versionHistory.action.updateFailure": "Gagal memperbarui versi", "versionHistory.action.updateSuccess": "Versi diperbarui", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index 71c5cc0be4..1c779d1365 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Contenuto memorizzato", "chatVariable.updatedAt": "Aggiornato il ", "collaboration.historyAction.generic": "Un collaboratore ha eseguito annulla/ripristina", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Aggiungi un commento", "comments.actions.deleteReply": "Elimina risposta", "comments.actions.editComment": "Modifica commento", "comments.actions.editReply": "Modifica risposta", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Impossibile eliminare la versione", "versionHistory.action.deleteSuccess": "Versione eliminata", "versionHistory.action.restoreFailure": "Impossibile ripristinare la versione", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} sta ripristinando alla versione {{versionName}}...", "versionHistory.action.restoreSuccess": "Versione ripristinata", "versionHistory.action.updateFailure": "Impossibile aggiornare la versione", "versionHistory.action.updateSuccess": "Versione aggiornata", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 357e9e0752..1ee43c17cf 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "保存内容", "chatVariable.updatedAt": "最終更新:", "collaboration.historyAction.generic": "共同編集者が元に戻す/やり直しを実行しました", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "コメントを追加", "comments.actions.deleteReply": "返信を削除", "comments.actions.editComment": "コメントを編集", "comments.actions.editReply": "返信を編集", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "削除に失敗しました", "versionHistory.action.deleteSuccess": "削除が完了しました", "versionHistory.action.restoreFailure": "復元に失敗しました", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} がバージョン {{versionName}} に復元中です...", "versionHistory.action.restoreSuccess": "復元が完了しました", "versionHistory.action.updateFailure": "更新に失敗しました", "versionHistory.action.updateSuccess": "更新が完了しました", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index eb25e295f0..b6291e4366 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "저장된 내용", "chatVariable.updatedAt": "업데이트 시간: ", "collaboration.historyAction.generic": "공동 작업자가 실행 취소/다시 실행을 수행했습니다", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "댓글 추가", "comments.actions.deleteReply": "답글 삭제", "comments.actions.editComment": "댓글 편집", "comments.actions.editReply": "답글 편집", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "버전을 삭제하지 못했습니다.", "versionHistory.action.deleteSuccess": "버전 삭제됨", "versionHistory.action.restoreFailure": "버전을 복원하지 못했습니다.", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}}님이 버전 {{versionName}}로 복원하는 중입니다...", "versionHistory.action.restoreSuccess": "복원된 버전", "versionHistory.action.updateFailure": "버전 업데이트에 실패했습니다.", "versionHistory.action.updateSuccess": "버전이 업데이트되었습니다.", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index d8da4c099c..c3d5824ef7 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Stored content", "chatVariable.updatedAt": "Updated at ", "collaboration.historyAction.generic": "Een medewerker heeft een ongedaan maken/opnieuw uitvoeren-actie uitgevoerd", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Voeg een reactie toe", "comments.actions.deleteReply": "Antwoord verwijderen", "comments.actions.editComment": "Reactie bewerken", "comments.actions.editReply": "Antwoord bewerken", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 25047dc9e7..6b0bda1ff8 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Przechowywana zawartość", "chatVariable.updatedAt": "Zaktualizowano ", "collaboration.historyAction.generic": "Współpracownik wykonał cofnięcie/ponowienie", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Dodaj komentarz", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "Edytuj komentarz", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Nie udało się usunąć wersji", "versionHistory.action.deleteSuccess": "Wersja usunięta", "versionHistory.action.restoreFailure": "Nie udało się przywrócić wersji", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} przywraca wersję {{versionName}}...", "versionHistory.action.restoreSuccess": "Wersja przywrócona", "versionHistory.action.updateFailure": "Nie udało się zaktualizować wersji", "versionHistory.action.updateSuccess": "Wersja zaktualizowana", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index 638a207db3..a8a7511100 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Conteúdo armazenado", "chatVariable.updatedAt": "Atualizado em ", "collaboration.historyAction.generic": "Um colaborador realizou desfazer/refazer", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Adicionar comentário", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "Editar comentário", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Falha ao deletar versão", "versionHistory.action.deleteSuccess": "Versão excluída", "versionHistory.action.restoreFailure": "Falha ao restaurar versão", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} está restaurando para a versão {{versionName}}...", "versionHistory.action.restoreSuccess": "Versão restaurada", "versionHistory.action.updateFailure": "Falha ao atualizar a versão", "versionHistory.action.updateSuccess": "Versão atualizada", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index 6293a2eadd..c15e8508ab 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Conținut stocat", "chatVariable.updatedAt": "Actualizat la ", "collaboration.historyAction.generic": "Un colaborator a efectuat anulare/refacere", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Adaugă un comentariu", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "Editează comentariul", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Ștergerea versiunii a eșuat", "versionHistory.action.deleteSuccess": "Versiune ștearsă", "versionHistory.action.restoreFailure": "Restaurarea versiunii a eșuat", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} restaurează la versiunea {{versionName}}...", "versionHistory.action.restoreSuccess": "Versiune restaurată", "versionHistory.action.updateFailure": "Actualizarea versiunii a eșuat", "versionHistory.action.updateSuccess": "Versiune actualizată", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index cce1fde523..55622ec730 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Сохраненный контент", "chatVariable.updatedAt": "Обновлено в ", "collaboration.historyAction.generic": "Участник выполнил отмену/повтор", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Добавить комментарий", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "Редактировать комментарий", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Не удалось удалить версию", "versionHistory.action.deleteSuccess": "Версия удалена", "versionHistory.action.restoreFailure": "Не удалось восстановить версию", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} восстанавливает версию {{versionName}}...", "versionHistory.action.restoreSuccess": "Версия восстановлена", "versionHistory.action.updateFailure": "Не удалось обновить версию", "versionHistory.action.updateSuccess": "Версия обновлена", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index 8ef999acdf..7ea8dedec4 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Shranjena vsebina", "chatVariable.updatedAt": "Posodobljeno ob", "collaboration.historyAction.generic": "Sodelavec je izvedel razveljavitev/ponovitev", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Dodaj komentar", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "Uredi komentar", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Brisanje različice ni uspelo", "versionHistory.action.deleteSuccess": "Različica izbrisana", "versionHistory.action.restoreFailure": "Obnavljanje različice ni uspelo", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} obnavlja različico {{versionName}}...", "versionHistory.action.restoreSuccess": "Obnovljena različica", "versionHistory.action.updateFailure": "Posodobitev različice ni uspela", "versionHistory.action.updateSuccess": "Različica posodobljena", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index f632b72b6d..e1280cf438 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "เนื้อหาที่เก็บไว้", "chatVariable.updatedAt": "อัพเดทเมื่อ", "collaboration.historyAction.generic": "ผู้ร่วมงานได้ทำการยกเลิก/ทำซ้ำ", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "เพิ่มความคิดเห็น", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "แก้ไขความคิดเห็น", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "ลบเวอร์ชันไม่สำเร็จ", "versionHistory.action.deleteSuccess": "เวอร์ชันถูกลบ", "versionHistory.action.restoreFailure": "ไม่สามารถกู้คืนเวอร์ชันได้", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} กำลังกู้คืนไปยังเวอร์ชัน {{versionName}}...", "versionHistory.action.restoreSuccess": "เวอร์ชันที่กู้คืน", "versionHistory.action.updateFailure": "ไม่สามารถอัปเดตเวอร์ชันได้", "versionHistory.action.updateSuccess": "อัปเดตเวอร์ชัน", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index 4d2212e85c..54ee28cf1c 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Depolanan içerik", "chatVariable.updatedAt": "Güncellenme zamanı: ", "collaboration.historyAction.generic": "Bir işbirlikçi geri alma/yeniden yapma gerçekleştirdi", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Yorum ekle", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "Yorumu düzenle", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Versiyonu silme işlemi başarısız oldu", "versionHistory.action.deleteSuccess": "Sürüm silindi", "versionHistory.action.restoreFailure": "Sürümü geri yüklemekte başarısız olundu", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}}, {{versionName}} sürümüne geri dönüyor...", "versionHistory.action.restoreSuccess": "Sürüm geri yüklendi", "versionHistory.action.updateFailure": "Sürüm güncellenemedi", "versionHistory.action.updateSuccess": "Sürüm güncellendi", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 1137e50ff8..94f869845e 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Збережений вміст", "chatVariable.updatedAt": "Оновлено ", "collaboration.historyAction.generic": "Співавтор виконав скасування/повторення", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Додати коментар", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "Редагувати коментар", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Не вдалося видалити версію", "versionHistory.action.deleteSuccess": "Версія видалена", "versionHistory.action.restoreFailure": "Не вдалося відновити версію", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} відновлює до версії {{versionName}}...", "versionHistory.action.restoreSuccess": "Версія відновлена", "versionHistory.action.updateFailure": "Не вдалося оновити версію", "versionHistory.action.updateSuccess": "Версія оновлена", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index fa9697dbef..377a794464 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "Nội dung đã lưu", "chatVariable.updatedAt": "Cập nhật lúc ", "collaboration.historyAction.generic": "Một cộng tác viên đã thực hiện hoàn tác/làm lại", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "Thêm bình luận", "comments.actions.deleteReply": "Delete reply", "comments.actions.editComment": "Chỉnh sửa bình luận", "comments.actions.editReply": "Edit reply", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "Xóa phiên bản thất bại", "versionHistory.action.deleteSuccess": "Phiên bản đã bị xóa", "versionHistory.action.restoreFailure": "Không thể khôi phục phiên bản", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} đang khôi phục về phiên bản {{versionName}}...", "versionHistory.action.restoreSuccess": "Phiên bản đã được khôi phục", "versionHistory.action.updateFailure": "Cập nhật phiên bản không thành công", "versionHistory.action.updateSuccess": "Phiên bản đã được cập nhật", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 292c80524a..817bf580c9 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "存储内容", "chatVariable.updatedAt": "更新时间 ", "collaboration.historyAction.generic": "协作者执行了撤销/重做操作", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "添加评论", "comments.actions.deleteReply": "删除回复", "comments.actions.editComment": "编辑评论", "comments.actions.editReply": "编辑回复", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 1841755e04..1e10badec0 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -110,7 +110,7 @@ "chatVariable.storedContent": "已儲存內容", "chatVariable.updatedAt": "更新於 ", "collaboration.historyAction.generic": "協作者執行了復原/重做操作", - "comments.actions.addComment": "Add Comment", + "comments.actions.addComment": "新增評論", "comments.actions.deleteReply": "刪除回覆", "comments.actions.editComment": "編輯評論", "comments.actions.editReply": "編輯回覆", @@ -1190,7 +1190,7 @@ "versionHistory.action.deleteFailure": "無法刪除版本", "versionHistory.action.deleteSuccess": "版本已刪除", "versionHistory.action.restoreFailure": "無法恢復版本", - "versionHistory.action.restoreInProgress": "{{userName}} is restoring to version {{versionName}}...", + "versionHistory.action.restoreInProgress": "{{userName}} 正在還原至版本 {{versionName}}...", "versionHistory.action.restoreSuccess": "恢復版本", "versionHistory.action.updateFailure": "更新版本失敗", "versionHistory.action.updateSuccess": "版本已更新",