mirror of
https://mirror.skon.top/github.com/langgenius/dify.git
synced 2026-04-30 17:50:29 +08:00
Merge branch 'main' into jzh
This commit is contained in:
@@ -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
|
||||
|
||||
172
api/dev/generate_swagger_specs.py
Normal file
172
api/dev/generate_swagger_specs.py
Normal file
@@ -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())
|
||||
@@ -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
|
||||
)
|
||||
|
||||
37
api/tests/unit_tests/commands/test_generate_swagger_specs.py
Normal file
37
api/tests/unit_tests/commands/test_generate_swagger_specs.py
Normal file
@@ -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
|
||||
26
e2e/features/apps/app-detail-navigation.feature
Normal file
26
e2e/features/apps/app-detail-navigation.feature
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
11
e2e/features/apps/publish-app.feature
Normal file
11
e2e/features/apps/publish-app.feature
Normal file
@@ -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
|
||||
8
e2e/features/auth/sign-in.feature
Normal file
8
e2e/features/auth/sign-in.feature
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
15
e2e/features/step-definitions/apps/publish-app.steps.ts
Normal file
15
e2e/features/step-definitions/apps/publish-app.steps.ts
Normal file
@@ -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 })
|
||||
})
|
||||
20
e2e/features/step-definitions/auth/sign-in.steps.ts
Normal file
20
e2e/features/step-definitions/auth/sign-in.steps.ts
Normal file
@@ -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 })
|
||||
})
|
||||
22
e2e/features/step-definitions/common/app.steps.ts
Normal file
22
e2e/features/step-definitions/common/app.steps.ts
Normal file
@@ -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()
|
||||
})
|
||||
@@ -43,6 +43,34 @@ export async function createTestApp(name: string, mode = 'workflow'): Promise<Ap
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncMinimalWorkflowDraft(appId: string): Promise<void> {
|
||||
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<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<CopyFeedback content="test content" />)
|
||||
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(<CopyFeedbackNew content="test content" />)
|
||||
const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement
|
||||
fireEvent.mouseLeave(clickableArea)
|
||||
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<ActionButton>
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
onMouseLeave={reset}
|
||||
>
|
||||
<div onClick={handleCopy}>
|
||||
{copied && <RiClipboardFill className="h-4 w-4" />}
|
||||
{!copied && <RiClipboardLine className="h-4 w-4" />}
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@ export default CopyFeedback
|
||||
|
||||
export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' | 'content'>) => {
|
||||
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<Props, 'className'
|
||||
>
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
onMouseLeave={reset}
|
||||
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''}`}
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -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 ? <div data-testid="secret-key-modal"><button onClick={onClose}>close</button></div> : null,
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<Popover open>
|
||||
{children}
|
||||
</Popover>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -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(<Card apiBaseUrl="https://custom-api.example.com" />)
|
||||
renderWithProviders(<Card apiBaseUrl="https://custom-api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show green indicator when apiBaseUrl is provided', () => {
|
||||
renderWithProviders(<Card apiBaseUrl="https://api.dify.ai" />)
|
||||
renderWithProviders(<Card apiBaseUrl="https://api.dify.ai" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
// 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(<Card apiBaseUrl="" />)
|
||||
renderWithProviders(<Card apiBaseUrl="" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
// 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(<Card {...defaultProps} />)
|
||||
|
||||
// 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(<Card {...defaultProps} />)
|
||||
|
||||
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(<Card apiBaseUrl="" />)
|
||||
renderWithProviders(<Card apiBaseUrl="" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
// 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(<Card apiBaseUrl={longUrl} />)
|
||||
renderWithProviders(<Card apiBaseUrl={longUrl} onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
expect(screen.getByText(longUrl)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle apiBaseUrl with special characters', () => {
|
||||
const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar'
|
||||
renderWithProviders(<Card apiBaseUrl={specialUrl} />)
|
||||
renderWithProviders(<Card apiBaseUrl={specialUrl} onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
expect(screen.getByText(specialUrl)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(<Popover open>{ui}</Popover>)
|
||||
|
||||
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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
// 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(<ServiceApi apiBaseUrl="" />)
|
||||
// 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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
@@ -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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
// PortalToFollowElem is configured with placement="top-start"
|
||||
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use correct portal offset configuration', () => {
|
||||
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
// 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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<ServiceApi apiBaseUrl="" />)
|
||||
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
|
||||
@@ -248,391 +152,60 @@ describe('ServiceApi', () => {
|
||||
|
||||
rerender(<ServiceApi apiBaseUrl="https://new-api.example.com" />)
|
||||
|
||||
// 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(<ServiceApi apiBaseUrl="" />)
|
||||
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not re-render unnecessarily with same props', () => {
|
||||
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when apiBaseUrl prop changes', () => {
|
||||
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
rerender(<ServiceApi apiBaseUrl="https://new-api.example.com" />)
|
||||
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display card title', () => {
|
||||
render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display enabled status', () => {
|
||||
render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render endpoint label', () => {
|
||||
render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
expect(screen.getByText(/serviceApi\.card\.endpoint/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display apiBaseUrl in endpoint field', () => {
|
||||
const testUrl = 'https://api.example.com'
|
||||
render(<Card apiBaseUrl={testUrl} />)
|
||||
renderCard(<Card apiBaseUrl={testUrl} onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
expect(screen.getByText(testUrl)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Indicator component', () => {
|
||||
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
// Card container should be present
|
||||
const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
|
||||
expect(cardContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API Key button', () => {
|
||||
render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API Reference button', () => {
|
||||
render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CopyFeedback component for endpoint', () => {
|
||||
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
// 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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
|
||||
expect(cardContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show yellow Indicator when apiBaseUrl is empty', () => {
|
||||
const { container } = render(<Card apiBaseUrl="" />)
|
||||
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(<Card apiBaseUrl={url} />)
|
||||
expect(screen.getByText(url)).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty apiBaseUrl', () => {
|
||||
render(<Card apiBaseUrl="" />)
|
||||
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(<Card apiBaseUrl={longUrl} />)
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
|
||||
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
// 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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open modal on handleOpenSecretKeyModal', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
// 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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<Card apiBaseUrl="" />)
|
||||
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(<Card apiBaseUrl={longUrl} />)
|
||||
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(<Card apiBaseUrl={specialUrl} />)
|
||||
expect(screen.getByText(specialUrl)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without errors when all buttons are clickable', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
rerender(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use useCallback for handlers', () => {
|
||||
const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
rerender(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
// 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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
|
||||
|
||||
rerender(<Card apiBaseUrl="https://new-api.example.com" />)
|
||||
|
||||
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(<Card apiBaseUrl="https://api.example.com" />)
|
||||
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(<Card apiBaseUrl={testUrl} />)
|
||||
// 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(<ServiceApi apiBaseUrl={testUrl} />)
|
||||
|
||||
// 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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
// 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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
|
||||
// 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(<ServiceApi apiBaseUrl="https://api.example.com" />)
|
||||
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
|
||||
|
||||
// Without URL - should be yellow
|
||||
rerender(<ServiceApi apiBaseUrl="" />)
|
||||
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex w-[360px] flex-col rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-1">
|
||||
<div className="flex flex-col gap-y-3 p-4">
|
||||
@@ -74,17 +66,21 @@ const Card = ({
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex gap-x-1 border-t-[0.5px] border-divider-subtle p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="gap-x-px text-text-tertiary"
|
||||
onClick={handleOpenSecretKeyModal}
|
||||
>
|
||||
<RiKey2Line className="size-3.5 shrink-0" />
|
||||
<span className="px-[3px] system-xs-medium">
|
||||
{t('serviceApi.card.apiKey', { ns: 'dataset' })}
|
||||
</span>
|
||||
</Button>
|
||||
<PopoverClose
|
||||
render={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="gap-x-px text-text-tertiary"
|
||||
onClick={onOpenSecretKeyModal}
|
||||
>
|
||||
<RiKey2Line className="size-3.5 shrink-0" />
|
||||
<span className="px-[3px] system-xs-medium">
|
||||
{t('serviceApi.card.apiKey', { ns: 'dataset' })}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Link
|
||||
href={apiReferenceUrl}
|
||||
target="_blank"
|
||||
@@ -102,10 +98,6 @@ const Card = ({
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<SecretKeyModal
|
||||
isShow={isSecretKeyModalVisible}
|
||||
onClose={handleCloseSecretKeyModal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
@@ -49,9 +59,14 @@ const ServiceApi = ({
|
||||
>
|
||||
<Card
|
||||
apiBaseUrl={apiBaseUrl}
|
||||
onOpenSecretKeyModal={handleOpenSecretKeyModal}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<SecretKeyModal
|
||||
isShow={isSecretKeyModalVisible}
|
||||
onClose={handleCloseSecretKeyModal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? <div data-testid="secret-key-modal" /> : null,
|
||||
}))
|
||||
|
||||
// Mock TagManagementModal
|
||||
vi.mock('@/app/components/base/tag-management', () => ({
|
||||
default: () => <div data-testid="tag-management-modal" />,
|
||||
|
||||
@@ -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(<SetURL {...defaultProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
|
||||
@@ -13,6 +13,18 @@ type SetURLProps = {
|
||||
|
||||
const SetURL: React.FC<SetURLProps> = ({ repoUrl, onChange, onNext, onCancel }) => {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = React.useRef<HTMLInputElement>(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 (
|
||||
<>
|
||||
<label
|
||||
@@ -22,6 +34,7 @@ const SetURL: React.FC<SetURLProps> = ({ repoUrl, onChange, onNext, onCancel })
|
||||
<span className="system-sm-semibold">{t('installFromGitHub.gitHubRepo', { ns: 'plugin' })}</span>
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
id="repoUrl"
|
||||
name="repoUrl"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DebugInfo from '../debug-info'
|
||||
|
||||
@@ -15,27 +16,6 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
useDebugKey: () => mockDebugKey,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({ children }: { children: React.ReactNode }) => <button data-testid="debug-button">{children}</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({
|
||||
children,
|
||||
disabled,
|
||||
popupContent,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
popupContent: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
{!disabled && <div data-testid="tooltip-content">{popupContent}</div>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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(<DebugInfo />)
|
||||
|
||||
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(<DebugInfo />)
|
||||
|
||||
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',
|
||||
|
||||
@@ -199,6 +199,7 @@ describe('InstallPluginDropdown', () => {
|
||||
const { container } = render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
|
||||
|
||||
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(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
|
||||
|
||||
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')],
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip
|
||||
triggerMethod="click"
|
||||
disabled={!info}
|
||||
popupContent={(
|
||||
<>
|
||||
<div className="flex items-center gap-1 self-stretch">
|
||||
<span className="flex shrink-0 grow basis-0 flex-col items-start justify-center system-sm-semibold text-text-secondary">{t(`${i18nPrefix}.title`, { ns: 'plugin' })}</span>
|
||||
<a href={docLink('/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin')} target="_blank" className="flex cursor-pointer items-center gap-0.5 text-text-accent-light-mode-only">
|
||||
<span className="system-xs-medium">{t(`${i18nPrefix}.viewDocs`, { ns: 'plugin' })}</span>
|
||||
<RiArrowRightUpLine className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<KeyValueItem
|
||||
label="URL"
|
||||
value={`${info?.host}:${info?.port}`}
|
||||
/>
|
||||
<KeyValueItem
|
||||
label="Key"
|
||||
value={info?.key || ''}
|
||||
maskedValue={maskedKey}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
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"
|
||||
>
|
||||
<Button className="h-full w-full p-2 text-components-button-secondary-text">
|
||||
if (!info) {
|
||||
return (
|
||||
<Button className="h-full w-full p-2 text-components-button-secondary-text" disabled>
|
||||
<RiBugLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button className="h-full w-full p-2 text-components-button-secondary-text">
|
||||
<RiBugLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom"
|
||||
popupClassName="flex w-[256px] flex-col items-start gap-1 rounded-xl border border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg"
|
||||
>
|
||||
<div className="flex items-center gap-1 self-stretch">
|
||||
<span className="flex shrink-0 grow basis-0 flex-col items-start justify-center system-sm-semibold text-text-secondary">
|
||||
{t(`${i18nPrefix}.title`, { ns: 'plugin' })}
|
||||
</span>
|
||||
<a
|
||||
href={docLink('/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex cursor-pointer items-center gap-0.5 text-text-accent-light-mode-only"
|
||||
>
|
||||
<span className="system-xs-medium">{t(`${i18nPrefix}.viewDocs`, { ns: 'plugin' })}</span>
|
||||
<RiArrowRightUpLine className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<KeyValueItem
|
||||
label="URL"
|
||||
value={`${info.host}:${info.port}`}
|
||||
/>
|
||||
<KeyValueItem
|
||||
label="Key"
|
||||
value={info.key || ''}
|
||||
maskedValue={maskedKey}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ const InstallPluginDropdown = ({
|
||||
})
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<any>([])
|
||||
// useEffect(() => {
|
||||
@@ -105,6 +113,13 @@ const InstallPluginDropdown = ({
|
||||
return (
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
|
||||
/>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button
|
||||
@@ -126,13 +141,6 @@ const InstallPluginDropdown = ({
|
||||
<span className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-3 system-xs-medium-uppercase text-text-tertiary">
|
||||
{t('installFrom', { ns: 'plugin' })}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
|
||||
/>
|
||||
{installMethods.map(({ icon: Icon, text, action }) => (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
@@ -157,7 +165,7 @@ const InstallPluginDropdown = ({
|
||||
&& (
|
||||
<InstallFromLocalPackage
|
||||
file={selectedFile}
|
||||
onClose={() => setSelectedAction(null)}
|
||||
onClose={handleCloseLocalInstaller}
|
||||
onSuccess={noop}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -72,10 +72,10 @@ const OnlineUsers = () => {
|
||||
const isCurrentUser = user.user_id === currentUserId
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1', baseClassName)}>
|
||||
<span>{baseName}</span>
|
||||
<span className={cn('inline-flex min-w-0 items-center gap-1', baseClassName)}>
|
||||
<span className="truncate">{baseName}</span>
|
||||
{isCurrentUser && (
|
||||
<span className={suffixClassName}>
|
||||
<span className={cn('shrink-0', suffixClassName)}>
|
||||
{currentUserSuffix}
|
||||
</span>
|
||||
)}
|
||||
@@ -156,11 +156,11 @@ const OnlineUsers = () => {
|
||||
<TooltipContent
|
||||
placement="bottom"
|
||||
sideOffset={4}
|
||||
className="flex h-[28px] w-[85px] items-center justify-center gap-1 rounded-md border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-3 py-[6px] shadow-lg shadow-shadow-shadow-5 backdrop-blur-[10px]"
|
||||
className="flex h-[28px] max-w-[220px] min-w-0 items-center justify-center rounded-md border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-3 py-[6px] shadow-lg shadow-shadow-shadow-5 backdrop-blur-[10px]"
|
||||
>
|
||||
{renderDisplayName(
|
||||
user,
|
||||
'system-xs-medium text-text-secondary',
|
||||
'max-w-full system-xs-medium text-text-secondary',
|
||||
'text-text-quaternary',
|
||||
)}
|
||||
</TooltipContent>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "تم تحديث الإصدار",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ویرایش پاسخ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "संस्करण अपडेट किया गया",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "更新が完了しました",
|
||||
|
||||
@@ -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": "버전이 업데이트되었습니다.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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": "Версия обновлена",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "อัปเดตเวอร์ชัน",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Версія оновлена",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "编辑回复",
|
||||
|
||||
@@ -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": "版本已更新",
|
||||
|
||||
Reference in New Issue
Block a user