Compare commits

...
Author SHA1 Message Date
Jingyiand非法操作 914598d7e3 fix: prevent app card meta overflow (#38349)
(cherry picked from commit d120995efc)
2026-07-03 10:55:44 +08:00
yyhand非法操作 602c24cf8c fix(web): align main nav app item states (#38326)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jingyi-Dify <jingyi.qi@dify.ai>
(cherry picked from commit 57f687fb45)
2026-07-03 10:55:44 +08:00
非法操作 e0b5ecc50f fix: Notion sync empty state width in knowledge creation (#38321)
(cherry picked from commit 3d10f6c510)
2026-07-03 10:55:44 +08:00
Manan Bansaland非法操作 a83c7e89e0 fix(api): tolerate legacy service_api end-user type on load (#38271)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 41fb484491)
2026-07-03 10:55:44 +08:00
yyhand非法操作 7a4252b3de fix(web): fill dataset create layout height (#38308)
(cherry picked from commit 6ec56e7bd1)
2026-07-02 16:25:42 +08:00
hjlarry 5c3dd25b32 fix: repair main nav hotfix backport
(cherry picked from commit 0bd359bb8f)
2026-07-02 15:25:06 +08:00
wangxiaoleiand非法操作 30c8e9b08d fix: Working outside of application context. (#38300)
(cherry picked from commit 826b259d9f)
2026-07-02 15:07:12 +08:00
Joeland非法操作 3e3a16feaf fix: web detail adjustment before release (#38296)
Co-authored-by: Jingyi-Dify <jingyi.qi@dify.ai>
(cherry picked from commit 0bd359bb8f)
2026-07-02 15:07:12 +08:00
hjlarry 0e1fad88c0 fix: avoid profile query in public web app chat
(cherry picked from commit 52c106b532)
2026-07-01 14:05:02 +08:00
yyhand非法操作 f442559543 fix: prevent exiting toasts from blocking page clicks (#38063)
(cherry picked from commit 484633d261)
2026-07-01 14:05:02 +08:00
Bond Zhuand非法操作 2a63f26796 fix(workflow): guard on_tool_execution stdout traces behind DEBUG (#38200)
(cherry picked from commit 03d59dba47)
2026-07-01 14:05:02 +08:00
非法操作 7e325b2ff2 fix: debug plugin permission setting not work (#38197)
(cherry picked from commit 4303103304)
2026-07-01 14:05:02 +08:00
Jingyiand非法操作 26487d19b4 fix: handle integration marketplace install callbacks (#38236)
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
(cherry picked from commit f816ae2e95)
2026-07-01 14:05:02 +08:00
非法操作 71a936e89a fix: editor can view the logs (#38165)
(cherry picked from commit fa1ac75922)
2026-07-01 14:05:02 +08:00
非法操作 f2c19b456b fix: editor should not query billing subscriptions (#38157)
(cherry picked from commit 34f62e7df6)
2026-07-01 14:05:02 +08:00
Manan Bansaland非法操作 2d3da2a49f fix(web): keep HITL input-field save button visible in the edit dialog (#38007)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 267b34caaf)
2026-07-01 14:05:02 +08:00
非法操作 11b393db9f fix: agent app log detail modal not display well (#38014)
(cherry picked from commit 23917c7b3e)
2026-07-01 14:05:02 +08:00
非法操作 9a01b16d6c fix: plugin installation task popover layout when some failed too long (#38000)
(cherry picked from commit e22fd9efd6)
2026-07-01 14:05:02 +08:00
98 changed files with 1625 additions and 442 deletions
@@ -1,6 +1,7 @@
from collections.abc import Generator, Iterable, Mapping
from typing import Any
from configs import dify_config
from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackHandler, print_text
from core.ops.ops_trace_manager import TraceQueueManager
from core.tools.entities.tool_entities import ToolInvokeMessage
@@ -19,8 +20,9 @@ class DifyWorkflowCallbackHandler(DifyAgentCallbackHandler):
trace_manager: TraceQueueManager | None = None,
) -> Generator[ToolInvokeMessage, None, None]:
for tool_output in tool_outputs:
print_text("\n[on_tool_execution]\n", color=self.color)
print_text("Tool: " + tool_name + "\n", color=self.color)
print_text("Outputs: " + tool_output.model_dump_json()[:1000] + "\n", color=self.color)
print_text("\n")
if dify_config.DEBUG:
print_text("\n[on_tool_execution]\n", color=self.color)
print_text("Tool: " + tool_name + "\n", color=self.color)
print_text("Outputs: " + tool_output.model_dump_json()[:1000] + "\n", color=self.color)
print_text("\n")
yield tool_output
+6 -3
View File
@@ -10,18 +10,21 @@ def is_credential_exists(credential_id: str, credential_type: "PluginCredentialT
"""
Check if the credential still exists in the database.
Uses the configured SQLAlchemy session factory instead of Flask-SQLAlchemy's
``db.engine`` because workflow graph node construction may run without an
active Flask application context.
:param credential_id: The credential ID to check
:param credential_type: The type of credential (MODEL or TOOL)
:return: True if credential exists, False otherwise
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from extensions.ext_database import db
from core.db import session_factory
from models.provider import ProviderCredential, ProviderModelCredential
from models.tools import BuiltinToolProvider
with Session(db.engine) as session:
with session_factory.create_session() as session:
if credential_type == PluginCredentialType.MODEL:
# Check both pre-defined and custom model credentials using a single UNION query
stmt = (
+12
View File
@@ -214,6 +214,18 @@ class EndUserType(StrEnum):
SERVICE_API = "service-api"
TRIGGER = "trigger"
@classmethod
@override
def _missing_(cls, value):
# Legacy rows persisted the service-api type with an underscore before it
# was normalized to the hyphenated value. The
# `4f7b2c8d9a10_normalize_legacy_end_user_type` migration rewrites those
# rows, but tolerate the old value here as well so an unmigrated end user
# keeps loading instead of failing enum validation on every request.
if value == "service_api":
return cls.SERVICE_API
return super()._missing_(value)
class DocumentDocType(StrEnum):
"""Document doc_type classification"""
+1
View File
@@ -435,6 +435,7 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.delete",
"app.acl.release_and_version",
"app.acl.monitor",
"app.acl.log_and_annotation",
"app.acl.access_config",
]
@@ -32,8 +32,16 @@ def mock_print_text(mocker: MockerFixture):
return mocker.patch("core.callback_handler.workflow_tool_callback_handler.print_text")
@pytest.fixture
def enable_debug(mocker: MockerFixture):
"""Force DEBUG on so the handler emits its verbose stdout traces."""
mocker.patch("core.callback_handler.workflow_tool_callback_handler.dify_config.DEBUG", True)
class TestDifyWorkflowCallbackHandler:
def test_on_tool_execution_single_output_success(self, handler: DifyWorkflowCallbackHandler, mock_print_text):
def test_on_tool_execution_single_output_success(
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
):
# Arrange
tool_name = "test_tool"
tool_inputs = {"a": 1}
@@ -63,7 +71,9 @@ class TestDifyWorkflowCallbackHandler:
]
)
def test_on_tool_execution_multiple_outputs(self, handler: DifyWorkflowCallbackHandler, mock_print_text):
def test_on_tool_execution_multiple_outputs(
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
):
# Arrange
tool_name = "multi_tool"
outputs = [
@@ -101,6 +111,29 @@ class TestDifyWorkflowCallbackHandler:
assert results == []
mock_print_text.assert_not_called()
def test_on_tool_execution_skips_print_when_debug_disabled(
self, handler: DifyWorkflowCallbackHandler, mock_print_text, mocker: MockerFixture
):
"""When DEBUG is off, outputs are still yielded but nothing is printed
and model_dump_json() is never invoked."""
# Arrange
mocker.patch("core.callback_handler.workflow_tool_callback_handler.dify_config.DEBUG", False)
message = MagicMock()
# Act
results = list(
handler.on_tool_execution(
tool_name="quiet_tool",
tool_inputs={},
tool_outputs=[message],
)
)
# Assert
assert results == [message]
mock_print_text.assert_not_called()
message.model_dump_json.assert_not_called()
@pytest.mark.parametrize(
("invalid_outputs", "expected_exception"),
[
@@ -110,7 +143,7 @@ class TestDifyWorkflowCallbackHandler:
],
)
def test_on_tool_execution_invalid_outputs_type(
self, handler: DifyWorkflowCallbackHandler, invalid_outputs, expected_exception
self, handler: DifyWorkflowCallbackHandler, invalid_outputs, expected_exception, enable_debug
):
# Arrange
tool_name = "invalid_tool"
@@ -125,7 +158,9 @@ class TestDifyWorkflowCallbackHandler:
)
)
def test_on_tool_execution_long_json_truncation(self, handler: DifyWorkflowCallbackHandler, mock_print_text):
def test_on_tool_execution_long_json_truncation(
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
):
# Arrange
tool_name = "long_json_tool"
long_json = "x" * 1500
@@ -147,7 +182,9 @@ class TestDifyWorkflowCallbackHandler:
color="blue",
)
def test_on_tool_execution_model_dump_json_exception(self, handler: DifyWorkflowCallbackHandler, mock_print_text):
def test_on_tool_execution_model_dump_json_exception(
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
):
# Arrange
tool_name = "exception_tool"
bad_message = MagicMock()
@@ -167,7 +204,7 @@ class TestDifyWorkflowCallbackHandler:
assert mock_print_text.call_count >= 2
def test_on_tool_execution_none_message_id_and_trace_manager(
self, handler: DifyWorkflowCallbackHandler, mock_print_text
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
):
# Arrange
tool_name = "optional_params_tool"
@@ -114,10 +114,11 @@ def test_is_credential_exists_by_type(
scalar_result: str | None,
expected: bool,
) -> None:
mocker.patch("extensions.ext_database.db", new=SimpleNamespace(engine=object()))
session_cls = mocker.patch("sqlalchemy.orm.Session")
session = session_cls.return_value.__enter__.return_value
session = mocker.MagicMock()
session.scalar.return_value = scalar_result
session_context = mocker.MagicMock()
session_context.__enter__.return_value = session
mocker.patch("core.db.session_factory.create_session", return_value=session_context)
result = is_credential_exists("cred-1", credential_type)
@@ -128,11 +129,33 @@ def test_is_credential_exists_by_type(
def test_is_credential_exists_returns_false_for_unknown_type(
mocker: MockerFixture,
) -> None:
mocker.patch("extensions.ext_database.db", new=SimpleNamespace(engine=object()))
session_cls = mocker.patch("sqlalchemy.orm.Session")
session = session_cls.return_value.__enter__.return_value
session = mocker.MagicMock()
session_context = mocker.MagicMock()
session_context.__enter__.return_value = session
mocker.patch("core.db.session_factory.create_session", return_value=session_context)
result = is_credential_exists("cred-1", cast(PluginCredentialType, "unknown"))
assert result is False
session.scalar.assert_not_called()
def test_is_credential_exists_uses_configured_session_factory_without_flask_app_context(
mocker: MockerFixture,
) -> None:
class RaisingDB:
@property
def engine(self):
raise RuntimeError("Working outside of application context.")
session = mocker.MagicMock()
session.scalar.return_value = "model-credential"
session_context = mocker.MagicMock()
session_context.__enter__.return_value = session
create_session = mocker.patch("core.db.session_factory.create_session", return_value=session_context)
mocker.patch("extensions.ext_database.db", new=RaisingDB())
result = is_credential_exists("cred-1", PluginCredentialType.MODEL)
assert result is True
create_session.assert_called_once_with()
@@ -2,6 +2,9 @@ import ast
import inspect
from pathlib import Path
import pytest
from sqlalchemy.engine.default import DefaultDialect
from models.enums import EndUserType
from models.model import EndUser
from models.types import EnumText
@@ -24,6 +27,24 @@ def test_end_user_type_is_plain_persisted_value_enum():
assert not hasattr(EndUserType, "from_invoke_from")
def test_end_user_type_accepts_legacy_service_api_value():
# Legacy rows stored the service-api type with an underscore before it was
# normalized to the hyphenated value; loading them must not raise. See #38201.
assert EndUserType("service_api") is EndUserType.SERVICE_API
assert EndUserType("service-api") is EndUserType.SERVICE_API
def test_end_user_type_still_rejects_unknown_values():
with pytest.raises(ValueError):
EndUserType("not-a-real-end-user-type")
def test_enum_text_deserializes_legacy_service_api_value():
# Exercises the actual column load path that raised for unmigrated rows.
column_type = EnumText(EndUserType)
assert column_type.process_result_value("service_api", DefaultDialect()) is EndUserType.SERVICE_API
def test_end_user_service_creation_methods_accept_end_user_type():
assert inspect.signature(EndUserService.get_or_create_end_user_by_type).parameters["type"].annotation is EndUserType
assert inspect.signature(EndUserService.create_end_user_batch).parameters["type"].annotation is EndUserType
@@ -633,6 +633,8 @@ class TestMyPermissions:
assert "dataset.acl.preview" in out.workspace.permission_keys
assert "app.acl.preview" in out.app.default_permission_keys
assert "dataset.acl.preview" in out.dataset.default_permission_keys
if role == "editor":
assert "app.acl.log_and_annotation" in out.app.default_permission_keys
@pytest.mark.parametrize(
("role", "expected_snippet_keys"),
-13
View File
@@ -3335,14 +3335,6 @@
"count": 1
}
},
"web/app/components/explore/sidebar/app-nav-item/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/explore/try-app/app/text-generation.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -3619,11 +3611,6 @@
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx": {
"jsx-a11y/anchor-has-content": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": {
"react/set-state-in-effect": {
"count": 1
@@ -3,19 +3,16 @@ import { toast, ToastHost } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
type BaseUIAnimationGlobal = typeof globalThis & {
BASE_UI_ANIMATIONS_DISABLED: boolean
}
const dispatchToastMouseOver = (element: HTMLElement | SVGElement) => {
element.dispatchEvent(new MouseEvent('mouseover', {
bubbles: true,
}))
}
const dispatchToastMouseOut = (element: HTMLElement | SVGElement) => {
element.dispatchEvent(new MouseEvent('mouseout', {
bubbles: true,
relatedTarget: document.body,
}))
}
describe('@langgenius/dify-ui/toast', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -38,13 +35,10 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByText('Saved')).toBeInTheDocument()
await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument()
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none')
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
const viewport = screen.getByRole('region', { name: 'Notifications' })
await expect.element(viewport).toHaveAttribute('aria-live', 'polite')
await expect.element(viewport).toHaveClass('z-60')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()
})
it('should keep multiple toast roots mounted in a collapsed stack', async () => {
@@ -58,37 +52,6 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByText('Third toast')).toBeInTheDocument()
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(3)
expect(document.body.querySelectorAll('button[aria-label="Close notification"][aria-hidden="true"]')).toHaveLength(3)
const viewport = screen.getByRole('region', { name: 'Notifications' }).element()
dispatchToastMouseOver(viewport)
await vi.waitFor(() => {
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).not.toBeInTheDocument()
})
dispatchToastMouseOut(viewport)
})
it('should clamp varying-height toasts to the frontmost stack height when collapsed', async () => {
const screen = await render(<ToastHost />)
toast.info('Long background toast', {
description: 'This longer toast intentionally spans multiple lines so it would overflow the collapsed stack without matching the frontmost toast height.',
})
toast.success('Short front toast', {
description: 'Short message.',
})
await expect.element(screen.getByText('Short front toast')).toBeInTheDocument()
await expect.element(screen.getByText('Long background toast')).toBeInTheDocument()
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
await expect.element(screen.getByRole('dialog', { name: 'Short front toast' })).toBeInTheDocument()
await expect.element(screen.getByRole('dialog', { name: 'Long background toast' })).toBeInTheDocument()
const longToastContent = screen.getByText('Long background toast').element().closest('[class*="transition-opacity"]')
expect(longToastContent).toHaveAttribute('data-behind')
expect(longToastContent).toHaveClass('h-full')
expect(longToastContent?.parentElement).toHaveClass('h-full')
})
it('should render a neutral toast when called directly', async () => {
@@ -157,7 +120,6 @@ describe('@langgenius/dify-ui/toast', () => {
dispatchToastMouseOver(viewport)
await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument()
dispatchToastMouseOut(viewport)
asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click()
await vi.waitFor(() => {
@@ -166,15 +128,112 @@ describe('@langgenius/dify-ui/toast', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should keep zero-timeout toasts persistent', async () => {
const screen = await render(<ToastHost />)
it('should let pointer events pass through a toast while it is exiting', async () => {
const onClick = vi.fn()
const baseUIAnimationGlobal = globalThis as BaseUIAnimationGlobal
const animationState = baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED
baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = false
try {
const screen = await render(
<>
<style>
{`
[role="dialog"] {
transition: opacity 10000s, transform 10000s !important;
}
[role="dialog"][data-ending-style] {
opacity: 0 !important;
transform: translateY(-150%) !important;
}
.data-ending-style\\:pointer-events-none[data-ending-style] {
pointer-events: none;
}
.data-ending-style\\:after\\:pointer-events-none[data-ending-style]::after {
pointer-events: none;
}
`}
</style>
<button
type="button"
onClick={onClick}
style={{
position: 'fixed',
top: '16px',
right: '32px',
width: '360px',
height: '96px',
}}
>
Underlying action
</button>
<ToastHost />
</>,
)
toast('Dismiss me', {
timeout: 0,
})
await expect.element(screen.getByRole('dialog', { name: 'Dismiss me' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click()
const viewport = screen.getByRole('region', { name: 'Notifications' }).element()
dispatchToastMouseOver(viewport)
await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click()
await vi.waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).toHaveAttribute('data-ending-style')
})
asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click()
const underlyingAction = asHTMLElement(screen.getByRole('button', { name: 'Underlying action' }).element())
const rect = underlyingAction.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
document.elementFromPoint(x, y)?.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
}))
expect(onClick).toHaveBeenCalledTimes(1)
}
finally {
baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = animationState
}
})
it('should pass the host timeout to added toasts', async () => {
const screen = await render(<ToastHost timeout={1000} />)
toast('Auto dismiss')
await expect.element(screen.getByText('Auto dismiss')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(999)
expect(document.body).toHaveTextContent('Auto dismiss')
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Auto dismiss')
})
})
it('should keep a toast persistent when its timeout is zero', async () => {
const screen = await render(<ToastHost timeout={1000} />)
toast('Persistent', {
timeout: 0,
})
await expect.element(screen.getByText('Persistent')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10000)
await vi.advanceTimersByTimeAsync(5000)
expect(document.body).toHaveTextContent('Persistent')
})
@@ -185,6 +244,7 @@ describe('@langgenius/dify-ui/toast', () => {
description: 'Preparing your data…',
})
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
expect(document.body.querySelector('[aria-hidden="true"].i-ri-information-2-fill')).toBeInTheDocument()
toast.update(toastId, {
title: 'Done',
@@ -195,27 +255,28 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByText('Done')).toBeInTheDocument()
await expect.element(screen.getByText('Your data is ready.')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('Loading')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
})
it('should upsert an existing toast when add is called with the same id', async () => {
const screen = await render(<ToastHost />)
toast('Syncing', {
id: 'sync-job',
description: 'Uploading changes…',
toast('Draft saving', {
id: 'draft-save-status',
description: 'Saving changes…',
})
await expect.element(screen.getByText('Syncing')).toBeInTheDocument()
await expect.element(screen.getByText('Draft saving')).toBeInTheDocument()
toast.success('Synced', {
id: 'sync-job',
description: 'All changes are uploaded.',
toast.success('Draft saved', {
id: 'draft-save-status',
description: 'All changes are saved.',
})
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Syncing')
expect(document.body).not.toHaveTextContent('Draft saving')
})
await expect.element(screen.getByText('Synced')).toBeInTheDocument()
await expect.element(screen.getByText('All changes are uploaded.')).toBeInTheDocument()
await expect.element(screen.getByText('Draft saved')).toBeInTheDocument()
await expect.element(screen.getByText('All changes are saved.')).toBeInTheDocument()
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(1)
})
@@ -261,5 +322,6 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByText('Saved')).toBeInTheDocument()
await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument()
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
})
})
+11 -17
View File
@@ -153,13 +153,13 @@ function ToastCard({
<BaseToast.Root
toast={toastItem}
className={cn(
'pointer-events-auto absolute top-0 right-0 w-90 max-w-[calc(100vw-2rem)] origin-top cursor-default rounded-xl select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'pointer-events-auto absolute top-0 right-0 w-full origin-top cursor-default rounded-xl select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',
'z-[calc(100-var(--toast-index))] h-(--toast-current-height)',
'[transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:transition-none',
'transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-current-height))))_scale(var(--toast-scale))]',
'data-expanded:h-(--toast-height) data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*8px)))_scale(1)]',
'data-ending-style:transform-[translateY(-150%)] data-ending-style:opacity-0',
'data-ending-style:pointer-events-none data-ending-style:after:pointer-events-none data-ending-style:transform-[translateY(-150%)] data-ending-style:opacity-0',
'data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))]',
'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))]',
'data-limited:pointer-events-none data-limited:opacity-0 data-starting-style:transform-[translateY(-150%)] data-starting-style:opacity-0',
@@ -178,13 +178,13 @@ function ToastCard({
<div className="min-w-0 flex-1 p-1">
<div className="flex w-full min-w-0 items-center gap-1">
{toastItem.title != null && (
<BaseToast.Title className="min-w-0 flex-1 system-sm-semibold wrap-break-word [overflow-wrap:anywhere] text-text-primary">
<BaseToast.Title className="min-w-0 flex-1 system-sm-semibold wrap-break-word text-text-primary">
{toastItem.title}
</BaseToast.Title>
)}
</div>
{toastItem.description != null && (
<BaseToast.Description className="mt-1 min-w-0 system-xs-regular wrap-break-word [overflow-wrap:anywhere] text-text-secondary">
<BaseToast.Description className="mt-1 min-w-0 system-xs-regular wrap-break-word text-text-secondary">
{toastItem.description}
</BaseToast.Description>
)}
@@ -222,21 +222,15 @@ function ToastViewport() {
<BaseToast.Viewport
aria-label={toastViewportLabel}
className={cn(
'group/toast-viewport pointer-events-none fixed inset-0 z-60 overflow-visible',
'group/toast-viewport pointer-events-none fixed top-4 right-4 z-60 w-90 max-w-[calc(100vw-2rem)] overflow-visible sm:right-8',
)}
>
<div
className={cn(
'pointer-events-none absolute top-4 right-4 w-90 max-w-[calc(100vw-2rem)] sm:right-8',
)}
>
{toasts.map(toastItem => (
<ToastCard
key={toastItem.id}
toast={toastItem}
/>
))}
</div>
{toasts.map(toastItem => (
<ToastCard
key={toastItem.id}
toast={toastItem}
/>
))}
</BaseToast.Viewport>
)
}
@@ -232,7 +232,20 @@ describe('Billing Page + Plan Integration', () => {
// Verify billing URL button visibility and behavior
describe('Billing URL button', () => {
it('should show billing button when subscription management permission is granted', () => {
it('should show billing button when manager has subscription management permission', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.subscription.manage'],
})
render(<Billing />)
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when subscription management permission is granted without manager role', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({
isCurrentWorkspaceManager: false,
@@ -241,8 +254,7 @@ describe('Billing Page + Plan Integration', () => {
render(<Billing />)
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
it('should hide billing button when subscription management permission is missing', () => {
@@ -204,12 +204,5 @@ describe('Sidebar Lifecycle Flow', () => {
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
})
it('should hide NoApps on mobile', () => {
mockMediaType = MediaType.mobile
renderSidebar()
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
})
})
})
@@ -6,6 +6,9 @@ import PluginPage from '@/app/components/plugins/plugin-page'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
const mockFetchManifestFromMarketPlace = vi.fn()
const { mockRouterReplace } = vi.hoisted(() => ({
mockRouterReplace: vi.fn(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@@ -26,6 +29,12 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockRouterReplace,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
+50 -2
View File
@@ -2,17 +2,65 @@ import type { LegacyPluginsSearchParams } from '@/app/components/plugins/plugin-
import Marketplace from '@/app/components/plugins/marketplace'
import PluginPage from '@/app/components/plugins/plugin-page'
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
import { getLegacyPluginRedirectPath } from '@/app/components/plugins/plugin-routes'
import {
getFirstPackageIdFromSearchParams,
getInstallRedirectPathByPluginCategory,
getInstallRedirectPathFromSearchParams,
getLegacyPluginRedirectPath,
shouldResolveInstallCategoryRedirect,
} from '@/app/components/plugins/plugin-routes'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { redirect } from '@/next/navigation'
type PluginListProps = {
searchParams?: Promise<LegacyPluginsSearchParams>
}
type MarketplaceManifestCategoryResponse = {
data?: {
plugin?: {
category?: string
}
}
}
const fetchPluginCategoryFromMarketplace = async (packageId: string) => {
try {
const response = await fetch(
`${MARKETPLACE_API_PREFIX}/plugins/identifier?unique_identifier=${encodeURIComponent(packageId)}`,
{ cache: 'no-store' },
)
if (!response.ok)
return undefined
const payload = await response.json() as MarketplaceManifestCategoryResponse
return payload.data?.plugin?.category
}
catch {
return undefined
}
}
const PluginList = async ({
searchParams,
}: PluginListProps) => {
const redirectPath = getLegacyPluginRedirectPath(await searchParams)
const resolvedSearchParams = await searchParams ?? {}
const installRedirectPathFromSearchParams = getInstallRedirectPathFromSearchParams(resolvedSearchParams)
if (installRedirectPathFromSearchParams)
redirect(installRedirectPathFromSearchParams)
if (shouldResolveInstallCategoryRedirect(resolvedSearchParams)) {
const packageId = getFirstPackageIdFromSearchParams(resolvedSearchParams)
const category = packageId ? await fetchPluginCategoryFromMarketplace(packageId) : undefined
const installRedirectPath = getInstallRedirectPathByPluginCategory(category, resolvedSearchParams)
if (installRedirectPath)
redirect(installRedirectPath)
}
const redirectPath = getLegacyPluginRedirectPath(resolvedSearchParams)
if (redirectPath)
redirect(redirectPath)
@@ -21,6 +21,7 @@ let mockChatConversationDetail: Record<string, unknown> | undefined
let mockCompletionConversationDetail: Record<string, unknown> | undefined
let mockShowMessageLogModal = false
let mockShowPromptLogModal = false
let mockShowAgentLogModal = false
let mockCurrentLogItem: Record<string, unknown> | undefined
let mockCurrentLogModalActiveTab = 'messages'
@@ -81,6 +82,7 @@ vi.mock('@/app/components/app/store', () => ({
setShowAgentLogModal: mockSetShowAgentLogModal,
setShowMessageLogModal: mockSetShowMessageLogModal,
showPromptLogModal: mockShowPromptLogModal,
showAgentLogModal: mockShowAgentLogModal,
currentLogModalActiveTab: mockCurrentLogModalActiveTab,
}),
}))
@@ -126,6 +128,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({
onAnnotationEdited,
onAnnotationRemoved,
switchSibling,
hideLogModal,
}: {
chatList: Array<{ id: string }>
onFeedback: (mid: string, value: { rating: string, content?: string }) => Promise<boolean>
@@ -133,8 +136,9 @@ vi.mock('@/app/components/base/chat/chat', () => ({
onAnnotationEdited: (query: string, answer: string, index: number) => void
onAnnotationRemoved: (index: number) => Promise<boolean>
switchSibling: (siblingMessageId: string) => void
hideLogModal?: boolean
}) => (
<div data-testid="chat-panel">
<div data-testid="chat-panel" data-hide-log-modal={String(hideLogModal)}>
<div>{chatList.length}</div>
<button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>chat-feedback</button>
<button onClick={() => onAnnotationAdded('annotation-2', 'Admin', 'Edited question', 'Edited answer', 1)}>chat-add-annotation</button>
@@ -145,6 +149,14 @@ vi.mock('@/app/components/base/chat/chat', () => ({
),
}))
vi.mock('@/app/components/base/agent-log-modal', () => ({
default: ({ floating, onCancel }: { floating?: boolean, onCancel: () => void }) => (
<div data-testid="agent-log-modal" data-floating={String(floating)}>
<button onClick={onCancel}>close-agent-log-modal</button>
</div>
),
}))
vi.mock('@/app/components/base/message-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="message-log-modal">
@@ -255,6 +267,7 @@ describe('ConversationList', () => {
mockCompletionConversationDetail = undefined
mockShowMessageLogModal = false
mockShowPromptLogModal = false
mockShowAgentLogModal = false
mockCurrentLogItem = undefined
mockCurrentLogModalActiveTab = 'messages'
mockDelAnnotation.mockResolvedValue(undefined)
@@ -383,6 +396,7 @@ describe('ConversationList', () => {
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
expect(screen.getByTestId('message-log-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('chat-feedback'))
@@ -399,6 +413,61 @@ describe('ConversationList', () => {
})
})
it('should mount agent log modals from the detail panel instead of the nested chat layout', async () => {
mockChatConversationDetail = {
id: 'conversation-1',
created_at: 1710000000,
model_config: {
model: 'gpt-4o',
configs: {
introduction: 'Hello there',
},
user_input_form: [],
},
message: {
inputs: {},
},
}
mockShowAgentLogModal = true
mockCurrentLogItem = {
id: 'message-1',
conversationId: 'conversation-1',
}
mockFetchChatMessages.mockResolvedValue({
data: [
{
id: 'message-1',
answer: 'Assistant reply',
query: 'Latest question',
created_at: 1710000000,
inputs: {},
feedbacks: [],
message: [],
message_files: [],
agent_thoughts: [{ id: 'thought-1' }],
},
],
has_more: false,
})
renderConversationList({
searchParams: '?page=2&conversation_id=conversation-1',
})
await waitFor(() => {
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
})
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
expect(screen.getByTestId('agent-log-modal')).toHaveAttribute('data-floating', 'true')
fireEvent.click(screen.getByText('close-agent-log-modal'))
expect(mockSetCurrentLogItem).toHaveBeenCalled()
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
})
it('should render completion details and refetch after feedback updates', async () => {
mockCompletionConversationDetail = {
id: 'conversation-1',
@@ -424,7 +493,7 @@ describe('ConversationList', () => {
},
}
mockShowPromptLogModal = true
mockCurrentLogItem = { id: 'log-2' }
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
renderConversationList({
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
@@ -626,7 +695,7 @@ describe('ConversationList', () => {
},
}
mockShowPromptLogModal = true
mockCurrentLogItem = { id: 'log-2' }
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
renderConversationList({
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
+29 -2
View File
@@ -36,6 +36,7 @@ import ModelInfo from '@/app/components/app/log/model-info'
import { useStore as useAppStore } from '@/app/components/app/store'
import TextGeneration from '@/app/components/app/text-generate/item'
import ActionButton from '@/app/components/base/action-button'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import Chat from '@/app/components/base/chat/chat'
import CopyIcon from '@/app/components/base/copy-icon'
import Loading from '@/app/components/base/loading'
@@ -165,13 +166,25 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
})
const { formatTime } = useTimestamp()
const { onClose, appDetail } = useContext(DrawerContext)
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({
const {
currentLogItem,
setCurrentLogItem,
showMessageLogModal,
setShowMessageLogModal,
showPromptLogModal,
setShowPromptLogModal,
showAgentLogModal,
setShowAgentLogModal,
currentLogModalActiveTab,
} = useAppStore(useShallow((state: AppStoreState) => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showMessageLogModal: state.showMessageLogModal,
setShowMessageLogModal: state.setShowMessageLogModal,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
currentLogModalActiveTab: state.currentLogModalActiveTab,
})))
const { t } = useTranslation()
@@ -395,6 +408,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION
const isAdvanced = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
const shouldShowPromptLogModal = showPromptLogModal && !!currentLogItem?.log
const varList = getDetailVarList(detail, varValues)
const message_files = getCompletionMessageFiles(detail, isChatMode)
@@ -507,6 +521,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
noChatInput
showPromptLog
hideProcessDetail
hideLogModal
chatContainerInnerClassName="px-3"
switchSibling={switchSibling}
/>
@@ -546,6 +561,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
noChatInput
showPromptLog
hideProcessDetail
hideLogModal
chatContainerInnerClassName="px-3"
switchSibling={switchSibling}
/>
@@ -574,7 +590,18 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
/>
</WorkflowContextProvider>
)}
{!isChatMode && showPromptLogModal && (
{showAgentLogModal && (
<AgentLogModal
floating
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
{shouldShowPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
+13 -4
View File
@@ -1058,11 +1058,20 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
</div>
</div>
<div className="flex h-[26px] shrink-0 items-start px-3" />
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
<div
className={cn(
'flex min-w-0 shrink-0 items-center overflow-hidden pt-2 pb-3 pl-4 system-xs-regular text-text-tertiary',
app.access_mode ? 'pr-9' : 'pr-4',
)}
>
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
<div className="truncate">{app.author_name}</div>
<div className="shrink-0">·</div>
<div className="truncate">{editTimeText}</div>
{app.author_name && (
<>
<div className="min-w-0 truncate">{app.author_name}</div>
<div className="shrink-0">·</div>
</>
)}
<div className="min-w-0 truncate">{editTimeText}</div>
</div>
</div>
</>
@@ -119,6 +119,17 @@ describe('AgentLogModal', () => {
})
})
it('should render the floating modal through a dialog portal', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
const { container } = render(<AgentLogModal {...mockProps} floating />)
const modal = screen.getByRole('dialog')
expect(container).not.toContainElement(modal)
expect(document.body).toContainElement(modal)
expect(modal).toHaveClass('fixed', 'z-50', 'w-[480px]!', 'left-[max(8px,calc(100vw-1136px))]!')
})
it('should call onCancel when close button is clicked', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
@@ -158,4 +169,18 @@ describe('AgentLogModal', () => {
expect(mockProps.onCancel).not.toHaveBeenCalled()
})
it('should not use click-away to close the floating dialog', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
let clickAwayHandler!: (event: Event) => void
vi.mocked(useClickAway).mockImplementation((callback) => {
clickAwayHandler = callback
})
render(<AgentLogModal {...mockProps} floating />)
clickAwayHandler(new Event('click'))
expect(mockProps.onCancel).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RiCloseLine } from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
@@ -10,11 +11,13 @@ import AgentLogDetail from './detail'
type AgentLogModalProps = Readonly<{
currentLogItem?: IChatItem
width: number
floating?: boolean
onCancel: () => void
}>
const AgentLogModal: FC<AgentLogModalProps> = ({
currentLogItem,
width,
floating,
onCancel,
}) => {
const { t } = useTranslation()
@@ -22,7 +25,7 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
const [mounted, setMounted] = useState(false)
useClickAway(() => {
if (mounted)
if (mounted && !floating)
onCancel()
}, ref)
@@ -33,6 +36,44 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
if (!currentLogItem || !currentLogItem.conversationId)
return null
const detailContent = (
<>
<AgentLogDetail
conversationID={currentLogItem.conversationId}
messageID={currentLogItem.id}
log={currentLogItem}
/>
</>
)
if (floating) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropClassName="bg-transparent!"
className="top-16! bottom-4! left-[max(8px,calc(100vw-1136px))]! flex max-h-none! w-[480px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! flex-col overflow-hidden! rounded-xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! pt-3! pb-3! shadow-xl!"
>
<DialogTitle className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary">{t('runDetail.workflowTitle', { ns: 'appLog' })}</DialogTitle>
<button
type="button"
aria-label={t('operation.close', { ns: 'common' })}
className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onCancel}
>
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
{detailContent}
</DialogContent>
</Dialog>
)
}
return (
<div
className={cn('relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl')}
@@ -54,11 +95,7 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
>
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
<AgentLogDetail
conversationID={currentLogItem.conversationId}
messageID={currentLogItem.id}
log={currentLogItem}
/>
{detailContent}
</div>
)
}
@@ -57,6 +57,9 @@ const ChatWrapper = () => {
} = useChatWithHistoryContext()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const timezone = appSourceType === AppSourceType.webApp
? Intl.DateTimeFormat().resolvedOptions().timeZone
: undefined
// Semantic variable for better code readability
const isHistoryConversation = !!currentConversationId
@@ -91,6 +94,8 @@ const ChatWrapper = () => {
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList,
setClearChatList,
undefined,
{ timezone },
)
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
@@ -6,6 +6,10 @@ import { useParams, usePathname } from '@/next/navigation'
import { sseGet, ssePost } from '@/service/base'
import { useChat } from '../hooks'
const useTimestampMock = vi.hoisted(() =>
vi.fn(() => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') })),
)
vi.mock('@/service/base', () => ({
sseGet: vi.fn(),
ssePost: vi.fn(),
@@ -31,7 +35,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') }),
default: useTimestampMock,
}))
vi.mock('@/next/navigation', () => ({
@@ -91,6 +95,21 @@ describe('useChat', () => {
expect(result.current.suggestedQuestions).toEqual([])
})
it('should pass timestamp options to timestamp formatter', () => {
renderHook(() => useChat(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ timezone: 'UTC' },
))
expect(useTimestampMock).toHaveBeenCalledWith({ timezone: 'UTC' })
})
it('should initialize with opening statement and suggested questions', () => {
const config = {
opening_statement: 'Hello {{name}}',
+6 -1
View File
@@ -55,6 +55,10 @@ type SendCallback = {
isPublicAPI?: boolean
}
type UseChatOptions = {
timezone?: string
}
export const useChat = (
config?: ChatConfig,
formSettings?: {
@@ -66,9 +70,10 @@ export const useChat = (
clearChatList?: boolean,
clearChatListCallback?: (state: boolean) => void,
initialConversationId?: string,
options: UseChatOptions = {},
) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const { formatTime } = useTimestamp({ timezone: options.timezone })
const conversationIdRef = useRef(initialConversationId ?? '')
const initialConversationIdRef = useRef(initialConversationId ?? '')
const hasStopRespondedRef = useRef(false)
@@ -81,6 +81,9 @@ const ChatWrapper = () => {
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
} as ChatConfig
}, [appParams, currentConversationItem?.introduction])
const timezone = appSourceType === AppSourceType.webApp
? Intl.DateTimeFormat().resolvedOptions().timeZone
: undefined
const {
chatList,
handleSend,
@@ -98,6 +101,8 @@ const ChatWrapper = () => {
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList,
setClearChatList,
undefined,
{ timezone },
)
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
@@ -109,7 +109,11 @@ describe('InputField', () => {
const scrollBody = panel?.children[1]
const footer = panel?.lastElementChild
expect(panel).toHaveClass('max-h-(--shortcut-popup-max-height)', 'overflow-hidden')
// The max-height falls back to a viewport unit so the panel stays bounded
// (and the footer/actions reachable via the internal scroll) even when it is
// rendered outside the shortcuts popup that defines --shortcut-popup-max-height,
// e.g. inside the edit dialog. See issue #37979.
expect(panel).toHaveClass('max-h-[var(--shortcut-popup-max-height,80dvh)]', 'overflow-hidden')
expect(header).toHaveClass('shrink-0', 'pb-2')
expect(scrollBody).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto')
expect(footer).toHaveClass('shrink-0', 'bg-components-panel-bg')
@@ -216,7 +216,7 @@ const InputField: React.FC<InputFieldProps> = ({
}, [handleSave])
return (
<div className="flex max-h-(--shortcut-popup-max-height) w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="flex max-h-[var(--shortcut-popup-max-height,80dvh)] w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="shrink-0 p-3 pb-2">
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
</div>
@@ -6,6 +6,7 @@ let fetching = false
let isManager = true
let enableBilling = true
let workspacePermissionKeys: string[] = ['billing.subscription.manage']
let billingUrlEnabled = false
const refetchMock = vi.fn()
const openAsyncWindowMock = vi.fn()
@@ -19,11 +20,14 @@ type BillingWindowOptions = {
type OpenAsyncWindowCall = [BillingUrlCallback, BillingWindowOptions]
vi.mock('@/service/use-billing', () => ({
useBillingUrl: () => ({
data: currentBillingUrl,
isFetching: fetching,
refetch: refetchMock,
}),
useBillingUrl: (enabled: boolean) => {
billingUrlEnabled = enabled
return {
data: currentBillingUrl,
isFetching: fetching,
refetch: refetchMock,
}
},
}))
vi.mock('@/hooks/use-async-window-open', () => ({
@@ -54,28 +58,32 @@ describe('Billing', () => {
fetching = false
isManager = true
enableBilling = true
billingUrlEnabled = false
workspacePermissionKeys = ['billing.subscription.manage']
refetchMock.mockResolvedValue({ data: 'https://billing' })
})
it('shows the billing action when subscription management permission is granted without manager role', () => {
it('hides the billing action when subscription management permission is granted without manager role', () => {
isManager = false
render(<Billing />)
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
})
it('hides the billing action when subscription management permission is missing or billing is disabled', () => {
workspacePermissionKeys = []
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
vi.clearAllMocks()
workspacePermissionKeys = ['billing.subscription.manage']
enableBilling = false
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
})
it('opens the billing window with the immediate url when the button is clicked', async () => {
@@ -11,9 +11,9 @@ import PlanComp from '../plan'
const Billing: FC = () => {
const { t } = useTranslation()
const { workspacePermissionKeys } = useAppContext()
const { isCurrentWorkspaceManager, workspacePermissionKeys } = useAppContext()
const { enableBilling } = useProviderContext()
const canManageBillingSubscription = hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
const canManageBillingSubscription = isCurrentWorkspaceManager && hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && canManageBillingSubscription)
const openAsyncWindow = useAsyncWindowOpen()
+2 -2
View File
@@ -131,9 +131,9 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
return <AppUnavailable code={500} unknownReason={t('error.unavailable', { ns: 'datasetCreation' }) as string} />
return (
<div className="flex flex-col overflow-hidden bg-components-panel-bg" style={{ height: 'calc(100vh - 56px)' }}>
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-components-panel-bg">
<TopBar activeIndex={step - 1} datasetId={datasetId} />
<div style={{ height: 'calc(100% - 52px)' }}>
<div className="min-h-0 flex-1">
{
isLoadingAuthedDataSourceList && (
<Loading type="app" />
@@ -218,7 +218,11 @@ const StepOne = ({
{/* Notion Data Source */}
{dataSourceType === DataSourceType.NOTION && (
<>
{!isNotionAuthed && <NotionConnector onSetting={onSetting} />}
{!isNotionAuthed && (
<div className={cn('mb-8 w-[640px]', !shouldShowDataSourceTypeList && 'mt-12')}>
<NotionConnector onSetting={onSetting} />
</div>
)}
{isNotionAuthed && (
<>
<div className="mb-8 w-[640px]">
@@ -1,6 +1,5 @@
import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MediaType } from '@/hooks/use-breakpoints'
import { expectLoadingButton } from '@/test/button'
import { AppModeEnum } from '@/types/app'
import SideBar from '../index'
@@ -16,7 +15,6 @@ const mockUpdatePinStatus = vi.fn()
let mockIsPending = false
let mockIsUninstallPending = false
let mockInstalledApps: InstalledApp[] = []
let mockMediaType: string = MediaType.pc
vi.mock('@/next/navigation', () => ({
usePathname: () => '/',
@@ -26,15 +24,6 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => mockMediaType,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isPending: mockIsPending,
@@ -87,7 +76,6 @@ describe('SideBar', () => {
mockIsPending = false
mockIsUninstallPending = false
mockInstalledApps = []
mockMediaType = MediaType.pc
})
describe('Rendering', () => {
@@ -97,13 +85,6 @@ describe('SideBar', () => {
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
})
it('should expose an accessible name for the discovery link when the text is hidden', () => {
mockMediaType = MediaType.mobile
renderSideBar()
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
})
it('should render workspace items when installed apps exist', () => {
mockInstalledApps = [createInstalledApp()]
renderSideBar()
@@ -156,6 +137,17 @@ describe('SideBar', () => {
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
})
it('should render icon-only content when folded', () => {
mockInstalledApps = [createInstalledApp()]
renderSideBar()
fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'My App' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
})
})
describe('User Interactions', () => {
@@ -229,14 +221,4 @@ describe('SideBar', () => {
expectLoadingButton(screen.getByText('common.operation.confirm').closest('button'))
})
})
describe('Edge Cases', () => {
it('should hide NoApps and app names on mobile', () => {
mockMediaType = MediaType.mobile
renderSideBar()
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
expect(screen.queryByText('explore.sidebar.webApps')).not.toBeInTheDocument()
})
})
})
@@ -2,7 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import AppNavItem from '../index'
const baseProps = {
isMobile: false,
name: 'My App',
id: 'app-123',
icon_type: 'emoji' as const,
@@ -22,18 +21,12 @@ describe('AppNavItem', () => {
})
describe('Rendering', () => {
it('should render name and item operation on desktop', () => {
it('should render name and item operation when expanded', () => {
render(<AppNavItem {...baseProps} />)
expect(screen.getByText('My App')).toBeInTheDocument()
expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument()
})
it('should hide name on mobile', () => {
render(<AppNavItem {...baseProps} isMobile />)
expect(screen.queryByText('My App')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
@@ -43,6 +36,26 @@ describe('AppNavItem', () => {
const link = screen.getByRole('link', { name: 'My App' })
expect(link).toHaveAttribute('href', '/installed/app-123')
expect(link).toHaveAttribute('aria-label', 'My App')
expect(link).not.toHaveAttribute('aria-current')
})
it('should use a contextual accessible name when ariaLabel is provided', () => {
render(<AppNavItem {...baseProps} variant="mainNav" ariaLabel="Open My App web app" />)
const link = screen.getByRole('link', { name: 'Open My App web app' })
expect(link).toHaveAttribute('href', '/installed/app-123')
expect(link).toHaveAttribute('aria-label', 'Open My App web app')
expect(screen.getByText('My App')).toBeInTheDocument()
})
it('should expose selected state through the current link', () => {
render(<AppNavItem {...baseProps} isSelected />)
const link = screen.getByRole('link', { name: 'My App' })
expect(link).toHaveAttribute('aria-current', 'page')
})
it('should only show the row focus ring when the app link receives focus', () => {
@@ -50,8 +63,8 @@ describe('AppNavItem', () => {
const row = screen.getByText('My App').closest('.group')
expect(row).toHaveClass('has-[>a:focus-visible]:ring-2')
expect(row).toHaveClass('has-[>a:focus-visible]:ring-state-accent-solid')
expect(row).toHaveClass('has-[>a:focus-visible]:inset-ring-2')
expect(row).toHaveClass('has-[>a:focus-visible]:inset-ring-state-accent-solid')
expect(row).not.toHaveClass('focus-within:ring-2')
})
@@ -9,9 +9,9 @@ import ItemOperation from '@/app/components/explore/item-operation'
import Link from '@/next/link'
type IAppNavItemProps = {
isMobile: boolean
variant?: 'default' | 'mainNav'
name: string
ariaLabel?: string
id: string
icon_type: AppIconType | null
icon: string
@@ -25,9 +25,9 @@ type IAppNavItemProps = {
}
export default function AppNavItem({
isMobile,
variant = 'default',
name,
ariaLabel,
id,
icon_type,
icon,
@@ -47,43 +47,31 @@ export default function AppNavItem({
key={id}
className={cn(
isMainNav
? 'group flex h-8 items-center justify-between gap-2 rounded-lg py-0.5 pr-0.5 pl-2 transition-colors has-[>a:focus-visible]:ring-2 has-[>a:focus-visible]:ring-state-accent-solid has-[>a:focus-visible]:ring-inset'
: 'group flex h-8 items-center justify-between rounded-lg px-2 system-sm-medium text-sm font-normal text-components-menu-item-text has-[>a:focus-visible]:ring-2 has-[>a:focus-visible]:ring-state-accent-solid has-[>a:focus-visible]:ring-inset mobile:justify-center mobile:px-1',
isMainNav
? (isSelected ? 'bg-state-base-hover' : 'hover:bg-state-base-hover')
: (isSelected ? 'bg-state-base-active text-components-menu-item-text-active' : 'hover:bg-state-base-hover hover:text-components-menu-item-text-hover'),
? 'group flex h-8 items-center justify-between gap-2 rounded-lg py-0.5 pr-0.5 pl-2 transition-colors not-has-[>a[aria-current=page]]:hover:bg-state-base-hover has-[>a:focus-visible]:inset-ring-2 has-[>a:focus-visible]:inset-ring-state-accent-solid has-[>a[aria-current=page]]:bg-state-base-active'
: cn(
'group flex h-8 items-center rounded-lg system-sm-medium text-sm font-normal text-components-menu-item-text transition-colors not-has-[>a[aria-current=page]]:hover:bg-state-base-hover not-has-[>a[aria-current=page]]:hover:text-components-menu-item-text-hover has-[>a:focus-visible]:inset-ring-2 has-[>a:focus-visible]:inset-ring-state-accent-solid has-[>a[aria-current=page]]:bg-state-base-active has-[>a[aria-current=page]]:text-components-menu-item-text-active',
'w-full justify-start px-2 group-data-[folded=true]/sidebar:justify-center group-data-[folded=true]/sidebar:px-1',
),
)}
>
{isMobile && (
<Link
href={url}
aria-label={name}
title={name}
className="flex min-w-0 flex-1 items-center justify-center outline-hidden"
>
<AppIcon size="tiny" iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
</Link>
)}
{!isMobile && (
<>
<Link
href={url}
title={name}
className={cn(isMainNav ? 'flex min-w-0 flex-1 items-center gap-2 outline-hidden' : 'flex w-0 grow items-center space-x-2 outline-hidden')}
>
<AppIcon size="tiny" className={cn(isMainNav && 'size-5 rounded-md text-sm')} iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
<div className={cn(isMainNav ? 'min-w-0 flex-1 truncate py-1 pr-1 system-sm-regular' : 'truncate system-sm-regular text-components-menu-item-text')} title={name}>{name}</div>
</Link>
<div className="h-6 shrink-0" onClick={e => e.stopPropagation()}>
<ItemOperation
isPinned={isPinned}
togglePin={togglePin}
isShowDelete={!uninstallable && !isSelected}
onDelete={() => onDelete(id)}
/>
</div>
</>
)}
<Link
href={url}
aria-current={isSelected ? 'page' : undefined}
aria-label={ariaLabel ?? name}
title={name}
className={cn(isMainNav ? 'flex min-w-0 flex-1 items-center gap-2 outline-hidden' : 'flex w-0 grow items-center space-x-2 outline-hidden group-data-[folded=true]/sidebar:w-auto group-data-[folded=true]/sidebar:justify-center group-data-[folded=true]/sidebar:space-x-0')}
>
<AppIcon size="tiny" className={cn(isMainNav && 'size-5 rounded-md text-sm')} iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
<div className={cn(isMainNav ? 'min-w-0 flex-1 truncate py-1 pr-1 system-sm-regular' : 'truncate system-sm-regular text-components-menu-item-text group-data-[folded=true]/sidebar:hidden')} title={name}>{name}</div>
</Link>
<div className={cn(isMainNav ? 'h-6 shrink-0' : 'h-6 shrink-0 group-data-[folded=true]/sidebar:hidden')}>
<ItemOperation
isPinned={isPinned}
togglePin={togglePin}
isShowDelete={!uninstallable && !isSelected}
onDelete={() => onDelete(id)}
/>
</div>
</div>
)
}
+24 -28
View File
@@ -16,7 +16,6 @@ import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
import { usePathname, useSelectedLayoutSegments } from '@/next/navigation'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
@@ -40,8 +39,6 @@ const SideBar = () => {
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [isFold, {
toggle: toggleIsFold,
}] = useBoolean(false)
@@ -61,12 +58,10 @@ const SideBar = () => {
}
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
const shouldUseExpandedScrollArea = !isMobile && !isFold
const webAppsLabelId = React.useId()
const installedAppItems = installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => (
<React.Fragment key={id}>
<Item
isMobile={isMobile || isFold}
name={name}
icon_type={icon_type}
icon={icon}
@@ -87,21 +82,24 @@ const SideBar = () => {
))
return (
<div className={cn('flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}>
<div
data-folded={isFold ? 'true' : undefined}
className={cn('group/sidebar flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}
>
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
<Link
href="/"
aria-label={isMobile || isFold ? t('sidebar.title', { ns: 'explore' }) : undefined}
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 items-center gap-2 rounded-lg px-1 mobile:w-fit mobile:justify-center pc:w-full pc:justify-start')}
aria-label={isFold ? t('sidebar.title', { ns: 'explore' }) : undefined}
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 w-full items-center justify-start gap-2 rounded-lg px-1')}
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
<span aria-hidden="true" className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
</div>
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t('sidebar.title', { ns: 'explore' })}</div>}
{!isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t('sidebar.title', { ns: 'explore' })}</div>}
</Link>
</div>
{!isPending && installedApps.length === 0 && !isMobile && !isFold
{!isPending && installedApps.length === 0 && !isFold
&& (
<div className="mt-5">
<NoApps />
@@ -110,8 +108,8 @@ const SideBar = () => {
{installedApps.length > 0 && (
<div className="mt-5 flex min-h-0 flex-1 flex-col">
{!isMobile && !isFold && <p id={webAppsLabelId} className="mb-1.5 pl-2 system-xs-medium-uppercase break-all text-text-tertiary uppercase mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
{shouldUseExpandedScrollArea
{!isFold && <p id={webAppsLabelId} className="mb-1.5 pl-2 system-xs-medium-uppercase break-all text-text-tertiary uppercase">{t('sidebar.webApps', { ns: 'explore' })}</p>}
{!isFold
? (
<div className="min-h-0 flex-1">
<ScrollArea
@@ -133,22 +131,20 @@ const SideBar = () => {
</div>
)}
{!isMobile && (
<div className="mt-auto flex py-3">
<button
type="button"
aria-label={isFold ? t('sidebar.expandSidebar', { ns: 'layout' }) : t('sidebar.collapseSidebar', { ns: 'layout' })}
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden focus-visible:ring-inset"
onClick={toggleIsFold}
>
{isFold
? <span aria-hidden="true" className="i-ri-expand-right-line" />
: (
<span aria-hidden="true" className="i-ri-layout-left-2-line" />
)}
</button>
</div>
)}
<div className="mt-auto flex py-3">
<button
type="button"
aria-label={isFold ? t('sidebar.expandSidebar', { ns: 'layout' }) : t('sidebar.collapseSidebar', { ns: 'layout' })}
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-hover focus-visible:outline-hidden"
onClick={toggleIsFold}
>
{isFold
? <span aria-hidden="true" className="i-ri-expand-right-line" />
: (
<span aria-hidden="true" className="i-ri-layout-left-2-line" />
)}
</button>
</div>
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialogContent>
@@ -26,10 +26,6 @@ import { useProviderContext } from '@/context/provider-context'
import Link from '@/next/link'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
const mainNavMenuGroupClassName = 'p-1'
const mainNavMenuItemClassName = 'mx-0 h-8 gap-1 px-3 py-1'
const mainNavMenuSubPopupClassName = 'w-60 max-h-[360px] bg-components-panel-bg-blur! p-1! backdrop-blur-[5px]'
type MainNavRadioItemContentProps = {
iconClassName?: string
label: ReactNode
@@ -56,7 +52,7 @@ function AppearanceSubmenu() {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={mainNavMenuItemClassName}>
<DropdownMenuSubTrigger className="mx-0 h-8 gap-1 px-3 py-1">
<MenuItemContent
iconClassName="i-ri-sun-line"
label={t('account.appearanceLabel', { ns: 'common' })}
@@ -65,16 +61,16 @@ function AppearanceSubmenu() {
<DropdownMenuSubContent
placement="right-start"
sideOffset={6}
popupClassName={mainNavMenuSubPopupClassName}
popupClassName="w-[139px] max-h-[360px] bg-components-panel-bg-blur p-1 backdrop-blur-[5px]"
>
<DropdownMenuRadioGroup value={theme || 'system'} onValueChange={value => setTheme(value as Theme)}>
<DropdownMenuRadioItem value="light" closeOnClick className={mainNavMenuItemClassName}>
<DropdownMenuRadioItem value="light" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1">
<MainNavRadioItemContent iconClassName="i-ri-sun-line" label={t('account.appearanceLight', { ns: 'common' })} />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark" closeOnClick className={mainNavMenuItemClassName}>
<DropdownMenuRadioItem value="dark" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1">
<MainNavRadioItemContent iconClassName="i-ri-moon-line" label={t('account.appearanceDark', { ns: 'common' })} />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system" closeOnClick className={mainNavMenuItemClassName}>
<DropdownMenuRadioItem value="system" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1">
<MainNavRadioItemContent iconClassName="i-ri-computer-line" label={t('account.appearanceSystem', { ns: 'common' })} />
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
@@ -97,7 +93,7 @@ export function MainNavMenuContent({
return (
<>
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
<DropdownMenuGroup className="p-1">
<div className="flex items-center gap-3 rounded-xl bg-gradient-to-b from-background-section-burn to-background-section p-3">
<div className="flex min-w-0 grow flex-col gap-1">
<div className="flex min-w-0 items-center gap-1">
@@ -114,9 +110,9 @@ export function MainNavMenuContent({
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" className="shrink-0" />
</div>
</DropdownMenuGroup>
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
<DropdownMenuGroup className="p-1">
<DropdownMenuLinkItem
className={cn('justify-between', mainNavMenuItemClassName)}
className="mx-0 h-8 justify-between gap-1 px-3 py-1"
render={<Link href="/account" />}
>
<MenuItemContent
@@ -126,7 +122,7 @@ export function MainNavMenuContent({
/>
</DropdownMenuLinkItem>
<DropdownMenuItem
className={mainNavMenuItemClassName}
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
>
<MenuItemContent
@@ -137,9 +133,9 @@ export function MainNavMenuContent({
<AppearanceSubmenu />
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0! bg-divider-subtle" />
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
<DropdownMenuGroup className="p-1">
<DropdownMenuItem
className={mainNavMenuItemClassName}
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => {
void onLogout()
}}
@@ -180,5 +180,22 @@ describe('InstallFromMarketplace Component', () => {
// Assert
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should use the marketplace callback action when provided', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: mockPlugins,
isLoading: false,
})
const onOpenMarketplace = vi.fn()
render(<InstallFromMarketplace providers={mockProviders} searchText="" onOpenMarketplace={onOpenMarketplace} />)
// Act
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' }))
// Assert
expect(onOpenMarketplace).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' })).not.toBeInTheDocument()
})
})
})
@@ -18,6 +18,7 @@ import InstallFromMarketplace from './install-from-marketplace'
type DataSourcePageProps = {
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
onOpenMarketplace?: () => void
stickyToolbar?: boolean
}
@@ -53,6 +54,7 @@ function DataSourceListSkeleton() {
const DataSourcePage = ({
layout,
onOpenMarketplace,
stickyToolbar,
}: DataSourcePageProps) => {
const { t } = useTranslation()
@@ -164,6 +166,7 @@ const DataSourcePage = ({
<InstallFromMarketplace
providers={dataSources}
searchText={searchText}
onOpenMarketplace={onOpenMarketplace}
/>
)
}
@@ -1,10 +1,6 @@
import type { DataSourceAuth } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiArrowDownSLine,
RiArrowRightUpLine,
} from '@remixicon/react'
import { useTheme } from 'next-themes'
import {
memo,
@@ -25,10 +21,12 @@ import {
} from './hooks'
type InstallFromMarketplaceProps = {
onOpenMarketplace?: () => void
providers: DataSourceAuth[]
searchText: string
}
const InstallFromMarketplace = ({
onOpenMarketplace,
providers,
searchText,
}: InstallFromMarketplaceProps) => {
@@ -58,15 +56,28 @@ const InstallFromMarketplace = ({
className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left system-md-semibold text-text-primary"
onClick={() => setCollapse(!collapse)}
>
<RiArrowDownSLine className={cn('size-4', collapse && '-rotate-90')} aria-hidden="true" />
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} aria-hidden="true" />
{t('modelProvider.installDataSource', { ns: 'common' })}
</button>
<div className="mb-2 flex items-center pt-2">
<span className="pr-1 system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
<Link target="_blank" href={getMarketplaceCategoryUrl(PluginCategoryEnum.datasource, { theme })} className="inline-flex items-center system-sm-medium text-text-accent">
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<RiArrowRightUpLine className="size-4" />
</Link>
{onOpenMarketplace
? (
<button
type="button"
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
onClick={onOpenMarketplace}
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</button>
)
: (
<Link target="_blank" rel="noopener noreferrer" href={getMarketplaceCategoryUrl(PluginCategoryEnum.datasource, { theme })} className="inline-flex items-center system-sm-medium text-text-accent">
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</Link>
)}
</div>
</div>
{!collapse && isAllPluginsLoading && <Loading type="area" />}
@@ -132,4 +132,15 @@ describe('InstallFromMarketplace', () => {
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute('href', 'https://marketplace.test/plugins/model?theme=light')
})
it('should use the marketplace callback action when provided', () => {
const onOpenMarketplace = vi.fn()
render(<InstallFromMarketplace providers={mockProviders} searchText="" onOpenMarketplace={onOpenMarketplace} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' }))
expect(onOpenMarketplace).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' })).not.toBeInTheDocument()
})
})
@@ -31,6 +31,7 @@ type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-co
type Props = Readonly<{
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
onOpenMarketplace?: () => void
onSearchTextChange?: (value: string) => void
searchText: string
stickyToolbar?: boolean
@@ -41,6 +42,7 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
const ModelProviderPage = ({
layout,
onOpenMarketplace,
onSearchTextChange,
searchText,
stickyToolbar,
@@ -139,6 +141,7 @@ const ModelProviderPage = ({
ttsDefaultModel={ttsDefaultModel}
isLoading={isDefaultModelLoading}
hideProviderSettingsFooter={hideSystemModelSelectorProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
/>
)
@@ -210,6 +213,7 @@ const ModelProviderPage = ({
enableMarketplace={enableMarketplace}
searchText={searchText}
pluginDetailMap={pluginDetailMap}
onOpenMarketplace={onOpenMarketplace}
/>
)
@@ -19,10 +19,12 @@ import {
} from './hooks'
type InstallFromMarketplaceProps = {
onOpenMarketplace?: () => void
providers: ModelProvider[]
searchText: string
}
const InstallFromMarketplace = ({
onOpenMarketplace,
providers,
searchText,
}: InstallFromMarketplaceProps) => {
@@ -57,15 +59,28 @@ const InstallFromMarketplace = ({
</button>
<div className="flex items-center gap-1">
<span className="system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
<Link
target="_blank"
rel="noopener noreferrer"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
className="inline-flex items-center system-sm-medium text-text-accent"
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" />
</Link>
{onOpenMarketplace
? (
<button
type="button"
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
onClick={onOpenMarketplace}
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</button>
)
: (
<Link
target="_blank"
rel="noopener noreferrer"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
className="inline-flex items-center system-sm-medium text-text-accent"
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</Link>
)}
</div>
</div>
{!collapse && isAllPluginsLoading && <Loading type="area" />}
@@ -21,6 +21,7 @@ type ModelProviderPageBodyProps = {
enableMarketplace: boolean
searchText: string
pluginDetailMap: Map<string, PluginDetail>
onOpenMarketplace?: () => void
}
function ModelProviderCardSkeleton() {
@@ -79,6 +80,7 @@ function EmptyProviderState({
marketplace: (
<a
href="#model-provider-marketplace"
aria-label={t('marketplace.difyMarketplace', { ns: 'plugin' })}
className="system-xs-medium text-text-accent hover:underline"
/>
),
@@ -128,6 +130,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
enableMarketplace,
searchText,
pluginDetailMap,
onOpenMarketplace,
}) => {
const { t } = useTranslation()
@@ -165,6 +168,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<InstallFromMarketplace
providers={providers}
searchText={searchText}
onOpenMarketplace={onOpenMarketplace}
/>
</div>
)}
@@ -33,6 +33,7 @@ type ModelSelectorProps = {
showDeprecatedWarnIcon?: boolean
hideProviderSettingsFooter?: boolean
onConfigureEmptyState?: () => void
onOpenMarketplace?: () => void
providerSettingsSource?: 'agent'
showModelMeta?: boolean
}
@@ -49,6 +50,7 @@ function ModelSelector({
showDeprecatedWarnIcon = true,
hideProviderSettingsFooter,
onConfigureEmptyState,
onOpenMarketplace,
providerSettingsSource,
showModelMeta,
}: ModelSelectorProps) {
@@ -168,6 +170,7 @@ function ModelSelector({
hideProviderSettingsFooter={hideProviderSettingsFooter}
providerSettingsSource={providerSettingsSource}
onConfigureEmptyState={onConfigureEmptyState ? handleConfigureEmptyState : undefined}
onOpenMarketplace={onOpenMarketplace}
onInputValueChange={setInputValue}
onHide={handleHide}
/>
@@ -15,6 +15,7 @@ type MarketplaceSectionProps = {
theme?: string
onMarketplaceCollapsedChange: (collapsed: boolean) => void
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
onOpenMarketplace?: () => void
}
function MarketplaceSection({
@@ -26,6 +27,7 @@ function MarketplaceSection({
theme,
onMarketplaceCollapsedChange,
onInstallPlugin,
onOpenMarketplace,
}: MarketplaceSectionProps) {
const { t } = useTranslation()
@@ -82,17 +84,32 @@ function MarketplaceSection({
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" />
</a>
{onOpenMarketplace
? (
<button
type="button"
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left"
onClick={onOpenMarketplace}
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" aria-hidden="true" />
</button>
)
: (
<a
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" aria-hidden="true" />
</a>
)}
</div>
)}
</div>
@@ -42,6 +42,7 @@ export type PopupProps = {
providerSettingsSource?: 'agent'
onConfigureEmptyState?: () => void
onInputValueChange: (value: string) => void
onOpenMarketplace?: () => void
onHide: () => void
}
function Popup({
@@ -53,6 +54,7 @@ function Popup({
providerSettingsSource,
onConfigureEmptyState,
onInputValueChange,
onOpenMarketplace,
onHide,
}: PopupProps) {
const { t } = useTranslation()
@@ -236,6 +238,7 @@ function Popup({
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
onOpenMarketplace={onOpenMarketplace}
/>
)}
</div>
@@ -38,6 +38,7 @@ type SystemModelSelectorProps = {
notConfigured: boolean
isLoading?: boolean
hideProviderSettingsFooter?: boolean
onOpenMarketplace?: () => void
}
type SystemModelLabelKey
@@ -64,6 +65,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
notConfigured,
isLoading,
hideProviderSettingsFooter,
onOpenMarketplace,
}) => {
const { t } = useTranslation()
const { workspacePermissionKeys } = useAppContext()
@@ -189,6 +191,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentTextGenerationDefaultModel}
modelList={textGenerationModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)}
@@ -202,6 +205,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentEmbeddingsDefaultModel}
modelList={embeddingModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)}
@@ -215,6 +219,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentRerankDefaultModel}
modelList={rerankModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
@@ -228,6 +233,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentSpeech2textDefaultModel}
modelList={speech2textModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
@@ -241,6 +247,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentTTSDefaultModel}
modelList={ttsModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
@@ -140,14 +140,23 @@ vi.mock('@/app/components/plugins/plugin-page/plugin-tasks', () => ({
default: () => <button type="button" aria-label="plugin tasks">tasks</button>,
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace-query', () => ({
__esModule: true,
default: ({ installContextCategory }: { installContextCategory?: string }) => (
<div data-testid="install-from-marketplace-query" data-install-context-category={installContextCategory} />
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
__esModule: true,
default: ({
layout,
onOpenMarketplace,
onSearchTextChange,
searchText,
}: {
layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode
onOpenMarketplace?: () => void
onSearchTextChange?: (value: string) => void
searchText: string
}) => {
@@ -160,7 +169,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
/>
</div>
)
const body = <div data-testid="model-provider-page" />
const body = (
<div data-testid="model-provider-page">
<button type="button" aria-label="model provider marketplace" onClick={onOpenMarketplace}>marketplace</button>
</div>
)
if (layout)
return layout({ body, toolbar })
@@ -179,9 +192,13 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
__esModule: true,
default: ({ layout }: { layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode }) => {
default: ({ layout, onOpenMarketplace }: { layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode, onOpenMarketplace?: () => void }) => {
const toolbar = <div data-testid="data-source-toolbar" />
const body = <div data-testid="data-source-page" />
const body = (
<div data-testid="data-source-page">
<button type="button" aria-label="data source marketplace" onClick={onOpenMarketplace}>marketplace</button>
</div>
)
if (layout)
return layout({ body, toolbar })
@@ -292,6 +309,7 @@ describe('IntegrationsPage', () => {
renderIntegrationsPage({ section: 'provider' })
expect(screen.getByTestId('model-provider-page')).toBeInTheDocument()
expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute('data-install-context-category', 'model')
expect(screen.getByTestId('model-provider-toolbar').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6', 'pt-3', 'pb-2')
expect(within(screen.getByTestId('model-provider-toolbar').closest('section')!).getByText('common.settings.provider')).toHaveClass('title-2xl-semi-bold')
expect(screen.getByTestId('model-provider-page').parentElement).toHaveClass('max-w-[1600px]', 'px-6')
@@ -353,13 +371,17 @@ describe('IntegrationsPage', () => {
expect(screen.getByRole('link', { name: 'plugin.categorySingle.extension' })).toHaveAttribute('href', '/integrations/extension')
})
it('opens the integrations marketplace path from plugin category empty states', () => {
renderIntegrationsPage({ section: 'extension' })
it.each([
['provider', 'model provider marketplace', '/plugins/model'],
['data-source', 'data source marketplace', '/plugins/datasource'],
['extension', 'empty marketplace', '/plugins/extension'],
] as const)('opens the %s marketplace path from integrations', (section, buttonName, marketplacePath) => {
renderIntegrationsPage({ section })
fireEvent.click(screen.getByRole('button', { name: 'empty marketplace' }))
fireEvent.click(screen.getByRole('button', { name: buttonName }))
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining('/plugins/extension?source='),
expect.stringContaining(`${marketplacePath}?source=`),
'_blank',
'noopener,noreferrer',
)
@@ -380,6 +402,7 @@ describe('IntegrationsPage', () => {
const { unmount } = renderIntegrationsPage({ section: 'data-source' })
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute('data-install-context-category', 'datasource')
expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent('plugin.debugInfo.title')
unmount()
@@ -626,13 +649,13 @@ describe('IntegrationsPage', () => {
expect(screen.getAllByText('common.settings.customEndpoint')).toHaveLength(2)
expect(screen.getByText('common.apiBasedExtensionPage.description')).toBeInTheDocument()
expect(screen.getByTestId('api-extension-toolbar')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/endpoint')
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
})
it.each([
['trigger', 'plugin.categorySingle.trigger', 'common.triggerPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin'],
['extension', 'plugin.categorySingle.extension', 'common.extensionPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/endpoint'],
['extension', 'plugin.categorySingle.extension', 'common.extensionPage.description', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension'],
['agent-strategy', 'plugin.categorySingle.agent', 'common.agentStrategyPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin'],
] as const)('renders the %s header with a docs link', (section, title, description, href) => {
renderIntegrationsPage({ section })
@@ -1,21 +1,27 @@
import type { ReactNode } from 'react'
import { act, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import PluginCategoryPage from '../plugin-category-page'
const {
mockContainerRef,
mockFetchManifestFromMarketPlace,
mockSetInstallState,
mockUseUploader,
mockUsePluginInstallation,
mockPluginInstallationPermission,
} = vi.hoisted(() => ({
mockContainerRef: { current: null },
mockFetchManifestFromMarketPlace: vi.fn(),
mockSetInstallState: vi.fn(),
mockUseUploader: vi.fn((_: unknown) => ({
dragging: false,
fileUploader: { current: null },
fileChangeHandle: undefined,
removeFile: undefined,
})),
mockUsePluginInstallation: vi.fn(),
mockPluginInstallationPermission: {
restrict_to_marketplace_only: false,
},
@@ -56,6 +62,34 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', ()
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({
installContextCategory,
onClose,
uniqueIdentifier,
}: {
installContextCategory?: PluginCategoryEnum
onClose: () => void
uniqueIdentifier: string
}) => (
<div
data-testid="install-from-marketplace"
data-install-context-category={installContextCategory}
data-unique-identifier={uniqueIdentifier}
>
<button type="button" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('@/hooks/use-query-params', () => ({
usePluginInstallation: () => mockUsePluginInstallation(),
}))
vi.mock('@/service/plugins', () => ({
fetchManifestFromMarketPlace: (...args: unknown[]) => mockFetchManifestFromMarketPlace(...args),
}))
type UploaderOptions = {
onFileChange: (file: File | null) => void
}
@@ -64,6 +98,7 @@ describe('PluginCategoryPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPluginInstallationPermission.restrict_to_marketplace_only = false
mockUsePluginInstallation.mockReturnValue([{ packageId: null, bundleInfo: null }, mockSetInstallState])
})
it.each([
@@ -112,6 +147,33 @@ describe('PluginCategoryPage', () => {
}))
})
it('keeps marketplace install params while install permission is loading', async () => {
const packageId = 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2'
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
render(<PluginCategoryPage canInstall={false} isInstallPermissionLoading category={PluginCategoryEnum.agent} />)
await waitFor(() => {
expect(mockUseUploader).toHaveBeenCalled()
})
expect(mockFetchManifestFromMarketPlace).not.toHaveBeenCalled()
expect(mockSetInstallState).not.toHaveBeenCalled()
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
})
it('clears marketplace install params when install permission is unavailable after loading', async () => {
const packageId = 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2'
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
render(<PluginCategoryPage canInstall={false} category={PluginCategoryEnum.agent} />)
await waitFor(() => {
expect(mockSetInstallState).toHaveBeenCalledWith(null)
})
expect(mockFetchManifestFromMarketPlace).not.toHaveBeenCalled()
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
})
it('opens the local package installer for supported dropped files', () => {
render(<PluginCategoryPage category={PluginCategoryEnum.tool} />)
@@ -124,6 +186,33 @@ describe('PluginCategoryPage', () => {
expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-install-context-category', PluginCategoryEnum.tool)
})
it('opens the marketplace installer from package id query params', async () => {
const packageId = 'langgenius/telegram_trigger:0.0.6@923a18de89d8cdb7f419d0dff60bf08a8b81b65fef6bf606cf0ce4b0ee56a9ca'
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
mockFetchManifestFromMarketPlace.mockResolvedValue({
data: {
plugin: {
org: 'langgenius',
name: 'telegram_trigger',
category: PluginCategoryEnum.trigger,
},
version: { version: '0.0.6' },
},
})
render(<PluginCategoryPage category={PluginCategoryEnum.trigger} />)
await waitFor(() => {
expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith(encodeURIComponent(packageId))
expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute('data-unique-identifier', packageId)
})
expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute('data-install-context-category', PluginCategoryEnum.trigger)
fireEvent.click(screen.getByRole('button', { name: 'close' }))
expect(mockSetInstallState).toHaveBeenCalledWith(null)
})
it('ignores dropped files when install permission is unavailable', () => {
render(<PluginCategoryPage canInstall={false} category={PluginCategoryEnum.agent} />)
@@ -46,6 +46,7 @@ describe('integration routes', () => {
[undefined, { type: 'redirect', destination: '/integrations/model-provider' }],
[[], { type: 'redirect', destination: '/integrations/model-provider' }],
[['model-provider'], { type: 'section', section: 'provider' }],
[['model-provider', 'plugins'], { type: 'redirect', destination: '/integrations/model-provider' }],
[['tools'], { type: 'redirect', destination: '/integrations/tools/built-in' }],
[['tools', 'built-in'], { type: 'section', section: 'builtin' }],
[['tool', 'api'], { type: 'redirect', destination: '/integrations/tools/api' }],
@@ -57,6 +58,10 @@ describe('integration routes', () => {
[['trigger'], { type: 'section', section: 'trigger' }],
[['agent-strategy'], { type: 'section', section: 'agent-strategy' }],
[['extension'], { type: 'section', section: 'extension' }],
[['agent-strategy', 'plugins'], { type: 'redirect', destination: '/integrations/agent-strategy' }],
[['trigger', 'plugins'], { type: 'redirect', destination: '/integrations/trigger' }],
[['tools', 'built-in', 'plugins'], { type: 'redirect', destination: '/integrations/tools/built-in' }],
[['data-source', 'plugins'], { type: 'redirect', destination: '/integrations/data-source' }],
[['model-providers'], { type: 'not-found' }],
[['data-sources'], { type: 'not-found' }],
[['api-extensions'], { type: 'not-found' }],
@@ -81,6 +86,30 @@ describe('integration routes', () => {
})
})
it('preserves marketplace install query params when redirecting nested marketplace callbacks', () => {
expect(getIntegrationRouteTargetBySlug(['agent-strategy', 'plugins'], {
'package-ids': '["junjiem/mcp_see_agent"]',
})).toEqual({
type: 'redirect',
destination: '/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
})
})
it('preserves marketplace install query params when redirecting model and data source callbacks', () => {
expect(getIntegrationRouteTargetBySlug(['model-provider', 'plugins'], {
'package-ids': '["langgenius/openai"]',
})).toEqual({
type: 'redirect',
destination: '/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
})
expect(getIntegrationRouteTargetBySlug(['data-source', 'plugins'], {
'package-ids': '["langgenius/notion_datasource"]',
})).toEqual({
type: 'redirect',
destination: '/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
})
})
it.each([
[{}, '/integrations/tools/built-in'],
[{ section: 'provider' }, '/integrations/model-provider'],
+5 -2
View File
@@ -43,9 +43,9 @@ const headerDescriptionDocPaths: Partial<Record<IntegrationSection, string>> = {
'custom-tool': '/use-dify/workspace/tools#custom-tool',
'workflow-tool': '/use-dify/workspace/tools#workflow-tool',
'mcp': '/use-dify/build/mcp',
'custom-endpoint': '/use-dify/workspace/api-extension/api-extension',
'custom-endpoint': '/develop-plugin/dev-guides-and-walkthroughs/endpoint',
'trigger': '/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin',
'extension': '/develop-plugin/dev-guides-and-walkthroughs/endpoint',
'extension': '/use-dify/workspace/api-extension/api-extension',
'agent-strategy': '/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin',
}
@@ -111,6 +111,7 @@ export default function IntegrationsPage({
canUpdatePlugin,
handlePermissionChange,
isPluginCategory,
isReferenceSettingLoading,
permission,
showPermissionQuickPanel,
showPluginCategorySetting,
@@ -285,6 +286,7 @@ export default function IntegrationsPage({
onSwitchToMarketplace={handleSwitchToMarketplace}
canInstallPlugin={canInstallPlugin}
canDeletePlugin={canDeletePlugin}
isInstallPermissionLoading={isReferenceSettingLoading}
canUpdatePlugin={canUpdatePlugin}
pluginCategoryToolbarAction={pluginSettingAction}
/>
@@ -310,6 +312,7 @@ export default function IntegrationsPage({
onSwitchToMarketplace={handleSwitchToMarketplace}
canInstallPlugin={canInstallPlugin}
canDeletePlugin={canDeletePlugin}
isInstallPermissionLoading={isReferenceSettingLoading}
canUpdatePlugin={canUpdatePlugin}
pluginCategoryToolbarAction={pluginSettingAction}
/>
@@ -5,6 +5,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useMemo, useState } from 'react'
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
import InstallFromMarketplaceQuery from '@/app/components/plugins/install-plugin/install-from-marketplace-query'
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
import { PluginPageContextProvider } from '@/app/components/plugins/plugin-page/context-provider'
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
@@ -16,6 +17,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
type PluginCategoryPageProps = {
canInstall?: boolean
canDeletePlugin?: boolean
isInstallPermissionLoading?: boolean
canUpdatePlugin?: boolean
category: PluginCategoryEnum
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
@@ -28,6 +30,7 @@ const supportedLocalPackageExtensions = SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS.sp
const PluginCategoryPageContent = ({
canInstall = true,
canDeletePlugin = true,
isInstallPermissionLoading = false,
canUpdatePlugin = true,
category,
layout,
@@ -93,6 +96,11 @@ const PluginCategoryPageContent = ({
onSuccess={noop}
/>
)}
<InstallFromMarketplaceQuery
canInstallPlugin={canInstall}
isPermissionLoading={isInstallPermissionLoading}
installContextCategory={category}
/>
<input
ref={fileUploader}
className="hidden"
@@ -108,6 +116,7 @@ const PluginCategoryPageContent = ({
const PluginCategoryPage = ({
canInstall = true,
canDeletePlugin = true,
isInstallPermissionLoading = false,
canUpdatePlugin = true,
category,
layout,
@@ -125,6 +134,7 @@ const PluginCategoryPage = ({
<PluginCategoryPageContent
canInstall={canInstall}
canDeletePlugin={canDeletePlugin}
isInstallPermissionLoading={isInstallPermissionLoading}
canUpdatePlugin={canUpdatePlugin}
category={category}
layout={layout}
+21
View File
@@ -136,8 +136,29 @@ type IntegrationRouteTarget
| { type: 'section', section: IntegrationSection }
| { type: 'not-found' }
const getSectionByCanonicalPath = (path: string) => {
const entry = Object.entries(integrationPathBySection).find(([, sectionPath]) => {
return sectionPath.replace('/integrations/', '') === path
})
return entry?.[0] as IntegrationSection | undefined
}
export const getIntegrationRouteTargetBySlug = (slug?: string[], searchParams?: IntegrationRouteSearchParams): IntegrationRouteTarget => {
const path = slug?.join('/') ?? ''
const nestedMarketplaceCallbackPath = path.endsWith('/plugins')
? path.slice(0, -'/plugins'.length)
: undefined
const nestedMarketplaceCallbackSection = nestedMarketplaceCallbackPath
? getSectionByCanonicalPath(nestedMarketplaceCallbackPath)
: undefined
if (nestedMarketplaceCallbackSection) {
return {
type: 'redirect',
destination: appendSearchParams(buildIntegrationPath(nestedMarketplaceCallbackSection), searchParams),
}
}
switch (path) {
case '':
@@ -5,6 +5,7 @@ import type { IntegrationSection } from './routes'
import { ApiBasedExtensionPage } from '@/app/components/header/account-setting/api-based-extension-page'
import DataSourcePage from '@/app/components/header/account-setting/data-source-page-new'
import ModelProviderPage from '@/app/components/header/account-setting/model-provider-page'
import InstallFromMarketplaceQuery from '@/app/components/plugins/install-plugin/install-from-marketplace-query'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { toolsContentFrameClassNames, toolsContentInsetClassNames } from '@/app/components/tools/content-inset'
import { IntegrationPageHeader } from './page-header'
@@ -15,6 +16,7 @@ import ToolProviderList from './tool-provider-list'
type IntegrationSectionRendererProps = {
canInstallPlugin?: boolean
canDeletePlugin?: boolean
isInstallPermissionLoading?: boolean
canUpdatePlugin?: boolean
description?: ReactNode
onProviderSearchTextChange: (value: string) => void
@@ -29,6 +31,7 @@ type IntegrationSectionRendererProps = {
const IntegrationSectionRenderer = ({
canInstallPlugin = true,
canDeletePlugin = true,
isInstallPermissionLoading = false,
canUpdatePlugin = true,
description,
onProviderSearchTextChange,
@@ -74,6 +77,7 @@ const IntegrationSectionRenderer = ({
<PluginCategoryPage
canInstall={canInstallPlugin}
canDeletePlugin={canDeletePlugin}
isInstallPermissionLoading={isInstallPermissionLoading}
canUpdatePlugin={canUpdatePlugin}
category={category}
layout={renderDirectLayout}
@@ -81,17 +85,28 @@ const IntegrationSectionRenderer = ({
toolbarAction={pluginCategoryToolbarAction}
/>
)
const renderMarketplaceInstallQuery = (category: PluginCategoryEnum) => (
<InstallFromMarketplaceQuery
canInstallPlugin={canInstallPlugin}
isPermissionLoading={isInstallPermissionLoading}
installContextCategory={category}
/>
)
switch (section) {
case 'provider':
return (
<ModelProviderPage
hideSystemModelSelectorProviderSettingsFooter
layout={renderScrollableLayout}
searchText={providerSearchText}
stickyToolbar
onSearchTextChange={onProviderSearchTextChange}
/>
<>
<ModelProviderPage
hideSystemModelSelectorProviderSettingsFooter
layout={renderScrollableLayout}
onOpenMarketplace={onSwitchToMarketplace}
searchText={providerSearchText}
stickyToolbar
onSearchTextChange={onProviderSearchTextChange}
/>
{renderMarketplaceInstallQuery(PluginCategoryEnum.model)}
</>
)
case 'builtin':
return renderPluginCategoryPage(PluginCategoryEnum.tool)
@@ -103,7 +118,10 @@ const IntegrationSectionRenderer = ({
return <ToolProviderList category="workflow" contentInset="compact" layout={renderDirectLayout} />
case 'data-source':
return (
<DataSourcePage stickyToolbar layout={renderScrollableLayout} />
<>
<DataSourcePage stickyToolbar layout={renderScrollableLayout} onOpenMarketplace={onSwitchToMarketplace} />
{renderMarketplaceInstallQuery(PluginCategoryEnum.datasource)}
</>
)
case 'custom-endpoint':
return <ApiBasedExtensionPage layout={renderScrollableLayout} />
@@ -422,7 +422,11 @@ describe('MainNav', () => {
it('aligns the global navigation spacing with the main sidebar design', () => {
mockInstalledApps = [createInstalledApp()]
renderMainNav()
const { container } = renderMainNav()
const mainNav = container.querySelector('aside')
expect(mainNav).toHaveClass('w-62', 'p-1')
expect(mainNav?.firstElementChild).toHaveClass('w-60')
const logoLink = screen.getByLabelText('Dify')
expect(logoLink).not.toHaveClass('px-2')
@@ -436,6 +440,8 @@ describe('MainNav', () => {
expect(webAppsButton.parentElement).toHaveClass('py-1', 'pr-2', 'pl-2')
const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })
expect(helpButton.parentElement?.parentElement).toHaveClass('w-60')
expect(helpButton.parentElement?.parentElement).not.toHaveClass('w-full')
expect(helpButton.parentElement).toHaveClass('shrink-0', 'rounded-full', 'p-1')
})
@@ -1078,7 +1084,8 @@ describe('MainNav', () => {
})
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Beta Tool' })).toHaveAttribute('href', '/installed/installed-2')
expect(screen.getByText('Beta Tool')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' })).toHaveAttribute('href', '/installed/installed-2')
})
it('renders web app skeleton rows while installed apps are loading', () => {
@@ -148,8 +148,8 @@ const WebAppsSectionContent = () => {
<AppNavItem
key={id}
variant="mainNav"
isMobile={false}
name={app.name}
ariaLabel={t('mainNav.webApps.openApp', { ns: 'common', name: app.name })}
icon_type={app.icon_type}
icon={app.icon}
icon_background={app.icon_background}
+2 -1
View File
@@ -225,7 +225,7 @@ const MainNav = ({
: 'w-16 bg-background-body p-1'
: showSnippetDetailBottomNavigation
? 'w-16 bg-background-body p-1'
: 'w-60 flex-col',
: 'w-62 flex-col p-1',
'bg-background-body',
className,
)}
@@ -233,6 +233,7 @@ const MainNav = ({
<div
className={cn(
'flex min-h-0 flex-1 flex-col',
!showDetailNavigation && !showSnippetDetailBottomNavigation && 'w-60 overflow-hidden',
showDetailNavigation && (
isDetailNavigationHoverPreviewOpen
? 'absolute top-1 bottom-1 left-1 z-40 w-60 overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg shadow-lg'
@@ -1,4 +1,4 @@
import { getLegacyPluginRedirectPath } from '../plugin-routes'
import { getFirstPackageIdFromSearchParams, getInstallRedirectPathByPluginCategory, getInstallRedirectPathFromSearchParams, getLegacyPluginRedirectPath, shouldResolveInstallCategoryRedirect } from '../plugin-routes'
describe('plugin routes', () => {
it.each([
@@ -13,10 +13,91 @@ describe('plugin routes', () => {
[{ tab: 'trigger' }, '/integrations/trigger'],
[{ tab: 'agent-strategy' }, '/integrations/agent-strategy'],
[{ tab: 'extension' }, '/integrations/extension'],
[
{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
],
])('redirects legacy plugin category URLs for search params %j', (searchParams, expected) => {
expect(getLegacyPluginRedirectPath(searchParams)).toBe(expected)
})
it.each([
{ 'package-ids': '["langgenius/telegram_trigger"]' },
{ 'tab': 'plugins', 'package-ids': '["langgenius/telegram_trigger"]' },
{ 'bundle-info': '{"org":"langgenius","name":"bundle","version":"1.0.0"}' },
])('keeps install deep links on the legacy plugin page for search params %j', (searchParams) => {
expect(getLegacyPluginRedirectPath(searchParams)).toBeUndefined()
})
it('parses the first package id from marketplace install search params', () => {
expect(getFirstPackageIdFromSearchParams({
'package-ids': '["junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2"]',
})).toBe('junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2')
})
it.each([
[{ 'package-ids': '["junjiem/mcp_see_agent"]' }, true],
[{ 'tab': 'plugins', 'package-ids': '["junjiem/mcp_see_agent"]' }, true],
[{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]' }, false],
[{ 'bundle-info': '{"org":"langgenius","name":"bundle","version":"1.0.0"}' }, false],
])('detects package install deep links that need category resolution for search params %j', (searchParams, expected) => {
expect(shouldResolveInstallCategoryRedirect(searchParams)).toBe(expected)
})
it.each([
[
'model',
{ 'package-ids': '["langgenius/openai"]' },
'/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
],
[
'agent-strategy',
{ 'package-ids': '["junjiem/mcp_see_agent"]' },
'/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
],
[
'trigger',
{ 'tab': 'plugins', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
],
[
'datasource',
{ 'package-ids': '["langgenius/notion_datasource"]' },
'/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
],
])('builds install redirect paths from marketplace plugin category %s', (category, searchParams, expected) => {
expect(getInstallRedirectPathByPluginCategory(category, searchParams)).toBe(expected)
})
it.each([
[
{ 'category': 'agent-strategy', 'package-ids': '["junjiem/mcp_see_agent"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
],
[
{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
],
[
{ 'category': 'model', 'package-ids': '["langgenius/openai"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
],
[
{ 'category': 'datasource', 'package-ids': '["langgenius/notion_datasource"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
],
[
{ 'category': 'bundle', 'package-ids': '["langgenius/bundle"]' },
undefined,
],
[
{ category: 'agent-strategy' },
undefined,
],
])('builds install redirect paths directly from install search params %j', (searchParams, expected) => {
expect(getInstallRedirectPathFromSearchParams(searchParams)).toBe(expected)
})
it.each([
[{ tab: 'discover' }, '/marketplace'],
[{ tab: 'discover', category: 'extension' }, '/marketplace?category=extension'],
@@ -0,0 +1,110 @@
'use client'
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../../types'
import { useEffect, useState } from 'react'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { usePluginInstallation } from '@/hooks/use-query-params'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
type MarketplaceInstall = {
manifest: PluginDeclaration | PluginManifestInMarket
uniqueIdentifier: string
}
export type UseInstallFromMarketplaceQueryOptions = {
canInstallPlugin: boolean
isPermissionLoading?: boolean
onPackageCategoryResolved?: (category: string | undefined, packageId: string) => boolean
}
export const useInstallFromMarketplaceQuery = ({
canInstallPlugin,
isPermissionLoading = false,
onPackageCategoryResolved,
}: UseInstallFromMarketplaceQueryOptions) => {
const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation()
const [marketplaceInstall, setMarketplaceInstall] = useState<MarketplaceInstall | null>(null)
const [dependencies, setDependencies] = useState<Dependency[]>([])
const [isShowInstallFromMarketplace, setIsShowInstallFromMarketplace] = useState(false)
useEffect(() => {
if (!packageId && !bundleInfo)
return
if (isPermissionLoading)
return
if (!canInstallPlugin) {
setInstallState(null)
return
}
let ignore = false
const loadMarketplaceInstall = async () => {
if (packageId) {
try {
const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId))
if (ignore)
return
const { plugin, version } = data
const redirected = onPackageCategoryResolved?.(plugin.category, packageId)
if (redirected)
return
setMarketplaceInstall({
uniqueIdentifier: packageId,
manifest: {
...plugin,
version: version.version,
icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`,
},
})
setIsShowInstallFromMarketplace(true)
}
catch (error) {
if (!ignore)
console.error('Failed to load marketplace plugin manifest:', error)
}
return
}
if (bundleInfo) {
try {
const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo)
if (ignore)
return
setDependencies(data.version.dependencies)
setIsShowInstallFromMarketplace(true)
}
catch (error) {
if (!ignore)
console.error('Failed to load bundle info:', error)
}
}
}
loadMarketplaceInstall()
return () => {
ignore = true
}
}, [bundleInfo, canInstallPlugin, isPermissionLoading, onPackageCategoryResolved, packageId, setInstallState])
const hideInstallFromMarketplace = () => {
setMarketplaceInstall(null)
setDependencies([])
setIsShowInstallFromMarketplace(false)
setInstallState(null)
}
return {
bundleInfo,
dependencies,
hideInstallFromMarketplace,
isShowInstallFromMarketplace,
marketplaceInstall: marketplaceInstall?.uniqueIdentifier === packageId ? marketplaceInstall : null,
}
}
@@ -0,0 +1,43 @@
'use client'
import type { PluginCategoryEnum, PluginManifestInMarket } from '../types'
import type { UseInstallFromMarketplaceQueryOptions } from './hooks/use-install-from-marketplace-query'
import { useInstallFromMarketplaceQuery as useInstallFromMarketplaceQueryHook } from './hooks/use-install-from-marketplace-query'
import InstallFromMarketplace from './install-from-marketplace'
type InstallFromMarketplaceQueryProps = UseInstallFromMarketplaceQueryOptions & {
installContextCategory?: PluginCategoryEnum
}
const InstallFromMarketplaceQuery = ({
installContextCategory,
...options
}: InstallFromMarketplaceQueryProps) => {
const {
bundleInfo,
dependencies,
hideInstallFromMarketplace,
isShowInstallFromMarketplace,
marketplaceInstall,
} = useInstallFromMarketplaceQueryHook(options)
if (!isShowInstallFromMarketplace)
return null
if (!marketplaceInstall && !bundleInfo)
return null
return (
<InstallFromMarketplace
manifest={marketplaceInstall?.manifest as PluginManifestInMarket}
uniqueIdentifier={marketplaceInstall?.uniqueIdentifier ?? ''}
isBundle={!!bundleInfo}
dependencies={dependencies}
installContextCategory={installContextCategory}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)
}
export default InstallFromMarketplaceQuery
@@ -12,6 +12,9 @@ import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/
import PluginPageWithContext from '../index'
let mockEnableMarketplace = true
const { mockRouterReplace } = vi.hoisted(() => ({
mockRouterReplace: vi.fn(),
}))
const render = (ui: ReactElement, options: Parameters<typeof renderWithSystemFeatures>[1] = {}) =>
renderWithSystemFeatures(ui, {
@@ -33,6 +36,12 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockRouterReplace,
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
@@ -466,7 +475,7 @@ describe('PluginPage Component', () => {
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
version: { version: '1.0.0' },
},
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
@@ -502,10 +511,10 @@ describe('PluginPage Component', () => {
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
data: {
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
plugin: { org: 'test-org', name: 'test-plugin', category: 'unknown' },
version: { version: '1.0.0' },
},
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
@@ -514,6 +523,28 @@ describe('PluginPage Component', () => {
}, { timeout: 3000 })
})
it('should redirect supported plugin categories to integrations before opening the modal', async () => {
const mockSetInstallState = vi.fn()
vi.mocked(usePluginInstallation).mockReturnValue([
{ packageId: 'junjiem/mcp_see_agent:0.2.4@test', bundleInfo: null },
mockSetInstallState,
])
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
data: {
plugin: { org: 'junjiem', name: 'mcp_see_agent', category: 'agent-strategy' },
version: { version: '0.2.4' },
},
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
await waitFor(() => {
expect(mockRouterReplace).toHaveBeenCalledWith('/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%3A0.2.4%40test%22%5D')
})
expect(screen.queryByTestId('install-marketplace-modal')).not.toBeInTheDocument()
})
it('should handle fetch error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -764,10 +795,10 @@ describe('PluginPage Component', () => {
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
data: {
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
plugin: { org: 'test-org', name: 'test-plugin', category: 'unknown' },
version: { version: '1.0.0' },
},
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
@@ -1044,10 +1075,10 @@ describe('PluginPage Integration', () => {
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
data: {
plugin: { org: 'langgenius', name: 'test-plugin', category: 'tool' },
plugin: { org: 'langgenius', name: 'test-plugin', category: 'unknown' },
version: { version: '1.0.0' },
},
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
@@ -128,7 +128,7 @@ describe('useReferenceSetting Hook', () => {
expect(result.current.canDebugger).toBe(true)
})
it('should ignore legacy admin permission for managers without plugin keys', () => {
it('should allow debug for managers with legacy admin permission when RBAC is disabled', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
@@ -146,10 +146,10 @@ describe('useReferenceSetting Hook', () => {
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
expect(result.current.canDebugger).toBe(true)
})
it('should ignore legacy admin permission for owners without plugin keys', () => {
it('should allow debug for owners with legacy admin permission when RBAC is disabled', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: true,
@@ -167,7 +167,30 @@ describe('useReferenceSetting Hook', () => {
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
expect(result.current.canDebugger).toBe(true)
})
it('should allow debug for normal users when legacy debug permission is everyone and RBAC is disabled', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install'],
} as ReturnType<typeof useAppContext>)
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
} as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool), {
systemFeatures: { rbac_enabled: false },
})
expect(result.current.canDebugPlugin).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should use plugin keys even when legacy admin permission is configured and RBAC is enabled', () => {
@@ -346,6 +369,35 @@ describe('useReferenceSetting Hook', () => {
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should keep permission state loading while workspace permission keys are loading', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isLoadingWorkspacePermissionKeys: true,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.isPermissionLoading).toBe(true)
expect(result.current.canInstallPlugin).toBe(false)
})
it('should keep permission state loading while current workspace is loading', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isLoadingCurrentWorkspace: true,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install'],
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.isPermissionLoading).toBe(true)
})
})
describe('RBAC permissions', () => {
@@ -1,6 +1,5 @@
'use client'
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types'
import type { PluginPageTab } from './context'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@@ -14,23 +13,22 @@ import {
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { cloneElement, isValidElement, useEffect, useMemo, useState } from 'react'
import { cloneElement, isValidElement, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import TabSlider from '@/app/components/base/tab-slider'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { useDocLink } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePluginInstallation } from '@/hooks/use-query-params'
import Link from '@/next/link'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
import { sleep } from '@/utils'
import { useRouter } from '@/next/navigation'
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
import { PluginInstallPermissionProvider } from '../install-plugin/components/plugin-install-permission-provider'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
import InstallFromMarketplaceQuery from '../install-plugin/install-from-marketplace-query'
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
import { getInstallRedirectPathByPluginCategory } from '../plugin-routes'
import { PluginCategoryEnum } from '../types'
import { usePluginPageContext } from './context'
import { PluginPageContextProvider } from './context-provider'
@@ -50,6 +48,29 @@ const isPluginPageTab = (value: string): value is PluginPageTab => {
return pluginPageTabSet.has(value)
}
const getCurrentInstallSearchParams = (packageId: string) => {
const searchParams: Record<string, string | string[]> = {}
new URLSearchParams(window.location.search).forEach((value, key) => {
const existing = searchParams[key]
if (existing === undefined) {
searchParams[key] = value
return
}
if (Array.isArray(existing)) {
existing.push(value)
return
}
searchParams[key] = [existing, value]
})
searchParams['package-ids'] ??= JSON.stringify([packageId])
return searchParams
}
export type PluginPageProps = {
plugins: React.ReactNode
marketplace: React.ReactNode
@@ -65,24 +86,7 @@ const PluginPage = ({
}: PluginPageProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
// Use nuqs hook for installation state
const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation()
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
const [dependencies, setDependencies] = useState<Dependency[]>([])
const [isShowInstallFromMarketplace, {
setTrue: showInstallFromMarketplace,
setFalse: doHideInstallFromMarketplace,
}] = useBoolean(false)
const hideInstallFromMarketplace = () => {
doHideInstallFromMarketplace()
setInstallState(null)
}
const [manifest, setManifest] = useState<PluginDeclaration | PluginManifestInMarket | null>(null)
const { replace } = useRouter()
const {
referenceSetting,
@@ -98,41 +102,15 @@ const PluginPage = ({
setReferenceSettings,
} = useReferenceSetting(PluginCategoryEnum.tool)
useEffect(() => {
(async () => {
setUniqueIdentifier(null)
await sleep(100)
if (isPermissionLoading)
return
const handlePackageCategoryResolved = useCallback((category: string | undefined, packageId: string) => {
const installRedirectPath = getInstallRedirectPathByPluginCategory(category, getCurrentInstallSearchParams(packageId))
if (!installRedirectPath)
return false
replace(installRedirectPath)
return true
}, [replace])
if (!canInstallPlugin) {
setInstallState(null)
return
}
if (packageId) {
const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId))
const { plugin, version } = data
setManifest({
...plugin,
version: version.version,
icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`,
})
setUniqueIdentifier(packageId)
showInstallFromMarketplace()
return
}
if (bundleInfo) {
try {
const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo)
setDependencies(data.version.dependencies)
showInstallFromMarketplace()
}
catch (error) {
console.error('Failed to load bundle info:', error)
}
}
})()
}, [packageId, bundleInfo, canInstallPlugin, isPermissionLoading, setInstallState, showInstallFromMarketplace])
const [showPluginSettingModal, {
setTrue: setShowPluginSettingModal,
setFalse: setHidePluginSettingModal,
@@ -351,18 +329,11 @@ const PluginPage = ({
/>
)}
{
isShowInstallFromMarketplace && uniqueIdentifier && (
<InstallFromMarketplace
manifest={manifest! as PluginManifestInMarket}
uniqueIdentifier={uniqueIdentifier}
isBundle={!!bundleInfo}
dependencies={dependencies}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)
}
<InstallFromMarketplaceQuery
canInstallPlugin={canInstallPlugin}
isPermissionLoading={isPermissionLoading}
onPackageCategoryResolved={handlePackageCategoryResolved}
/>
</div>
)
}
@@ -111,7 +111,7 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag
</span>
)}
statusText={(
<span className="block max-w-full wrap-break-word whitespace-pre-line">
<span className="block max-w-full min-w-0 [overflow-wrap:anywhere] break-words whitespace-pre-wrap">
{plugin.message || errorMsg}
</span>
)}
@@ -29,7 +29,7 @@ const PluginItem: FC<PluginItemProps> = ({
const pluginName = plugin.labels[language] || plugin.plugin_unique_identifier
return (
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
<div className="group/item flex w-full max-w-full min-w-0 gap-1 overflow-hidden rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative shrink-0 self-start">
{hasPluginIcon
? (
@@ -44,11 +44,11 @@ const PluginItem: FC<PluginItemProps> = ({
{statusIcon}
</div>
</div>
<div className="flex min-w-0 grow flex-col gap-0.5 px-1">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 px-1 [overflow-wrap:anywhere]">
<div className="truncate system-sm-medium text-text-secondary">
{plugin.labels[language]}
</div>
<div className={`min-w-0 system-xs-regular wrap-break-word ${statusClassName || 'text-text-tertiary'}`}>
<div className={`max-w-full min-w-0 system-xs-regular [overflow-wrap:anywhere] wrap-break-word ${statusClassName || 'text-text-tertiary'}`}>
{statusText}
</div>
{action}
@@ -33,15 +33,15 @@ function PluginTaskList({
return (
<div
className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
data-testid="plugin-task-list"
>
<ScrollArea
className="max-h-[420px] overflow-hidden"
label={t('task.installing', { ns: 'plugin' })}
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0',
viewport: 'max-h-[420px] overscroll-contain',
content: 'w-full! max-w-full! min-w-0! overflow-x-hidden!',
}}
>
{runningPlugins.length > 0 && (
@@ -103,13 +103,10 @@ function PluginTaskList({
{t('task.clearAll', { ns: 'plugin' })}
</Button>
</div>
<ScrollArea
className="overflow-hidden"
label={errorSectionTitle}
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0',
}}
<div
aria-label={errorSectionTitle}
className="w-full max-w-full min-w-0 overflow-hidden"
role="region"
>
{errorPlugins.map(plugin => (
<ErrorPluginItem
@@ -120,7 +117,7 @@ function PluginTaskList({
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
/>
))}
</ScrollArea>
</div>
</>
)}
</ScrollArea>
@@ -25,7 +25,14 @@ const useCanSetPluginSettings = () => {
export const usePluginSettingsAccess = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner, workspacePermissionKeys, langGeniusVersionInfo } = useAppContext()
const {
isCurrentWorkspaceManager,
isCurrentWorkspaceOwner,
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
workspacePermissionKeys,
langGeniusVersionInfo,
} = useAppContext()
const { data: rbacEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.rbac_enabled,
@@ -52,7 +59,9 @@ export const usePluginSettingsAccess = () => {
const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin
const canUpdatePlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin
const canDeletePlugin = hasPermission(workspacePermissionKeys, 'plugin.delete') && legacyCanInstallPlugin
const canDebugPlugin = hasPermission(workspacePermissionKeys, 'plugin.debug') && legacyCanDebugPlugin
const canDebugPlugin = rbacEnabled
? hasPermission(workspacePermissionKeys, 'plugin.debug')
: legacyCanDebugPlugin
return {
permission: permissions,
@@ -66,7 +75,7 @@ export const usePluginSettingsAccess = () => {
canDebugger: canDebugPlugin,
canSetPermissions,
currentDifyVersion: langGeniusVersionInfo?.current_version,
isPermissionLoading: permissionQuery.isLoading || permissionQuery.isFetching,
isPermissionLoading: permissionQuery.isLoading || permissionQuery.isFetching || !!isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys,
permissionError: permissionQuery.error,
isPermissionUpdatePending,
}
+114 -6
View File
@@ -4,6 +4,8 @@ export type LegacyPluginsSearchParams = Record<string, string | string[] | undef
const INSTALLED_PLUGINS_TAB = 'plugins'
const MARKETPLACE_TAB = 'discover'
const installSearchParamKeys = new Set(['package-ids', 'bundle-info'])
const PACKAGE_IDS_SEARCH_PARAM = 'package-ids'
const integrationPluginPathByTab = new Map<string, string>([
['trigger', '/integrations/trigger'],
@@ -11,6 +13,15 @@ const integrationPluginPathByTab = new Map<string, string>([
['extension', '/integrations/extension'],
])
const integrationPluginPathByInstallCategory = new Map<string, string>([
['model', '/integrations/model-provider'],
['tool', '/integrations/tools/built-in'],
['datasource', '/integrations/data-source'],
['trigger', '/integrations/trigger'],
['agent-strategy', '/integrations/agent-strategy'],
['extension', '/integrations/extension'],
])
const getIntegrationPluginPathByTab = (tab: string) => {
return integrationPluginPathByTab.get(tab)
}
@@ -27,14 +38,42 @@ const getFirstSearchParamValue = (value: string | string[] | undefined) => {
return value
}
const buildMarketplaceRedirectPath = (
const hasInstallSearchParams = (searchParams: LegacyPluginsSearchParams) => {
return Object.keys(searchParams).some(key => installSearchParamKeys.has(key))
}
export const getFirstPackageIdFromSearchParams = (searchParams: LegacyPluginsSearchParams) => {
const value = getFirstSearchParamValue(searchParams[PACKAGE_IDS_SEARCH_PARAM])
if (!value)
return null
try {
const parsed: unknown = JSON.parse(value)
if (Array.isArray(parsed)) {
const first = parsed[0]
return typeof first === 'string' ? first : null
}
}
catch {
return value
}
return value
}
export const shouldResolveInstallCategoryRedirect = (searchParams: LegacyPluginsSearchParams) => {
const tab = getFirstSearchParamValue(searchParams.tab)
return (!tab || tab === INSTALLED_PLUGINS_TAB) && !!getFirstPackageIdFromSearchParams(searchParams)
}
const buildPreservedSearchParams = (
searchParams: LegacyPluginsSearchParams,
tab: string,
ignoredKeys: Set<string>,
) => {
const preservedSearchParams = new URLSearchParams()
Object.entries(searchParams).forEach(([key, value]) => {
if (key === 'tab' || value === undefined)
if (ignoredKeys.has(key) || value === undefined)
return
if (Array.isArray(value)) {
@@ -45,11 +84,74 @@ const buildMarketplaceRedirectPath = (
preservedSearchParams.set(key, value)
})
return preservedSearchParams
}
const buildRedirectPath = (
path: string,
searchParams: LegacyPluginsSearchParams,
ignoredKeys: Set<string>,
) => {
const query = buildPreservedSearchParams(searchParams, ignoredKeys).toString()
return query ? `${path}?${query}` : path
}
const buildInstallRedirectPath = (
path: string,
searchParams: LegacyPluginsSearchParams,
) => {
const installSearchParams: LegacyPluginsSearchParams = {}
Object.entries(searchParams).forEach(([key, value]) => {
if (installSearchParamKeys.has(key))
installSearchParams[key] = value
})
return buildRedirectPath(path, installSearchParams, new Set())
}
export const getInstallRedirectPathByPluginCategory = (
category: string | undefined,
searchParams: LegacyPluginsSearchParams,
) => {
if (!category)
return undefined
const path = integrationPluginPathByInstallCategory.get(category)
if (!path)
return undefined
return buildInstallRedirectPath(path, searchParams)
}
export const getInstallRedirectPathFromSearchParams = (
searchParams: LegacyPluginsSearchParams,
) => {
if (!hasInstallSearchParams(searchParams))
return undefined
const category = getFirstSearchParamValue(searchParams.category)
const pathByCategory = getInstallRedirectPathByPluginCategory(category, searchParams)
if (pathByCategory)
return pathByCategory
const tab = getFirstSearchParamValue(searchParams.tab)
return getInstallRedirectPathByPluginCategory(tab, searchParams)
}
const buildMarketplaceRedirectPath = (
searchParams: LegacyPluginsSearchParams,
tab: string,
) => {
const path = '/marketplace'
const ignoredKeys = new Set(['tab'])
const preservedSearchParams = buildPreservedSearchParams(searchParams, ignoredKeys)
if (tab !== MARKETPLACE_TAB && !preservedSearchParams.has('category'))
preservedSearchParams.set('category', tab)
const query = preservedSearchParams.toString()
return query ? `/marketplace?${query}` : '/marketplace'
return query ? `${path}?${query}` : path
}
export const getLegacyPluginRedirectPath = (
@@ -57,12 +159,18 @@ export const getLegacyPluginRedirectPath = (
) => {
const tab = getFirstSearchParamValue(searchParams.tab)
if ((!tab || tab === INSTALLED_PLUGINS_TAB) && hasInstallSearchParams(searchParams))
return undefined
if (!tab || tab === INSTALLED_PLUGINS_TAB)
return '/integrations'
const integrationPluginPath = getIntegrationPluginPathByTab(tab)
if (integrationPluginPath)
return integrationPluginPath
if (integrationPluginPath) {
return hasInstallSearchParams(searchParams)
? buildInstallRedirectPath(integrationPluginPath, searchParams)
: buildRedirectPath(integrationPluginPath, searchParams, new Set(['tab']))
}
if (marketplacePluginTabs.has(tab))
return buildMarketplaceRedirectPath(searchParams, tab)
@@ -63,7 +63,7 @@ describe('useAvailableNodesMetaData', () => {
expect(result.current.nodesMap?.[BlockEnum.IterationStart]?.metaData.helpLinkUri).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.LoopStart]?.metaData.helpLinkUri).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.LoopEnd]?.metaData.helpLinkUri).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/user-input')
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/start')
})
it('should expose Agent v2 instead of legacy Agent when Agent v2 is enabled', () => {
@@ -9,7 +9,7 @@ const metaData = genNodeMetaData({
isRequired: false,
isSingleton: true,
isTypeFixed: true,
helpLinkUri: 'user-input',
helpLinkUri: 'start',
})
const nodeDefault: NodeDefault<StartPlaceholderNodeType> = {
@@ -10,7 +10,7 @@ const metaData = genNodeMetaData({
isRequired: false,
isSingleton: true,
isTypeFixed: false, // support node type change for start node(user input)
helpLinkUri: 'user-input',
helpLinkUri: 'start',
})
const nodeDefault: NodeDefault<StartNodeType> = {
metaData,
+8
View File
@@ -178,6 +178,14 @@ describe('useDocLink', () => {
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/workspace/subscription-management#dify-for-education`)
})
it('should use the self-host Start node docs path outside cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = false
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/nodes/start')
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/use-dify/nodes/start`)
})
it('should use the existing self-host docs path for self-host-only product docs in cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = true
+16 -4
View File
@@ -13,6 +13,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useAppContext } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { DSLImportStatus } from '@/models/app'
import { useRouter } from '@/next/navigation'
@@ -45,6 +46,7 @@ export const useImportDSL = () => {
const { push } = useRouter()
const invalidateAppList = useInvalidateAppList()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { userProfile, workspacePermissionKeys } = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const importIdRef = useRef<string>('')
@@ -95,7 +97,12 @@ export const useImportDSL = () => {
setNeedRefresh('1')
invalidateAppList()
await handleCheckPluginDependencies(app_id)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled })
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, {
currentUserId: userProfile?.id,
resourceMaintainer: userProfile?.id,
workspacePermissionKeys,
isRbacEnabled,
})
}
else if (status === DSLImportStatus.PENDING) {
setVersions({
@@ -117,7 +124,7 @@ export const useImportDSL = () => {
finally {
setIsFetching(false)
}
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, push, setNeedRefresh, invalidateAppList])
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, push, setNeedRefresh, invalidateAppList, userProfile?.id, workspacePermissionKeys])
const handleImportDSLConfirm = useCallback(async (
{
@@ -146,7 +153,12 @@ export const useImportDSL = () => {
await handleCheckPluginDependencies(app_id)
setNeedRefresh('1')
invalidateAppList()
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled })
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, {
currentUserId: userProfile?.id,
resourceMaintainer: userProfile?.id,
workspacePermissionKeys,
isRbacEnabled,
})
}
else if (status === DSLImportStatus.FAILED) {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
@@ -160,7 +172,7 @@ export const useImportDSL = () => {
finally {
setIsFetching(false)
}
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, setNeedRefresh, push, invalidateAppList])
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, setNeedRefresh, push, invalidateAppList, userProfile?.id, workspacePermissionKeys])
return {
handleImportDSL,
+30
View File
@@ -1,4 +1,8 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { createElement } from 'react'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { createAccountProfileQueryWrapper } from '@/test/account-profile-query'
import useTimestamp from './use-timestamp'
@@ -21,6 +25,22 @@ vi.mock('@/context/app-context', () => ({
})),
}))
const createEmptyQueryWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
function EmptyQueryWrapper({ children }: { children: ReactNode }) {
return createElement(QueryClientProvider, { client: queryClient }, children)
}
return { queryClient, wrapper: EmptyQueryWrapper }
}
describe('useTimestamp', () => {
describe('formatTime', () => {
it('should format unix timestamp correctly', () => {
@@ -63,4 +83,14 @@ describe('useTimestamp', () => {
.toBe('20:00')
})
})
it('should not request account profile when timezone is provided', () => {
const { queryClient, wrapper } = createEmptyQueryWrapper()
const { result } = renderHook(() => useTimestamp({ timezone: 'UTC' }), { wrapper })
expect(result.current.formatTime(1704132000, 'YYYY-MM-DD HH:mm')).toBe('2024-01-01 18:00')
expect(queryClient.isFetching({ queryKey: userProfileQueryOptions().queryKey })).toBe(0)
expect(queryClient.getQueryState(userProfileQueryOptions().queryKey)?.fetchStatus).toBe('idle')
})
})
+16 -6
View File
@@ -9,19 +9,29 @@ import { userProfileQueryOptions } from '@/features/account-profile/client'
dayjs.extend(utc)
dayjs.extend(timezone)
const useTimestamp = () => {
const { data: timezone } = useQuery({
type UseTimestampOptions = {
timezone?: string
}
const getBrowserTimezone = () => {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
const useTimestamp = ({ timezone: timezoneOverride }: UseTimestampOptions = {}) => {
const { data: accountTimezone } = useQuery({
...userProfileQueryOptions(),
select: data => data.profile.timezone ?? undefined,
enabled: timezoneOverride === undefined,
})
const resolvedTimezone = timezoneOverride ?? accountTimezone ?? getBrowserTimezone()
const formatTime = useCallback((value: number, format: string) => {
return dayjs.unix(value).tz(timezone).format(format)
}, [timezone])
return dayjs.unix(value).tz(resolvedTimezone).format(format)
}, [resolvedTimezone])
const formatDate = useCallback((value: string, format: string) => {
return dayjs(value).tz(timezone).format(format)
}, [timezone])
return dayjs(value).tz(resolvedTimezone).format(format)
}, [resolvedTimezone])
return { formatTime, formatDate }
}
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "التكاملات",
"mainNav.marketplace": "سوق الإضافات",
"mainNav.webApps.noResults": "لم يتم العثور على تطبيقات ويب",
"mainNav.webApps.openApp": "فتح تطبيق الويب {{name}}",
"mainNav.webApps.searchPlaceholder": "البحث في تطبيقات الويب",
"mainNav.workspace.credits": "{{count}} رصيد",
"mainNav.workspace.creditsUnit": "أرصدة",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integrationen",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Keine Web-Apps gefunden",
"mainNav.webApps.openApp": "Web-App {{name}} öffnen",
"mainNav.webApps.searchPlaceholder": "Web-Apps suchen",
"mainNav.workspace.credits": "{{count}} Credits",
"mainNav.workspace.creditsUnit": "Credits",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integrations",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "No web apps found",
"mainNav.webApps.openApp": "Open {{name}} web app",
"mainNav.webApps.searchPlaceholder": "Search web apps",
"mainNav.workspace.credits": "{{count}} credits",
"mainNav.workspace.creditsUnit": "credits",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integraciones",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "No se encontraron aplicaciones web",
"mainNav.webApps.openApp": "Abrir la aplicación web {{name}}",
"mainNav.webApps.searchPlaceholder": "Buscar aplicaciones web",
"mainNav.workspace.credits": "{{count}} créditos",
"mainNav.workspace.creditsUnit": "créditos",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "یکپارچه‌سازی‌ها",
"mainNav.marketplace": "بازارچه",
"mainNav.webApps.noResults": "هیچ برنامه وبی یافت نشد",
"mainNav.webApps.openApp": "باز کردن برنامه وب {{name}}",
"mainNav.webApps.searchPlaceholder": "جستجوی برنامه‌های وب",
"mainNav.workspace.credits": "{{count}} اعتبار",
"mainNav.workspace.creditsUnit": "اعتبار",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Intégrations",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Aucune application web trouvée",
"mainNav.webApps.openApp": "Ouvrir lapplication web {{name}}",
"mainNav.webApps.searchPlaceholder": "Rechercher des applications web",
"mainNav.workspace.credits": "{{count}} crédits",
"mainNav.workspace.creditsUnit": "crédits",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "इंटीग्रेशन",
"mainNav.marketplace": "मार्केटप्लेस",
"mainNav.webApps.noResults": "कोई वेब ऐप नहीं मिला",
"mainNav.webApps.openApp": "{{name}} वेब ऐप खोलें",
"mainNav.webApps.searchPlaceholder": "वेब ऐप खोजें",
"mainNav.workspace.credits": "{{count}} क्रेडिट",
"mainNav.workspace.creditsUnit": "क्रेडिट",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integrasi",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Tidak ada aplikasi web ditemukan",
"mainNav.webApps.openApp": "Buka aplikasi web {{name}}",
"mainNav.webApps.searchPlaceholder": "Cari aplikasi web",
"mainNav.workspace.credits": "{{count}} kredit",
"mainNav.workspace.creditsUnit": "kredit",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integrazioni",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Nessuna app web trovata",
"mainNav.webApps.openApp": "Apri l'app web {{name}}",
"mainNav.webApps.searchPlaceholder": "Cerca app web",
"mainNav.workspace.credits": "{{count}} crediti",
"mainNav.workspace.creditsUnit": "crediti",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "連携",
"mainNav.marketplace": "マーケットプレイス",
"mainNav.webApps.noResults": "Webアプリが見つかりません",
"mainNav.webApps.openApp": "{{name}} のWebアプリを開く",
"mainNav.webApps.searchPlaceholder": "Webアプリを検索",
"mainNav.workspace.credits": "{{count}} クレジット",
"mainNav.workspace.creditsUnit": "クレジット",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "연동",
"mainNav.marketplace": "마켓플레이스",
"mainNav.webApps.noResults": "웹 앱을 찾을 수 없습니다",
"mainNav.webApps.openApp": "{{name}} 웹 앱 열기",
"mainNav.webApps.searchPlaceholder": "웹 앱 검색",
"mainNav.workspace.credits": "{{count}} 크레딧",
"mainNav.workspace.creditsUnit": "크레딧",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integraties",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Geen webapps gevonden",
"mainNav.webApps.openApp": "Webapp {{name}} openen",
"mainNav.webApps.searchPlaceholder": "Webapps zoeken",
"mainNav.workspace.credits": "{{count}} credits",
"mainNav.workspace.creditsUnit": "credits",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integracje",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Nie znaleziono aplikacji webowych",
"mainNav.webApps.openApp": "Otwórz aplikację webową {{name}}",
"mainNav.webApps.searchPlaceholder": "Szukaj aplikacji webowych",
"mainNav.workspace.credits": "{{count}} kredytów",
"mainNav.workspace.creditsUnit": "kredyty",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integrações",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Nenhum app web encontrado",
"mainNav.webApps.openApp": "Abrir o app web {{name}}",
"mainNav.webApps.searchPlaceholder": "Pesquisar apps web",
"mainNav.workspace.credits": "{{count}} créditos",
"mainNav.workspace.creditsUnit": "créditos",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integrări",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Nu s-au găsit aplicații web",
"mainNav.webApps.openApp": "Deschide aplicația web {{name}}",
"mainNav.webApps.searchPlaceholder": "Caută aplicații web",
"mainNav.workspace.credits": "{{count}} credite",
"mainNav.workspace.creditsUnit": "credite",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Интеграции",
"mainNav.marketplace": "Маркетплейс",
"mainNav.webApps.noResults": "Веб-приложения не найдены",
"mainNav.webApps.openApp": "Открыть веб-приложение {{name}}",
"mainNav.webApps.searchPlaceholder": "Поиск веб-приложений",
"mainNav.workspace.credits": "{{count}} кредитов",
"mainNav.workspace.creditsUnit": "кредиты",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Integracije",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Ni najdenih spletnih aplikacij",
"mainNav.webApps.openApp": "Odpri spletno aplikacijo {{name}}",
"mainNav.webApps.searchPlaceholder": "Išči spletne aplikacije",
"mainNav.workspace.credits": "{{count}} kreditov",
"mainNav.workspace.creditsUnit": "krediti",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "การผสานรวม",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "ไม่พบเว็บแอป",
"mainNav.webApps.openApp": "เปิดเว็บแอป {{name}}",
"mainNav.webApps.searchPlaceholder": "ค้นหาเว็บแอป",
"mainNav.workspace.credits": "{{count}} เครดิต",
"mainNav.workspace.creditsUnit": "เครดิต",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Entegrasyonlar",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Web uygulaması bulunamadı",
"mainNav.webApps.openApp": "{{name}} web uygulamasını aç",
"mainNav.webApps.searchPlaceholder": "Web uygulamalarında ara",
"mainNav.workspace.credits": "{{count}} kredi",
"mainNav.workspace.creditsUnit": "kredi",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Інтеграції",
"mainNav.marketplace": "Маркетплейс",
"mainNav.webApps.noResults": "Вебзастосунки не знайдено",
"mainNav.webApps.openApp": "Відкрити вебзастосунок {{name}}",
"mainNav.webApps.searchPlaceholder": "Пошук вебзастосунків",
"mainNav.workspace.credits": "{{count}} кредитів",
"mainNav.workspace.creditsUnit": "кредити",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "Tích hợp",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "Không tìm thấy ứng dụng web",
"mainNav.webApps.openApp": "Mở ứng dụng web {{name}}",
"mainNav.webApps.searchPlaceholder": "Tìm kiếm ứng dụng web",
"mainNav.workspace.credits": "{{count}} tín dụng",
"mainNav.workspace.creditsUnit": "tín dụng",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "集成",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "未找到 Web 应用",
"mainNav.webApps.openApp": "打开 {{name}} Web 应用",
"mainNav.webApps.searchPlaceholder": "搜索 Web 应用",
"mainNav.workspace.credits": "{{count}} 消息额度",
"mainNav.workspace.creditsUnit": "消息额度",
+1
View File
@@ -202,6 +202,7 @@
"mainNav.integrations": "集成",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "未找到 Web 應用",
"mainNav.webApps.openApp": "開啟 {{name}} Web 應用",
"mainNav.webApps.searchPlaceholder": "搜尋 Web 應用",
"mainNav.workspace.credits": "{{count}} 點額度",
"mainNav.workspace.creditsUnit": "點額度",
+4
View File
@@ -74,6 +74,7 @@ type CloudPath =
| '/cloud/use-dify/nodes/output'
| '/cloud/use-dify/nodes/parameter-extractor'
| '/cloud/use-dify/nodes/question-classifier'
| '/cloud/use-dify/nodes/start'
| '/cloud/use-dify/nodes/template'
| '/cloud/use-dify/nodes/tools'
| '/cloud/use-dify/nodes/trigger/overview'
@@ -170,6 +171,7 @@ type UseDifyPath =
| '/use-dify/nodes/output'
| '/use-dify/nodes/parameter-extractor'
| '/use-dify/nodes/question-classifier'
| '/use-dify/nodes/start'
| '/use-dify/nodes/template'
| '/use-dify/nodes/tools'
| '/use-dify/nodes/trigger/overview'
@@ -356,6 +358,7 @@ type SelfHostPath =
| '/self-host/use-dify/nodes/output'
| '/self-host/use-dify/nodes/parameter-extractor'
| '/self-host/use-dify/nodes/question-classifier'
| '/self-host/use-dify/nodes/start'
| '/self-host/use-dify/nodes/template'
| '/self-host/use-dify/nodes/tools'
| '/self-host/use-dify/nodes/trigger/overview'
@@ -588,6 +591,7 @@ export const docPathProductAvailability: Record<string, readonly DocsProduct[]>
'/use-dify/nodes/output': ['cloud', 'self-host'],
'/use-dify/nodes/parameter-extractor': ['cloud', 'self-host'],
'/use-dify/nodes/question-classifier': ['cloud', 'self-host'],
'/use-dify/nodes/start': ['cloud', 'self-host'],
'/use-dify/nodes/template': ['cloud', 'self-host'],
'/use-dify/nodes/tools': ['cloud', 'self-host'],
'/use-dify/nodes/trigger/overview': ['cloud', 'self-host'],