mirror of
https://github.com/langgenius/dify.git
synced 2026-05-16 15:07:11 +08:00
Compare commits
7 Commits
copilot/fi
...
hotfix/1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d96e6e520 | |||
| 842110a601 | |||
| abcfb5bb81 | |||
| 7113b42744 | |||
| 6fafeec415 | |||
| d23cefe005 | |||
| 16d408d908 |
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.3"
|
||||
version = "1.14.0"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@ -17,7 +18,7 @@ from core.trigger.constants import (
|
||||
)
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from models import Account, AppMode
|
||||
from models import Account, App, AppMode
|
||||
from models.model import AppModelConfig, IconType
|
||||
from services import app_dsl_service
|
||||
from services.account_service import AccountService, TenantService
|
||||
@ -67,6 +68,22 @@ def _pending_yaml_content(version: str = "99.0.0") -> bytes:
|
||||
return (f'version: "{version}"\nkind: app\napp:\n name: Loop Test\n mode: workflow\n').encode()
|
||||
|
||||
|
||||
def _app_stub(**overrides: Any) -> App:
|
||||
defaults = {
|
||||
"id": str(uuid4()),
|
||||
"tenant_id": _DEFAULT_TENANT_ID,
|
||||
"mode": AppMode.WORKFLOW.value,
|
||||
"name": "n",
|
||||
"description": "d",
|
||||
"icon_type": IconType.EMOJI,
|
||||
"icon": "i",
|
||||
"icon_background": "#fff",
|
||||
"use_icon_as_answer_icon": False,
|
||||
"app_model_config": None,
|
||||
}
|
||||
return cast(App, SimpleNamespace(**(defaults | overrides)))
|
||||
|
||||
|
||||
class TestAppDslService:
|
||||
"""Integration tests for AppDslService using testcontainers."""
|
||||
|
||||
@ -585,7 +602,7 @@ class TestAppDslService:
|
||||
|
||||
def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers):
|
||||
service = AppDslService(db_session_with_containers)
|
||||
app_model = SimpleNamespace(id=str(uuid4()), tenant_id=_DEFAULT_TENANT_ID)
|
||||
app_model = _app_stub()
|
||||
result = service.check_dependencies(app_model=app_model)
|
||||
assert result.leaked_dependencies == []
|
||||
|
||||
@ -614,7 +631,7 @@ class TestAppDslService:
|
||||
)
|
||||
|
||||
service = AppDslService(db_session_with_containers)
|
||||
result = service.check_dependencies(app_model=SimpleNamespace(id=app_id, tenant_id=_DEFAULT_TENANT_ID))
|
||||
result = service.check_dependencies(app_model=_app_stub(id=app_id))
|
||||
assert len(result.leaked_dependencies) == 1
|
||||
|
||||
def test_check_dependencies_with_real_app(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
@ -656,9 +673,7 @@ class TestAppDslService:
|
||||
lambda _m: SimpleNamespace(kind="conv"),
|
||||
)
|
||||
|
||||
app = SimpleNamespace(
|
||||
id=str(uuid4()),
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
app = _app_stub(
|
||||
mode=AppMode.WORKFLOW.value,
|
||||
name="old",
|
||||
description="old-desc",
|
||||
@ -667,7 +682,6 @@ class TestAppDslService:
|
||||
icon_background="#111111",
|
||||
updated_by=None,
|
||||
updated_at=None,
|
||||
app_model_config=None,
|
||||
)
|
||||
service = AppDslService(db_session_with_containers)
|
||||
updated = service._create_or_update_app(
|
||||
@ -745,15 +759,7 @@ class TestAppDslService:
|
||||
service = AppDslService(db_session_with_containers)
|
||||
with pytest.raises(ValueError, match="Missing workflow data"):
|
||||
service._create_or_update_app(
|
||||
app=SimpleNamespace(
|
||||
id=str(uuid4()),
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
mode=AppMode.WORKFLOW.value,
|
||||
name="n",
|
||||
description="d",
|
||||
icon_background="#fff",
|
||||
app_model_config=None,
|
||||
),
|
||||
app=_app_stub(mode=AppMode.WORKFLOW.value),
|
||||
data={"app": {"mode": AppMode.WORKFLOW.value}},
|
||||
account=_account_mock(),
|
||||
)
|
||||
@ -762,15 +768,7 @@ class TestAppDslService:
|
||||
service = AppDslService(db_session_with_containers)
|
||||
with pytest.raises(ValueError, match="Missing model_config"):
|
||||
service._create_or_update_app(
|
||||
app=SimpleNamespace(
|
||||
id=str(uuid4()),
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
mode=AppMode.CHAT.value,
|
||||
name="n",
|
||||
description="d",
|
||||
icon_background="#fff",
|
||||
app_model_config=None,
|
||||
),
|
||||
app=_app_stub(mode=AppMode.CHAT.value),
|
||||
data={"app": {"mode": AppMode.CHAT.value}},
|
||||
account=_account_mock(),
|
||||
)
|
||||
@ -799,15 +797,7 @@ class TestAppDslService:
|
||||
service = AppDslService(db_session_with_containers)
|
||||
with pytest.raises(ValueError, match="Invalid app mode"):
|
||||
service._create_or_update_app(
|
||||
app=SimpleNamespace(
|
||||
id=str(uuid4()),
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
mode=AppMode.RAG_PIPELINE.value,
|
||||
name="n",
|
||||
description="d",
|
||||
icon_background="#fff",
|
||||
app_model_config=None,
|
||||
),
|
||||
app=_app_stub(mode=AppMode.RAG_PIPELINE.value),
|
||||
data={"app": {"mode": AppMode.RAG_PIPELINE.value}},
|
||||
account=_account_mock(),
|
||||
)
|
||||
@ -828,29 +818,16 @@ class TestAppDslService:
|
||||
lambda *_args, **_kwargs: model_calls.append(True),
|
||||
)
|
||||
|
||||
workflow_app = SimpleNamespace(
|
||||
workflow_app = _app_stub(
|
||||
mode=AppMode.WORKFLOW.value,
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
name="n",
|
||||
icon="i",
|
||||
icon_type="emoji",
|
||||
icon_background="#fff",
|
||||
description="d",
|
||||
use_icon_as_answer_icon=False,
|
||||
app_model_config=None,
|
||||
)
|
||||
AppDslService.export_dsl(workflow_app)
|
||||
assert workflow_calls == [True]
|
||||
|
||||
chat_app = SimpleNamespace(
|
||||
chat_app = _app_stub(
|
||||
mode=AppMode.CHAT.value,
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
name="n",
|
||||
icon="i",
|
||||
icon_type="emoji",
|
||||
icon_background="#fff",
|
||||
description="d",
|
||||
use_icon_as_answer_icon=False,
|
||||
app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}),
|
||||
)
|
||||
AppDslService.export_dsl(chat_app)
|
||||
@ -863,16 +840,14 @@ class TestAppDslService:
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
|
||||
emoji_app = SimpleNamespace(
|
||||
emoji_app = _app_stub(
|
||||
mode=AppMode.WORKFLOW.value,
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
name="Emoji App",
|
||||
icon="🎨",
|
||||
icon_type=IconType.EMOJI,
|
||||
icon_background="#FF5733",
|
||||
description="App with emoji icon",
|
||||
use_icon_as_answer_icon=True,
|
||||
app_model_config=None,
|
||||
)
|
||||
yaml_output = AppDslService.export_dsl(emoji_app)
|
||||
data = yaml.safe_load(yaml_output)
|
||||
@ -880,16 +855,14 @@ class TestAppDslService:
|
||||
assert data["app"]["icon_type"] == "emoji"
|
||||
assert data["app"]["icon_background"] == "#FF5733"
|
||||
|
||||
image_app = SimpleNamespace(
|
||||
image_app = _app_stub(
|
||||
mode=AppMode.WORKFLOW.value,
|
||||
tenant_id=_DEFAULT_TENANT_ID,
|
||||
name="Image App",
|
||||
icon="https://example.com/icon.png",
|
||||
icon_type=IconType.IMAGE,
|
||||
icon_background="#FFEAD5",
|
||||
description="App with image icon",
|
||||
use_icon_as_answer_icon=False,
|
||||
app_model_config=None,
|
||||
)
|
||||
yaml_output = AppDslService.export_dsl(image_app)
|
||||
data = yaml.safe_load(yaml_output)
|
||||
@ -1106,7 +1079,7 @@ class TestAppDslService:
|
||||
export_data: dict = {}
|
||||
AppDslService._append_workflow_export_data(
|
||||
export_data=export_data,
|
||||
app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID),
|
||||
app_model=_app_stub(),
|
||||
include_secret=False,
|
||||
workflow_id=None,
|
||||
)
|
||||
@ -1132,7 +1105,7 @@ class TestAppDslService:
|
||||
with pytest.raises(ValueError, match="Missing draft workflow configuration"):
|
||||
AppDslService._append_workflow_export_data(
|
||||
export_data={},
|
||||
app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID),
|
||||
app_model=_app_stub(),
|
||||
include_secret=False,
|
||||
workflow_id=None,
|
||||
)
|
||||
@ -1160,7 +1133,7 @@ class TestAppDslService:
|
||||
monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x)
|
||||
|
||||
app_model_config = SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": [{"credential_id": "secret"}]}})
|
||||
app_model = SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID, app_model_config=app_model_config)
|
||||
app_model = _app_stub(app_model_config=app_model_config)
|
||||
export_data: dict = {}
|
||||
|
||||
AppDslService._append_model_config_export_data(export_data, app_model)
|
||||
@ -1169,7 +1142,7 @@ class TestAppDslService:
|
||||
|
||||
def test_append_model_config_export_data_requires_app_config(self):
|
||||
with pytest.raises(ValueError, match="Missing app configuration"):
|
||||
AppDslService._append_model_config_export_data({}, SimpleNamespace(app_model_config=None))
|
||||
AppDslService._append_model_config_export_data({}, _app_stub(app_model_config=None))
|
||||
|
||||
# ── Dependency Extraction ─────────────────────────────────────────
|
||||
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -1289,7 +1289,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.13.3"
|
||||
version = "1.14.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -69,7 +69,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -115,7 +115,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -152,7 +152,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.3
|
||||
image: langgenius/dify-web:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@ -268,7 +268,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
image: langgenius/dify-sandbox:0.2.15
|
||||
restart: always
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
@ -292,7 +292,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.6.0-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@ -103,7 +103,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
image: langgenius/dify-sandbox:0.2.15
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
@ -129,7 +129,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.6.0-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
|
||||
@ -745,7 +745,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -793,7 +793,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -839,7 +839,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -876,7 +876,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.3
|
||||
image: langgenius/dify-web:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@ -992,7 +992,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
image: langgenius/dify-sandbox:0.2.15
|
||||
restart: always
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
@ -1016,7 +1016,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.6.0-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@ -1825,11 +1825,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": {
|
||||
"erasable-syntax-only/parameter-properties": {
|
||||
"count": 1
|
||||
@ -3203,24 +3198,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorize/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorized-in-node.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -3344,11 +3326,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -3820,21 +3797,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/workflow-tool/confirm-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/workflow-tool/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/workflow-tool/method-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow-app/components/workflow-children.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -10,7 +10,7 @@ export default function SignInLayout({ children }: any) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={
|
||||
cn(
|
||||
@ -20,7 +20,7 @@ export default function SignInLayout({ children }: any) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col md:w-[400px]">
|
||||
<div className="flex w-[400px] flex-col">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -14,10 +14,10 @@ export default function SignInLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
{/* <Header /> */}
|
||||
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
|
||||
<div className="flex w-full justify-center md:w-[440px] lg:w-[600px]">
|
||||
<div className="flex justify-center md:w-[440px] lg:w-[600px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -31,10 +31,10 @@ export default function SignInLayout({ children }: any) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
|
||||
<div className="flex w-full flex-col md:w-[400px]">
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
{isLoggedIn
|
||||
? (
|
||||
<AppContextProvider>
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
|
||||
import { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
@ -928,5 +929,46 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not hide the menu when focus moves into a variable child popup', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="/"
|
||||
workflowVariableBlock={makeWorkflowVariableBlock({}, [
|
||||
makeWorkflowVarNode('node-1', 'Node 1', [
|
||||
makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child', VarType.string)]),
|
||||
]),
|
||||
])}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '/', true)
|
||||
expect(await screen.findByText('payload')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
const popupTarget = document.createElement('button')
|
||||
const popup = document.createElement('div')
|
||||
popup.classList.add(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME)
|
||||
popup.appendChild(popupTarget)
|
||||
document.body.appendChild(popup)
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: popupTarget }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('payload')).toBeInTheDocument()
|
||||
|
||||
popup.remove()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../types'
|
||||
import type { PickerBlockMenuOption } from './menu'
|
||||
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||
import {
|
||||
flip,
|
||||
offset,
|
||||
@ -39,7 +40,7 @@ import {
|
||||
} from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
|
||||
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
import VarReferenceVars, { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
|
||||
import { $splitNodeContainingQuery } from '../../utils'
|
||||
@ -119,7 +120,9 @@ const ComponentPicker = ({
|
||||
(event) => {
|
||||
clearBlurTimer()
|
||||
const target = event?.relatedTarget as HTMLElement
|
||||
if (!target?.classList?.contains('var-search-input'))
|
||||
const isVariableMenuTarget = target?.classList?.contains('var-search-input')
|
||||
|| target?.closest?.(`.${VAR_REFERENCE_CHILD_POPUP_CLASS_NAME}`)
|
||||
if (!isVariableMenuTarget)
|
||||
blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200)
|
||||
return false
|
||||
},
|
||||
@ -143,8 +146,8 @@ const ComponentPicker = ({
|
||||
}
|
||||
}, [editor, clearBlurTimer])
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
|
||||
eventEmitter?.useSubscription((v: EventEmitterValue) => {
|
||||
if (typeof v !== 'string' && v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND && typeof v.payload === 'string')
|
||||
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
|
||||
})
|
||||
|
||||
@ -303,7 +306,7 @@ const ComponentPicker = ({
|
||||
}
|
||||
</>
|
||||
)
|
||||
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
|
||||
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, triggerString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { OAuthClientSettingsProps } from '../oauth-client-settings'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../../types'
|
||||
|
||||
const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' })
|
||||
const mockOpenOAuthPopup = vi.fn()
|
||||
const mockWriteText = vi.fn()
|
||||
const mockOAuthClientSettingsProps: OAuthClientSettingsProps[] = []
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj.en_US || '',
|
||||
@ -31,11 +35,37 @@ vi.mock('../../hooks/use-credential', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../oauth-client-settings', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="oauth-settings-modal">
|
||||
<button data-testid="oauth-settings-close" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
default: (props: OAuthClientSettingsProps) => {
|
||||
mockOAuthClientSettingsProps.push(props)
|
||||
const {
|
||||
open = true,
|
||||
onClose,
|
||||
onOpenChange,
|
||||
schemas,
|
||||
} = props
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange?.(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="oauth-settings-modal">
|
||||
<button data-testid="oauth-settings-close" onClick={handleClose}>Close</button>
|
||||
{schemas.map(schema => (
|
||||
<div key={schema.name} data-testid={`oauth-schema-${schema.name}`}>
|
||||
<div data-testid={`oauth-schema-label-${schema.name}`}>
|
||||
{React.isValidElement(schema.label) ? schema.label : String(schema.label || '')}
|
||||
</div>
|
||||
{String(schema.default || '')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/types', () => ({
|
||||
@ -56,6 +86,11 @@ describe('AddOAuthButton', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockOAuthClientSettingsProps.length = 0
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: mockWriteText },
|
||||
})
|
||||
const mod = await import('../add-oauth-button')
|
||||
AddOAuthButton = mod.default
|
||||
})
|
||||
@ -72,6 +107,7 @@ describe('AddOAuthButton', () => {
|
||||
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
||||
|
||||
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
|
||||
expect(mockOAuthClientSettingsProps.at(-1)?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('should close OAuth settings modal', () => {
|
||||
@ -84,13 +120,37 @@ describe('AddOAuthButton', () => {
|
||||
})
|
||||
|
||||
it('should trigger OAuth flow on main button click', async () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" onUpdate={mockOnUpdate} />)
|
||||
|
||||
const button = screen.getByText('Use OAuth').closest('button')
|
||||
if (button)
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenOAuthPopup).toHaveBeenCalledWith('https://auth.example.com', expect.any(Function))
|
||||
})
|
||||
|
||||
const handleOAuthSuccess = mockOpenOAuthPopup.mock.calls[0]?.[1]
|
||||
expect(handleOAuthSuccess).toBeTypeOf('function')
|
||||
if (typeof handleOAuthSuccess === 'function')
|
||||
handleOAuthSuccess()
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not open OAuth popup when authorization URL is missing', async () => {
|
||||
mockGetPluginOAuthUrl.mockResolvedValueOnce({})
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
|
||||
|
||||
const button = screen.getByText('Use OAuth').closest('button')
|
||||
if (button)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockOpenOAuthPopup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
@ -99,4 +159,96 @@ describe('AddOAuthButton', () => {
|
||||
const button = screen.getByText('Use OAuth').closest('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should open OAuth settings from setup entry when OAuth is not configured', () => {
|
||||
render(
|
||||
<AddOAuthButton
|
||||
pluginPayload={basePayload}
|
||||
oAuthData={{
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
is_system_oauth_params_exists: false,
|
||||
client_params: {},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
|
||||
|
||||
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
|
||||
expect(mockOAuthClientSettingsProps.at(-1)?.editValues).toMatchObject({
|
||||
__oauth_client__: 'custom',
|
||||
})
|
||||
})
|
||||
|
||||
it('should show custom badge when OAuth custom client is enabled', () => {
|
||||
render(
|
||||
<AddOAuthButton
|
||||
pluginPayload={basePayload}
|
||||
buttonText="Use OAuth"
|
||||
oAuthData={{
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: true,
|
||||
is_system_oauth_params_exists: true,
|
||||
client_params: {},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should build custom OAuth schema and edit values for settings modal', () => {
|
||||
const schema = [
|
||||
{
|
||||
name: 'client_id',
|
||||
label: { en_US: 'Client ID' },
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
default: 'schema-client-id',
|
||||
},
|
||||
] as FormSchema[]
|
||||
|
||||
render(
|
||||
<AddOAuthButton
|
||||
pluginPayload={basePayload}
|
||||
buttonText="Use OAuth"
|
||||
oAuthData={{
|
||||
schema,
|
||||
is_oauth_custom_client_enabled: true,
|
||||
is_system_oauth_params_exists: true,
|
||||
client_params: { client_id: 'stored-client-id' },
|
||||
redirect_uri: 'https://redirect.example.com',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
||||
|
||||
const settingsProps = mockOAuthClientSettingsProps.at(-1)
|
||||
expect(settingsProps?.editValues).toMatchObject({
|
||||
__oauth_client__: 'custom',
|
||||
client_id: 'stored-client-id',
|
||||
})
|
||||
expect(settingsProps?.hasOriginalClientParams).toBe(true)
|
||||
expect(settingsProps?.schemas[0]).toMatchObject({
|
||||
name: '__oauth_client__',
|
||||
default: 'custom',
|
||||
})
|
||||
expect(settingsProps?.schemas[1]).toMatchObject({
|
||||
name: 'client_id',
|
||||
default: 'stored-client-id',
|
||||
show_on: [
|
||||
{
|
||||
variable: '__oauth_client__',
|
||||
value: 'custom',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(screen.getByText('https://redirect.example.com')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('oauth-schema-label-client_id')).getByRole('button'))
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('https://redirect.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ApiKeyModalProps } from '../api-key-modal'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@ -384,6 +385,29 @@ describe('ApiKeyModal', () => {
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close on backdrop click when nested inside another dialog', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent backdropClassName="bg-transparent">
|
||||
<ControlledModalHarness ApiKeyModal={ApiKeyModal} onClose={mockOnClose} />
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
const backdrop = document.querySelector('.bg-background-overlay')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerDown(backdrop!)
|
||||
fireEvent.mouseDown(backdrop!)
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||
})
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render readme entrance when detail is provided', () => {
|
||||
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
||||
render(<ApiKeyModal pluginPayload={payload} />)
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import type { OAuthClientSettingsProps } from '../oauth-client-settings'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../../types'
|
||||
@ -20,7 +24,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
||||
const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
||||
const mockInvalidPluginOAuthClientSchema = vi.fn()
|
||||
const mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||
let mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||
let mockAuthFormProps: Record<string, unknown> | undefined
|
||||
|
||||
vi.mock('../../hooks/use-credential', () => ({
|
||||
useSetPluginOAuthCustomClientHook: () => ({
|
||||
@ -40,36 +45,19 @@ vi.mock('../../../readme-panel/store', () => ({
|
||||
ReadmeShowType: { modal: 'modal' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
default: ({ children, title, onClose: _onClose, onConfirm, onCancel, onExtraButtonClick, footerSlot }: {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
onExtraButtonClick?: () => void
|
||||
footerSlot?: React.ReactNode
|
||||
[key: string]: unknown
|
||||
}) => (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
<button data-testid="modal-confirm" onClick={onConfirm}>Save And Auth</button>
|
||||
<button data-testid="modal-cancel" onClick={onCancel}>Save Only</button>
|
||||
<button data-testid="modal-close" onClick={onExtraButtonClick}>Cancel</button>
|
||||
{!!footerSlot && <div data-testid="footer-slot">{footerSlot}</div>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
|
||||
default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
|
||||
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
|
||||
mockAuthFormProps = props
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getFormValues: () => mockFormValues,
|
||||
}))
|
||||
return <div data-testid="auth-form" />
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
default: MockAuthForm,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-form', () => ({
|
||||
useForm: (config: Record<string, unknown>) => ({
|
||||
@ -89,11 +77,72 @@ const defaultSchemas = [
|
||||
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true },
|
||||
] as never
|
||||
|
||||
const PopoverSettingsHarness = ({
|
||||
OAuthClientSettings,
|
||||
onClose,
|
||||
onPopoverClose,
|
||||
}: {
|
||||
OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||
onClose: () => void
|
||||
onPopoverClose: () => void
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen)
|
||||
onPopoverClose()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger render={<button type="button">OAuth</button>} />
|
||||
<PopoverContent>
|
||||
<div data-testid="oauth-popover">
|
||||
<OAuthClientSettings
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const ControlledSettingsHarness = ({
|
||||
OAuthClientSettings,
|
||||
onClose,
|
||||
}: {
|
||||
OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-testid="modal-open-state">{String(open)}</div>
|
||||
<OAuthClientSettings
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('OAuthClientSettings', () => {
|
||||
let OAuthClientSettings: (typeof import('../oauth-client-settings'))['default']
|
||||
let OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||
mockAuthFormProps = undefined
|
||||
const mod = await import('../oauth-client-settings')
|
||||
OAuthClientSettings = mod.default
|
||||
})
|
||||
@ -120,6 +169,36 @@ describe('OAuthClientSettings', () => {
|
||||
expect(screen.getByTestId('auth-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render backdrop when nested inside another dialog', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent backdropClassName="bg-transparent">
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
expect(document.querySelector('.bg-background-overlay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass schema defaults to auth form', () => {
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={[
|
||||
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true, default: 'default-client-id' },
|
||||
] as never}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockAuthFormProps?.defaultValues).toMatchObject({
|
||||
client_id: 'default-client-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClose when cancel clicked', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(
|
||||
@ -134,6 +213,33 @@ describe('OAuthClientSettings', () => {
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close through controlled open state when cancel clicked', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-close'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||
})
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close when backdrop is clicked', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
|
||||
|
||||
const backdrop = document.querySelector('.bg-background-overlay')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||
})
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save settings on save only button click', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpdate = vi.fn()
|
||||
@ -155,6 +261,38 @@ describe('OAuthClientSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore duplicate save clicks while action is pending', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
let resolveSave: (value: object) => void = () => {}
|
||||
mockSetPluginOAuthCustomClient.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
}))
|
||||
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSave({})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should save and authorize on confirm button click', async () => {
|
||||
const mockOnAuth = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
@ -172,6 +310,34 @@ describe('OAuthClientSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove custom client settings', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
editValues={{ client_id: 'test-id' }}
|
||||
hasOriginalClientParams
|
||||
onClose={mockOnClose}
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-extra'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
expect(mockInvalidPluginOAuthClientSchema).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render readme entrance when detail is provided', () => {
|
||||
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
||||
render(
|
||||
@ -183,4 +349,26 @@ describe('OAuthClientSettings', () => {
|
||||
|
||||
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stay open when clicking inside the modal from a popover', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnPopoverClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PopoverSettingsHarness
|
||||
OAuthClientSettings={OAuthClientSettings}
|
||||
onClose={mockOnClose}
|
||||
onPopoverClose={mockOnPopoverClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
const form = await screen.findByTestId('auth-form')
|
||||
|
||||
await user.click(form)
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
expect(mockOnPopoverClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,11 +3,6 @@ import type { PluginPayload } from '../types'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -40,10 +35,12 @@ export type AddOAuthButtonProps = {
|
||||
schema?: FormSchema[]
|
||||
is_oauth_custom_client_enabled?: boolean
|
||||
is_system_oauth_params_exists?: boolean
|
||||
client_params?: Record<string, any>
|
||||
client_params?: Record<string, unknown>
|
||||
redirect_uri?: string
|
||||
}
|
||||
}
|
||||
type OAuthData = NonNullable<AddOAuthButtonProps['oAuthData']>
|
||||
|
||||
const AddOAuthButton = ({
|
||||
pluginPayload,
|
||||
buttonVariant = 'primary',
|
||||
@ -59,22 +56,27 @@ const AddOAuthButton = ({
|
||||
const { t } = useTranslation()
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
|
||||
const [isOAuthSettingsMounted, setIsOAuthSettingsMounted] = useState(false)
|
||||
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
|
||||
const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload)
|
||||
const mergedOAuthData = useMemo(() => {
|
||||
const mergedOAuthData = useMemo<OAuthData>(() => {
|
||||
if (oAuthData)
|
||||
return oAuthData
|
||||
|
||||
return data
|
||||
return data || {}
|
||||
}, [oAuthData, data])
|
||||
const {
|
||||
schema = [],
|
||||
is_oauth_custom_client_enabled,
|
||||
is_system_oauth_params_exists,
|
||||
client_params,
|
||||
client_params = {},
|
||||
redirect_uri,
|
||||
} = mergedOAuthData as any || {}
|
||||
} = mergedOAuthData
|
||||
const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled
|
||||
const openOAuthSettings = useCallback(() => {
|
||||
setIsOAuthSettingsMounted(true)
|
||||
setIsOAuthSettingsOpen(true)
|
||||
}, [])
|
||||
const handleOAuth = useCallback(async () => {
|
||||
const { authorization_url } = await getPluginOAuthUrl()
|
||||
|
||||
@ -91,7 +93,7 @@ const AddOAuthButton = ({
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex rounded-xl bg-background-section-burn p-4">
|
||||
<div className="mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg">
|
||||
<RiInformation2Fill className="h-5 w-5 text-text-accent" />
|
||||
<span className="i-ri-information-2-fill h-5 w-5 text-text-accent" />
|
||||
</div>
|
||||
<div className="w-0 grow">
|
||||
<div className="mb-1.5 system-sm-regular">
|
||||
@ -107,7 +109,7 @@ const AddOAuthButton = ({
|
||||
navigator.clipboard.writeText(redirect_uri || '')
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
<span className="i-ri-clipboard-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
@ -232,10 +234,10 @@ const AddOAuthButton = ({
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsOAuthSettingsOpen(true)
|
||||
openOAuthSettings()
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className="h-4 w-4" />
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4" />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
@ -244,18 +246,20 @@ const AddOAuthButton = ({
|
||||
!isConfigured && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
onClick={() => setIsOAuthSettingsOpen(true)}
|
||||
onClick={openOAuthSettings}
|
||||
disabled={disabled}
|
||||
className="w-full"
|
||||
>
|
||||
<RiEqualizer2Line className="mr-0.5 h-4 w-4" />
|
||||
<span className="mr-0.5 i-ri-equalizer-2-line h-4 w-4" />
|
||||
{t('auth.setupOAuth', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{
|
||||
isOAuthSettingsOpen && (
|
||||
isOAuthSettingsMounted && (
|
||||
<OAuthClientSettings
|
||||
open={isOAuthSettingsOpen}
|
||||
onOpenChange={setIsOAuthSettingsOpen}
|
||||
pluginPayload={pluginPayload}
|
||||
onClose={() => setIsOAuthSettingsOpen(false)}
|
||||
disabled={disabled || isLoading}
|
||||
|
||||
@ -140,7 +140,10 @@ const ApiKeyModal = ({
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DialogContent className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!">
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!"
|
||||
>
|
||||
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
|
||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
|
||||
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
useForm,
|
||||
@ -17,7 +18,6 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import {
|
||||
@ -26,10 +26,12 @@ import {
|
||||
useSetPluginOAuthCustomClientHook,
|
||||
} from '../hooks/use-credential'
|
||||
|
||||
type OAuthClientSettingsProps = {
|
||||
export type OAuthClientSettingsProps = {
|
||||
pluginPayload: PluginPayload
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onClose?: () => void
|
||||
editValues?: Record<string, any>
|
||||
editValues?: Record<string, unknown>
|
||||
disabled?: boolean
|
||||
schemas: FormSchema[]
|
||||
onAuth?: () => Promise<void>
|
||||
@ -38,6 +40,8 @@ type OAuthClientSettingsProps = {
|
||||
}
|
||||
const OAuthClientSettings = ({
|
||||
pluginPayload,
|
||||
open = true,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
editValues,
|
||||
disabled,
|
||||
@ -53,11 +57,16 @@ const OAuthClientSettings = ({
|
||||
doingActionRef.current = value
|
||||
setDoingAction(value)
|
||||
}, [])
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
onOpenChange?.(nextOpen)
|
||||
if (!nextOpen)
|
||||
onClose?.()
|
||||
}, [onClose, onOpenChange])
|
||||
const defaultValues = schemas.reduce((acc, schema) => {
|
||||
if (schema.default)
|
||||
acc[schema.name] = schema.default
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
}, {} as Record<string, unknown>)
|
||||
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
|
||||
const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload)
|
||||
const formRef = useRef<FormRefObject>(null)
|
||||
@ -87,6 +96,7 @@ const OAuthClientSettings = ({
|
||||
})
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
|
||||
onOpenChange?.(false)
|
||||
onClose?.()
|
||||
onUpdate?.()
|
||||
invalidPluginOAuthClientSchema()
|
||||
@ -94,7 +104,7 @@ const OAuthClientSettings = ({
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction])
|
||||
}, [onClose, onOpenChange, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction])
|
||||
|
||||
const handleConfirmAndAuthorize = useCallback(async () => {
|
||||
await handleConfirm()
|
||||
@ -110,6 +120,7 @@ const OAuthClientSettings = ({
|
||||
handleSetDoingAction(true)
|
||||
await deletePluginOAuthCustomClient()
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
onOpenChange?.(false)
|
||||
onClose?.()
|
||||
onUpdate?.()
|
||||
invalidPluginOAuthClientSchema()
|
||||
@ -117,53 +128,89 @@ const OAuthClientSettings = ({
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose])
|
||||
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose, onOpenChange])
|
||||
const form = useForm({
|
||||
defaultValues: editValues || defaultValues,
|
||||
})
|
||||
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
|
||||
const isDisabled = disabled || doingAction
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('auth.oauthClientSettings', { ns: 'plugin' })}
|
||||
confirmButtonText={t('auth.saveAndAuth', { ns: 'plugin' })}
|
||||
cancelButtonText={t('auth.saveOnly', { ns: 'plugin' })}
|
||||
extraButtonText={t('operation.cancel', { ns: 'common' })}
|
||||
showExtraButton
|
||||
extraButtonVariant="secondary"
|
||||
onExtraButtonClick={onClose}
|
||||
onClose={onClose}
|
||||
onCancel={handleConfirm}
|
||||
onConfirm={handleConfirmAndAuthorize}
|
||||
disabled={disabled || doingAction}
|
||||
footerSlot={
|
||||
__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
||||
<div className="grow">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-components-button-destructive-secondary-text"
|
||||
disabled={disabled || doingAction || !editValues}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
containerClassName="pt-0"
|
||||
wrapperClassName="z-1002!"
|
||||
clickOutsideNotClose={true}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<AuthForm
|
||||
formFromProps={form}
|
||||
ref={formRef}
|
||||
formSchemas={schemas}
|
||||
defaultValues={editValues || defaultValues}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Modal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[480px]! max-w-[calc(100vw-2rem)]! p-0!"
|
||||
>
|
||||
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
|
||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
|
||||
{t('auth.oauthClientSettings', { ns: 'plugin' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton
|
||||
data-testid="modal-x-close"
|
||||
className="top-5 right-5 h-8 w-8 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<AuthForm
|
||||
formFromProps={form}
|
||||
ref={formRef}
|
||||
formSchemas={schemas}
|
||||
defaultValues={editValues || defaultValues}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-between p-6 pt-5">
|
||||
<div>
|
||||
{__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
||||
<Button
|
||||
data-testid="modal-extra"
|
||||
variant="secondary"
|
||||
className="text-components-button-destructive-secondary-text"
|
||||
disabled={isDisabled || !editValues}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
data-testid="modal-close"
|
||||
variant="secondary"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
|
||||
<Button
|
||||
data-testid="modal-cancel"
|
||||
onClick={handleConfirm}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t('auth.saveOnly', { ns: 'plugin' })}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="modal-confirm"
|
||||
className="ml-2"
|
||||
variant="primary"
|
||||
onClick={handleConfirmAndAuthorize}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t('auth.saveAndAuth', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type * as React from 'react'
|
||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
@ -134,36 +134,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
default: ({
|
||||
children,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
confirmButtonText,
|
||||
bottomSlot,
|
||||
size,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
confirmButtonText: string
|
||||
bottomSlot?: React.ReactNode
|
||||
size?: string
|
||||
disabled?: boolean
|
||||
}) => (
|
||||
<div data-testid="modal" data-size={size} data-disabled={disabled}>
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
<div data-testid="modal-bottom-slot">{bottomSlot}</div>
|
||||
<button data-testid="modal-confirm" onClick={onConfirm} disabled={disabled}>{confirmButtonText}</button>
|
||||
<button data-testid="modal-close" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
type MockFormValuesConfig = {
|
||||
values: Record<string, unknown>
|
||||
isCheckValidated: boolean
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
'use client'
|
||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import {
|
||||
ConfigurationStepContent,
|
||||
@ -48,46 +55,93 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
|
||||
const isApiKeyType = createType === SupportedCreationMethods.APIKEY
|
||||
const isVerifyStep = currentStep === ApiKeyStep.Verify
|
||||
const isConfigurationStep = currentStep === ApiKeyStep.Configuration
|
||||
const isDisabled = isVerifyingCredentials || isBuilding
|
||||
const modalSize = createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t(MODAL_TITLE_KEY_MAP[createType], { ns: 'pluginTrigger' })}
|
||||
confirmButtonText={confirmButtonText}
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
disabled={isVerifyingCredentials || isBuilding}
|
||||
bottomSlot={isVerifyStep ? <EncryptedBottom /> : null}
|
||||
size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'}
|
||||
containerClassName="min-h-[360px]"
|
||||
clickOutsideNotClose
|
||||
>
|
||||
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
|
||||
<Dialog open disablePointerDismissal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className={cn(
|
||||
'flex max-h-[80%] min-h-[360px] flex-col overflow-hidden p-0 shadow-xs',
|
||||
modalSize === 'md'
|
||||
? 'w-[640px] max-w-[calc(100vw-2rem)]'
|
||||
: 'w-[480px] max-w-[calc(100vw-2rem)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="modal"
|
||||
data-size={modalSize}
|
||||
data-disabled={isDisabled}
|
||||
>
|
||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary" data-testid="modal-title">
|
||||
{t(MODAL_TITLE_KEY_MAP[createType], { ns: 'pluginTrigger' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton
|
||||
className="top-5 right-5 h-8 w-8 rounded-lg [&>span]:h-5 [&>span]:w-5"
|
||||
data-testid="modal-close"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isVerifyStep && (
|
||||
<VerifyStepContent
|
||||
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
|
||||
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
|
||||
onChange={handleApiKeyCredentialsChange}
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
|
||||
|
||||
{isConfigurationStep && (
|
||||
<ConfigurationStepContent
|
||||
createType={createType}
|
||||
subscriptionBuilder={subscriptionBuilder}
|
||||
subscriptionFormRef={formRefs.subscriptionFormRef}
|
||||
autoCommonParametersSchema={autoCommonParametersSchema}
|
||||
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
|
||||
manualPropertiesSchema={manualPropertiesSchema}
|
||||
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
|
||||
onManualPropertiesChange={handleManualPropertiesChange}
|
||||
logs={logData?.logs || []}
|
||||
pluginId={detail?.plugin_id || ''}
|
||||
pluginName={detail?.name || ''}
|
||||
provider={detail?.provider || ''}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
{isVerifyStep && (
|
||||
<VerifyStepContent
|
||||
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
|
||||
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
|
||||
onChange={handleApiKeyCredentialsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isConfigurationStep && (
|
||||
<ConfigurationStepContent
|
||||
createType={createType}
|
||||
subscriptionBuilder={subscriptionBuilder}
|
||||
subscriptionFormRef={formRefs.subscriptionFormRef}
|
||||
autoCommonParametersSchema={autoCommonParametersSchema}
|
||||
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
|
||||
manualPropertiesSchema={manualPropertiesSchema}
|
||||
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
|
||||
onManualPropertiesChange={handleManualPropertiesChange}
|
||||
logs={logData?.logs || []}
|
||||
pluginId={detail?.plugin_id || ''}
|
||||
pluginName={detail?.name || ''}
|
||||
provider={detail?.provider || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end p-6 pt-5">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-2"
|
||||
variant="primary"
|
||||
disabled={isDisabled}
|
||||
data-testid="modal-confirm"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isVerifyStep && (
|
||||
<div className="shrink-0">
|
||||
<EncryptedBottom />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -330,6 +330,27 @@ describe('publisher', () => {
|
||||
})
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep confirm dialog mounted when first publish opens follow-up overlay', async () => {
|
||||
mockPublishedAt.mockReturnValue(null)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow.common.publishUpdate/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
|
||||
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -13,13 +15,19 @@ import Popup from './popup'
|
||||
const Publisher = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (!newOpen && confirmVisible)
|
||||
return
|
||||
if (newOpen)
|
||||
handleSyncWorkflowDraft(true)
|
||||
setOpen(newOpen)
|
||||
}, [handleSyncWorkflowDraft])
|
||||
}, [confirmVisible, handleSyncWorkflowDraft])
|
||||
const closePopover = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@ -42,9 +50,14 @@ const Publisher = () => {
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={40}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
|
||||
>
|
||||
<Popup onRequestClose={() => handleOpenChange(false)} />
|
||||
<Popup
|
||||
onRequestClose={closePopover}
|
||||
confirmVisible={confirmVisible}
|
||||
onShowConfirm={showConfirm}
|
||||
onHideConfirm={hideConfirm}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@ -41,9 +41,17 @@ import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
type PopupProps = {
|
||||
onRequestClose?: () => void
|
||||
confirmVisible?: boolean
|
||||
onShowConfirm?: () => void
|
||||
onHideConfirm?: () => void
|
||||
}
|
||||
|
||||
const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
const Popup = ({
|
||||
onRequestClose,
|
||||
confirmVisible: controlledConfirmVisible,
|
||||
onShowConfirm,
|
||||
onHideConfirm,
|
||||
}: PopupProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { datasetId } = useParams()
|
||||
const { push } = useRouter()
|
||||
@ -60,24 +68,32 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector(s => s.isAllowPublishAsCustomKnowledgePipelineTemplate)
|
||||
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
|
||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
|
||||
const [localConfirmVisible, { setFalse: hideLocalConfirm, setTrue: showLocalConfirm }] = useBoolean(false)
|
||||
const confirmVisible = controlledConfirmVisible ?? localConfirmVisible
|
||||
const showConfirm = onShowConfirm ?? showLocalConfirm
|
||||
const hideConfirm = onHideConfirm ?? hideLocalConfirm
|
||||
const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false)
|
||||
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
|
||||
const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false)
|
||||
const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false)
|
||||
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
const handleHideConfirm = useCallback(() => {
|
||||
hideConfirm()
|
||||
onRequestClose?.()
|
||||
}, [hideConfirm, onRequestClose])
|
||||
const handlePublish = useCallback(async (params?: PublishWorkflowParams) => {
|
||||
if (publishing)
|
||||
return
|
||||
let startedPublishing = false
|
||||
try {
|
||||
const checked = await handleCheckBeforePublish()
|
||||
if (checked) {
|
||||
if (!publishedAt && !confirmVisible) {
|
||||
onRequestClose?.()
|
||||
showConfirm()
|
||||
return
|
||||
}
|
||||
startedPublishing = true
|
||||
showPublishing()
|
||||
const res = await publishWorkflow({
|
||||
url: `/rag/pipelines/${pipelineId}/workflows/publish`,
|
||||
@ -114,12 +130,12 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
toast.error(t('publishPipeline.error.message', { ns: 'datasetPipeline' }))
|
||||
}
|
||||
finally {
|
||||
if (publishing)
|
||||
if (startedPublishing)
|
||||
hidePublishing()
|
||||
if (confirmVisible)
|
||||
hideConfirm()
|
||||
handleHideConfirm()
|
||||
}
|
||||
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm, onRequestClose])
|
||||
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, handleHideConfirm])
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
e.preventDefault()
|
||||
if (published)
|
||||
@ -163,10 +179,12 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
|
||||
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
||||
onRequestClose?.()
|
||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
|
||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
|
||||
setShowPricingModal()
|
||||
else
|
||||
}
|
||||
else {
|
||||
setShowPublishAsKnowledgePipelineModal()
|
||||
}
|
||||
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
||||
@ -238,7 +256,7 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDialog open={confirmVisible} onOpenChange={open => !open && hideConfirm()}>
|
||||
<AlertDialog open={confirmVisible} onOpenChange={open => !open && handleHideConfirm()}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle
|
||||
|
||||
@ -12,6 +12,7 @@ const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockInvalidateMCPTools = vi.fn()
|
||||
const mockInvalidateAllMCPTools = vi.fn()
|
||||
const mockOpenOAuthPopup = vi.fn()
|
||||
|
||||
// Mutable mock state
|
||||
@ -33,6 +34,7 @@ vi.mock('@/service/use-tools', () => ({
|
||||
isFetching: mockIsFetching,
|
||||
}),
|
||||
useInvalidateMCPTools: () => mockInvalidateMCPTools,
|
||||
useInvalidateAllMCPTools: () => mockInvalidateAllMCPTools,
|
||||
useUpdateMCPTools: () => ({
|
||||
mutateAsync: mockUpdateTools,
|
||||
isPending: mockIsUpdating,
|
||||
@ -180,6 +182,7 @@ describe('MCPDetailContent', () => {
|
||||
mockUpdateMCP.mockClear()
|
||||
mockDeleteMCP.mockClear()
|
||||
mockInvalidateMCPTools.mockClear()
|
||||
mockInvalidateAllMCPTools.mockClear()
|
||||
mockOpenOAuthPopup.mockClear()
|
||||
|
||||
// Reset mock return values
|
||||
@ -513,6 +516,7 @@ describe('MCPDetailContent', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
|
||||
expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1')
|
||||
expect(mockInvalidateAllMCPTools).toHaveBeenCalled()
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -530,6 +534,7 @@ describe('MCPDetailContent', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
|
||||
expect(mockInvalidateAllMCPTools).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -26,6 +26,7 @@ import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import {
|
||||
useAuthorizeMCP,
|
||||
useDeleteMCP,
|
||||
useInvalidateAllMCPTools,
|
||||
useInvalidateMCPTools,
|
||||
useMCPTools,
|
||||
useUpdateMCP,
|
||||
@ -61,6 +62,7 @@ const MCPDetailContent: FC<Props> = ({
|
||||
|
||||
const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '')
|
||||
const invalidateMCPTools = useInvalidateMCPTools()
|
||||
const invalidateAllMCPTools = useInvalidateAllMCPTools()
|
||||
const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools()
|
||||
const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP()
|
||||
const toolList = data?.tools || []
|
||||
@ -76,8 +78,9 @@ const MCPDetailContent: FC<Props> = ({
|
||||
return
|
||||
await updateTools(detail.id)
|
||||
invalidateMCPTools(detail.id)
|
||||
invalidateAllMCPTools()
|
||||
onUpdate()
|
||||
}, [detail, hideUpdateConfirm, invalidateMCPTools, onUpdate, updateTools])
|
||||
}, [detail, hideUpdateConfirm, invalidateAllMCPTools, invalidateMCPTools, onUpdate, updateTools])
|
||||
|
||||
const { mutateAsync: updateMCP } = useUpdateMCP({})
|
||||
const { mutateAsync: deleteMCP } = useDeleteMCP({})
|
||||
|
||||
@ -9,6 +9,8 @@ import WorkflowToolConfigureButton from '../configure-button'
|
||||
import WorkflowToolAsModal from '../index'
|
||||
import MethodSelector from '../method-selector'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
// Mock Next.js navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@ -83,12 +85,11 @@ vi.mock('@/app/components/base/drawer-plus', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock EmojiPicker - simplified for testing
|
||||
vi.mock('@/app/components/base/emoji-picker', () => ({
|
||||
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
|
||||
// Mock EmojiPickerInner - simplified for testing
|
||||
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
|
||||
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
|
||||
<div data-testid="emoji-picker">
|
||||
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#f0f0f0')}>Select Emoji</button>
|
||||
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -978,6 +979,7 @@ describe('WorkflowToolAsModal', () => {
|
||||
|
||||
// Select emoji
|
||||
await user.click(screen.getByTestId('select-emoji'))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
|
||||
// Assert
|
||||
const updatedIcon = screen.getByTestId('app-icon')
|
||||
@ -1002,7 +1004,7 @@ describe('WorkflowToolAsModal', () => {
|
||||
|
||||
expect(screen.getByTestId('emoji-picker'))!.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('close-emoji-picker'))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
@ -1501,7 +1503,7 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display parameter method text when value is llm', () => {
|
||||
@ -1562,11 +1564,11 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with llm when parameter option clicked', async () => {
|
||||
@ -1580,7 +1582,7 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
const paramOption = screen.getAllByText('tools.createTool.toolInput.methodParameter')[0]
|
||||
await user.click(paramOption!)
|
||||
@ -1600,7 +1602,7 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
const settingOption = screen.getByText('tools.createTool.toolInput.methodSetting')
|
||||
await user.click(settingOption)
|
||||
@ -1621,12 +1623,12 @@ describe('MethodSelector', () => {
|
||||
render(<MethodSelector {...props} />)
|
||||
|
||||
// First click - open
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Second click - close
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1642,10 +1644,10 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
// Assert - the first option (llm) should have a check icon container
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const content = screen.getByTestId('popover-content')
|
||||
expect(content)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1659,10 +1661,10 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
// Assert
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const content = screen.getByTestId('popover-content')
|
||||
expect(content)!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -18,11 +18,10 @@ vi.mock('@/app/components/base/drawer-plus', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/emoji-picker', () => ({
|
||||
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
|
||||
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
|
||||
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
|
||||
<div data-testid="emoji-picker">
|
||||
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#000000')}>Emoji</button>
|
||||
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -129,6 +128,7 @@ describe('WorkflowToolAsModal', () => {
|
||||
await user.click(screen.getByTestId('append-label'))
|
||||
await user.click(screen.getByTestId('app-icon'))
|
||||
await user.click(screen.getByTestId('select-emoji'))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@ -195,6 +195,6 @@ describe('WorkflowToolAsModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('tools.createTool.toolOutput.reservedParameterDuplicateTip').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByTestId('reserved-output-warning').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MethodSelector from '../method-selector'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
// Test utilities
|
||||
const defaultProps: ComponentProps<typeof MethodSelector> = {
|
||||
value: 'llm',
|
||||
@ -139,6 +141,24 @@ describe('MethodSelector', () => {
|
||||
expect(onChange).toHaveBeenCalledWith('form')
|
||||
})
|
||||
|
||||
it('should close dropdown after an option is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'llm' })
|
||||
|
||||
const trigger = screen.getByText('tools.createTool.toolInput.methodParameter')
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tools.createTool.toolInput.methodSettingTip'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('tools.createTool.toolInput.methodSettingTip'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('tools.createTool.toolInput.methodSettingTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle dropdown open state', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
@ -235,10 +255,9 @@ describe('MethodSelector', () => {
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
|
||||
const dropdown = document.querySelector('.w-\\[320px\\]')
|
||||
expect(dropdown)!.toBeInTheDocument()
|
||||
expect(dropdown)!.toHaveClass('rounded-lg')
|
||||
expect(dropdown)!.toHaveClass('shadow-lg')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -93,13 +93,12 @@ describe('ConfirmModal', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert - Check for the dialog panel with modal content
|
||||
// The real modal structure has nested divs, we need to find the one with our classes
|
||||
const dialogContent = document.querySelector('.relative.rounded-2xl')
|
||||
// Assert
|
||||
const dialogContent = screen.getByRole('dialog')
|
||||
expect(dialogContent).toBeInTheDocument()
|
||||
expect(dialogContent).toHaveClass('w-[600px]')
|
||||
expect(dialogContent).toHaveClass('max-w-[600px]')
|
||||
expect(dialogContent).toHaveClass('p-8')
|
||||
expect(dialogContent).toHaveClass('w-[600px]!')
|
||||
expect(dialogContent).toHaveClass('max-w-[600px]!')
|
||||
expect(dialogContent).toHaveClass('p-8!')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -2,11 +2,9 @@
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
|
||||
type ConfirmModalProps = {
|
||||
show: boolean
|
||||
@ -18,28 +16,29 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={cn('w-[600px] max-w-[600px] p-8')}
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl">
|
||||
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
|
||||
</div>
|
||||
<div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('createTool.confirmTitle', { ns: 'tools' })}</div>
|
||||
<div className="my-1 text-sm leading-5 text-text-tertiary">
|
||||
{t('createTool.confirmTip', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="flex items-center justify-end pt-6">
|
||||
<div className="flex items-center">
|
||||
<Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button variant="primary" tone="destructive" onClick={onConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
|
||||
<Dialog open={show} disablePointerDismissal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className={cn('w-[600px]! max-w-[600px]! p-8!')}
|
||||
>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl">
|
||||
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
|
||||
</div>
|
||||
<DialogTitle className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('createTool.confirmTitle', { ns: 'tools' })}</DialogTitle>
|
||||
<div className="my-1 text-sm leading-5 text-text-tertiary">
|
||||
{t('createTool.confirmTip', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="flex items-center justify-end pt-6">
|
||||
<div className="flex items-center">
|
||||
<Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button variant="primary" tone="destructive" onClick={onConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -437,7 +437,6 @@ describe('useConfigureButton', () => {
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@ -206,7 +206,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
onRefreshData?.()
|
||||
invalidateAllWorkflowTools()
|
||||
invalidateDetail(workflowAppId)
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@ -3,18 +3,18 @@ import type { FC } from 'react'
|
||||
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiErrorWarningLine } from '@remixicon/react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Drawer from '@/app/components/base/drawer-plus'
|
||||
import EmojiPicker from '@/app/components/base/emoji-picker'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import LabelSelector from '@/app/components/tools/labels/selector'
|
||||
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
|
||||
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
|
||||
@ -53,6 +53,111 @@ type Props = {
|
||||
workflow_tool_id: string
|
||||
}>) => void
|
||||
}
|
||||
|
||||
type WorkflowToolDrawerProps = {
|
||||
title: string
|
||||
onHide: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const InfoTooltip = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="i-ri-question-line h-3.5 w-3.5 shrink-0 cursor-help text-text-quaternary hover:text-text-tertiary" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="w-[180px]">
|
||||
{children}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => {
|
||||
return (
|
||||
<Dialog open disablePointerDismissal>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'top-2 right-2 bottom-2 left-auto h-[calc(100dvh-16px)] max-h-[calc(100dvh-16px)] w-[640px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! overflow-hidden rounded-xl border-none bg-transparent p-0 shadow-none',
|
||||
'data-ending-style:translate-x-4 data-ending-style:scale-100 data-starting-style:translate-x-4 data-starting-style:scale-100',
|
||||
)}
|
||||
backdropClassName="bg-background-overlay"
|
||||
>
|
||||
<div data-testid="drawer" className="flex h-full w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl">
|
||||
<div className="shrink-0 border-b border-divider-subtle py-4">
|
||||
<div className="flex h-6 items-center justify-between pr-5 pl-6">
|
||||
<DialogTitle data-testid="drawer-title" className="system-xl-semibold text-text-primary">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="drawer-close"
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover"
|
||||
aria-label="Close"
|
||||
onClick={onHide}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type WorkflowToolEmojiPickerProps = {
|
||||
onSelect: (icon: string, background: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedEmoji, setSelectedEmoji] = useState('')
|
||||
const [selectedBackground, setSelectedBackground] = useState<string>()
|
||||
|
||||
return (
|
||||
<Dialog open disablePointerDismissal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="flex max-h-[552px] w-[480px]! flex-col overflow-hidden rounded-xl border-[0.5px] border-divider-subtle p-0! shadow-xl"
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{t('iconPicker.emoji', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<EmojiPickerInner
|
||||
className="pt-3"
|
||||
onSelect={(emoji, background) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setSelectedBackground(background)
|
||||
}}
|
||||
/>
|
||||
<Divider className="mt-3 mb-0" />
|
||||
<div className="flex w-full items-center justify-center gap-2 p-3">
|
||||
<Button className="w-full" onClick={onClose}>
|
||||
{t('iconPicker.cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectedEmoji === '' || !selectedBackground}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => onSelect(selectedEmoji, selectedBackground!)}
|
||||
>
|
||||
{t('iconPicker.ok', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Add and Edit
|
||||
const WorkflowToolAsModal: FC<Props> = ({
|
||||
isAdd,
|
||||
@ -138,210 +243,201 @@ const WorkflowToolAsModal: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
isShow
|
||||
<WorkflowToolDrawer
|
||||
onHide={onHide}
|
||||
title={t('common.workflowAsTool', { ns: 'workflow' })!}
|
||||
panelClassName="mt-2 w-[640px]!"
|
||||
maxWidthClassName="max-w-[640px]!"
|
||||
height="calc(100vh - 16px)"
|
||||
headerClassName="!border-b-divider"
|
||||
body={(
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
|
||||
{/* name & icon */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.name', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
|
||||
<Input
|
||||
className="h-10 grow"
|
||||
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
|
||||
{/* name & icon */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.name', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</div>
|
||||
{/* name for tool call */}
|
||||
<div>
|
||||
<div className="flex items-center py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.nameForToolCall', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
|
||||
<Input
|
||||
className="h-10"
|
||||
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
{!isWorkflowToolNameValid(name) && (
|
||||
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* description */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
|
||||
<Textarea
|
||||
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Tool Input */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.title', { ns: 'tools' })}</div>
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
|
||||
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
|
||||
<thead className="text-text-tertiary uppercase">
|
||||
<tr className="border-b border-divider-regular">
|
||||
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.toolInput.name', { ns: 'tools' })}</th>
|
||||
<th className="w-[102px] p-2 pl-3 font-medium">{t('createTool.toolInput.method', { ns: 'tools' })}</th>
|
||||
<th className="p-2 pl-3 font-medium">{t('createTool.toolInput.description', { ns: 'tools' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parameters.map((item, index) => (
|
||||
<tr key={index} className="border-b border-divider-regular last:border-0">
|
||||
<td className="max-w-[156px] p-2 pl-3">
|
||||
<div className="text-[13px] leading-[18px]">
|
||||
<div title={item.name} className="flex">
|
||||
<span className="truncate font-medium text-text-primary">{item.name}</span>
|
||||
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span>
|
||||
</div>
|
||||
<div className="text-text-tertiary">{item.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.name === '__image' && (
|
||||
<div className={cn(
|
||||
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
|
||||
)}
|
||||
>
|
||||
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
|
||||
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.name !== '__image' && (
|
||||
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full appearance-none bg-transparent text-[13px] leading-[18px] font-normal text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary"
|
||||
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
|
||||
value={item.description}
|
||||
onChange={e => handleParameterChange('description', e.target.value, index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tool Output */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolOutput.title', { ns: 'tools' })}</div>
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
|
||||
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
|
||||
<thead className="text-text-tertiary uppercase">
|
||||
<tr className="border-b border-divider-regular">
|
||||
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.name', { ns: 'tools' })}</th>
|
||||
<th className="p-2 pl-3 font-medium">{t('createTool.toolOutput.description', { ns: 'tools' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...reservedOutputParameters, ...outputParameters].map((item, index) => (
|
||||
<tr key={index} className="border-b border-divider-regular last:border-0">
|
||||
<td className="max-w-[156px] p-2 pl-3">
|
||||
<div className="text-[13px] leading-[18px]">
|
||||
<div title={item.name} className="flex items-center">
|
||||
<span className="truncate font-medium text-text-primary">{item.name}</span>
|
||||
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}</span>
|
||||
{
|
||||
!item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name)
|
||||
? (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<RiErrorWarningLine className="h-3 w-3 text-text-warning-secondary" />
|
||||
</Tooltip>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div className="text-text-tertiary">{item.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
|
||||
<span className="text-[13px] leading-[18px] font-normal text-text-secondary">{item.description}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
|
||||
<LabelSelector value={labels} onChange={handleLabelSelect} />
|
||||
</div>
|
||||
{/* Privacy Policy */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
value={privacyPolicy}
|
||||
onChange={e => setPrivacyPolicy(e.target.value)}
|
||||
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
|
||||
className="h-10 grow"
|
||||
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
|
||||
{!isAdd && onRemove && (
|
||||
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (isAdd)
|
||||
onConfirm()
|
||||
else
|
||||
setShowModal(true)
|
||||
}}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
{/* name for tool call */}
|
||||
<div>
|
||||
<div className="flex items-center py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.nameForToolCall', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<InfoTooltip>
|
||||
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
{!isWorkflowToolNameValid(name) && (
|
||||
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* description */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
|
||||
<Textarea
|
||||
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Tool Input */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.title', { ns: 'tools' })}</div>
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
|
||||
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
|
||||
<thead className="text-text-tertiary uppercase">
|
||||
<tr className="border-b border-divider-regular">
|
||||
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.toolInput.name', { ns: 'tools' })}</th>
|
||||
<th className="w-[102px] p-2 pl-3 font-medium">{t('createTool.toolInput.method', { ns: 'tools' })}</th>
|
||||
<th className="p-2 pl-3 font-medium">{t('createTool.toolInput.description', { ns: 'tools' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parameters.map((item, index) => (
|
||||
<tr key={index} className="border-b border-divider-regular last:border-0">
|
||||
<td className="max-w-[156px] p-2 pl-3">
|
||||
<div className="text-[13px] leading-[18px]">
|
||||
<div title={item.name} className="flex">
|
||||
<span className="truncate font-medium text-text-primary">{item.name}</span>
|
||||
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span>
|
||||
</div>
|
||||
<div className="text-text-tertiary">{item.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.name === '__image' && (
|
||||
<div className={cn(
|
||||
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
|
||||
)}
|
||||
>
|
||||
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
|
||||
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.name !== '__image' && (
|
||||
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full appearance-none bg-transparent text-[13px] leading-[18px] font-normal text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary"
|
||||
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
|
||||
value={item.description}
|
||||
onChange={e => handleParameterChange('description', e.target.value, index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tool Output */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolOutput.title', { ns: 'tools' })}</div>
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
|
||||
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
|
||||
<thead className="text-text-tertiary uppercase">
|
||||
<tr className="border-b border-divider-regular">
|
||||
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.name', { ns: 'tools' })}</th>
|
||||
<th className="p-2 pl-3 font-medium">{t('createTool.toolOutput.description', { ns: 'tools' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...reservedOutputParameters, ...outputParameters].map((item, index) => (
|
||||
<tr key={index} className="border-b border-divider-regular last:border-0">
|
||||
<td className="max-w-[156px] p-2 pl-3">
|
||||
<div className="text-[13px] leading-[18px]">
|
||||
<div title={item.name} className="flex items-center">
|
||||
<span className="truncate font-medium text-text-primary">{item.name}</span>
|
||||
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}</span>
|
||||
{
|
||||
!item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name)
|
||||
? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span data-testid="reserved-output-warning" className="i-ri-error-warning-line h-3 w-3 text-text-warning-secondary" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="w-[180px]">
|
||||
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div className="text-text-tertiary">{item.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
|
||||
<span className="text-[13px] leading-[18px] font-normal text-text-secondary">{item.description}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
|
||||
<LabelSelector value={labels} onChange={handleLabelSelect} />
|
||||
</div>
|
||||
{/* Privacy Policy */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
value={privacyPolicy}
|
||||
onChange={e => setPrivacyPolicy(e.target.value)}
|
||||
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
isShowMask={true}
|
||||
clickOutsideNotOpen={true}
|
||||
/>
|
||||
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
|
||||
{!isAdd && onRemove && (
|
||||
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (isAdd)
|
||||
onConfirm()
|
||||
else
|
||||
setShowModal(true)
|
||||
}}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorkflowToolDrawer>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker
|
||||
<WorkflowToolEmojiPicker
|
||||
onSelect={(icon, icon_background) => {
|
||||
setEmoji({ content: icon, background: icon_background })
|
||||
setShowEmojiPicker(false)
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type MethodSelectorProps = {
|
||||
value?: string
|
||||
@ -20,37 +20,43 @@ const MethodSelector: FC<MethodSelectorProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleSelect = (value: string) => {
|
||||
onChange(value)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="block"
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
|
||||
open && 'bg-background-section-burn! hover:bg-background-section-burn',
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(
|
||||
'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
|
||||
open && 'bg-background-section-burn! hover:bg-background-section-burn',
|
||||
)}
|
||||
>
|
||||
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
|
||||
{value === 'llm' ? t('createTool.toolInput.methodParameter', { ns: 'tools' }) : t('createTool.toolInput.methodSetting', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
|
||||
{value === 'llm' ? t('createTool.toolInput.methodParameter', { ns: 'tools' }) : t('createTool.toolInput.methodSetting', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1040">
|
||||
<div className="relative w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
positionerProps={{ style: { zIndex: 1040 } }}
|
||||
>
|
||||
<div className="relative w-[320px]">
|
||||
<div className="p-1">
|
||||
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => onChange('llm')}>
|
||||
<div className="item-center flex gap-1">
|
||||
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => handleSelect('llm')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-4 w-4 shrink-0">
|
||||
{value === 'llm' && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
|
||||
</div>
|
||||
@ -58,8 +64,8 @@ const MethodSelector: FC<MethodSelectorProps> = ({
|
||||
</div>
|
||||
<div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t('createTool.toolInput.methodParameterTip', { ns: 'tools' })}</div>
|
||||
</div>
|
||||
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => onChange('form')}>
|
||||
<div className="item-center flex gap-1">
|
||||
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => handleSelect('form')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-4 w-4 shrink-0">
|
||||
{value === 'form' && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
|
||||
</div>
|
||||
@ -69,9 +75,9 @@ const MethodSelector: FC<MethodSelectorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -3,10 +3,10 @@ import type { FC } from 'react'
|
||||
import type { Field as FieldType } from '../../../../../llm/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Type } from '../../../../../llm/types'
|
||||
import { getFieldType } from '../../../../../llm/utils'
|
||||
import TreeIndentLine from '../tree-indent-line'
|
||||
@ -38,24 +38,32 @@ const Field: FC<Props> = ({
|
||||
return null
|
||||
return (
|
||||
<div>
|
||||
<Tooltip popupContent={t('structOutput.moreFillTip', { ns: 'app' })} disabled={depth !== MAX_DEPTH + 1}>
|
||||
<div
|
||||
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
|
||||
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
|
||||
>
|
||||
<div className="flex grow items-stretch">
|
||||
<TreeIndentLine depth={depth} />
|
||||
{depth === MAX_DEPTH + 1
|
||||
? (
|
||||
<RiMoreFill className="h-3 w-3 text-text-tertiary" />
|
||||
)
|
||||
: (<div className={cn('h-6 w-0 grow truncate system-sm-medium leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={depth !== MAX_DEPTH + 1}
|
||||
render={(
|
||||
<div
|
||||
className={cn('flex items-center justify-between rounded-md pr-2 outline-none focus:outline-none focus-visible:outline-none', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
|
||||
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
|
||||
>
|
||||
<div className="flex grow items-stretch">
|
||||
<TreeIndentLine depth={depth} />
|
||||
{depth === MAX_DEPTH + 1
|
||||
? (
|
||||
<RiMoreFill className="h-3 w-3 text-text-tertiary" />
|
||||
)
|
||||
: (<div className={cn('h-6 w-0 grow truncate system-sm-medium leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
|
||||
|
||||
</div>
|
||||
{depth < MAX_DEPTH + 1 && (
|
||||
<div className="ml-2 shrink-0 system-xs-regular text-text-tertiary">{getFieldType(payload)}</div>
|
||||
</div>
|
||||
{depth < MAX_DEPTH + 1 && (
|
||||
<div className="ml-2 shrink-0 system-xs-regular text-text-tertiary">{getFieldType(payload)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('structOutput.moreFillTip', { ns: 'app' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{depth <= MAX_DEPTH && payload.type === Type.object && payload.properties && (
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
} from './var-reference-vars.helpers'
|
||||
|
||||
const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input'
|
||||
export const VAR_REFERENCE_CHILD_POPUP_CLASS_NAME = 'var-reference-vars-child-popup'
|
||||
|
||||
const resolveValueSelector = ({
|
||||
itemData,
|
||||
@ -210,7 +211,7 @@ const Item: FC<ItemProps> = ({
|
||||
className={cn(
|
||||
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
|
||||
(isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
|
||||
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
|
||||
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-none focus:outline-none focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
data-selected={isSelected ? 'true' : 'false'}
|
||||
@ -263,7 +264,7 @@ const Item: FC<ItemProps> = ({
|
||||
<PopoverContent
|
||||
placement="left-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
popupClassName={cn(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME, 'border-none bg-transparent p-0 shadow-none backdrop-blur-none')}
|
||||
positionerProps={{
|
||||
style: {
|
||||
zIndex: zIndex || 100,
|
||||
|
||||
@ -10,7 +10,7 @@ export default function SignInLayout({ children }: any) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={
|
||||
cn(
|
||||
@ -20,7 +20,7 @@ export default function SignInLayout({ children }: any) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col md:w-[400px]">
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,10 +12,10 @@ export default function SignInLayout({ children }: any) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
|
||||
<div className="flex w-full flex-col md:w-[400px]">
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.13.3",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#i18n": {
|
||||
|
||||
Reference in New Issue
Block a user