Compare commits

..

3 Commits

Author SHA1 Message Date
4fa49b27f8 [autofix.ci] apply automated fixes 2026-05-18 08:20:01 +00:00
19b334e5ca chore(api): update graphon filter revision
Point the temporary Graphon source pin at the latest langgenius/graphon#146 head after its event filter updates.
2026-05-18 16:16:54 +08:00
dce25a3909 fix(api): filter Graphon workflow response events
Apply Graphon ResponseStreamFilter to workflow-based app runners so workflow execution consumes filtered graph events in both streaming and blocking paths.

Pin Graphon to the PR revision while the change waits for the 0.5.0 release, and update unit coverage for the filtered event stream behavior.
2026-05-18 15:54:55 +08:00
27 changed files with 368 additions and 367 deletions

View File

@ -246,7 +246,11 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
for layer in self._graph_engine_layers:
workflow_entry.graph_engine.layer(layer)
generator = workflow_entry.run()
generator = self._iter_workflow_events(
workflow_entry,
workflow_entry.run(),
stream=self.application_generate_entity.stream,
)
for event in generator:
self._handle_event(workflow_entry, event)

View File

@ -169,7 +169,11 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
for layer in self._graph_engine_layers:
workflow_entry.graph_engine.layer(layer)
generator = workflow_entry.run()
generator = self._iter_workflow_events(
workflow_entry,
workflow_entry.run(),
stream=self.application_generate_entity.stream,
)
for event in generator:
self._handle_event(workflow_entry, event)

View File

@ -1,6 +1,6 @@
import logging
import time
from collections.abc import Mapping, Sequence
from collections.abc import Iterable, Mapping, Sequence
from typing import Any, cast
from pydantic import ValidationError
@ -51,6 +51,7 @@ from core.workflow.workflow_entry import WorkflowEntry
from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run
from graphon.entities.graph_config import NodeConfigDictAdapter
from graphon.entities.pause_reason import HumanInputRequired
from graphon.filters import GraphEventFilterContext, ResponseStreamFilter, filter_graph_events
from graphon.graph import Graph
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import (
@ -381,6 +382,21 @@ class WorkflowBasedAppRunner:
return graph, variable_pool
@staticmethod
def _iter_workflow_events(
workflow_entry: WorkflowEntry,
events: Iterable[GraphEngineEvent],
*,
stream: bool,
) -> Iterable[GraphEngineEvent]:
_ = stream
return filter_graph_events(
events,
context=GraphEventFilterContext.from_engine(workflow_entry.graph_engine),
filters=[ResponseStreamFilter()],
)
@staticmethod
def _build_agent_strategy_info(event: NodeRunStartedEvent) -> AgentStrategyInfo | None:
raw_agent_strategy = event.extras.get("agent_strategy")

View File

@ -97,6 +97,7 @@ dify-trace-mlflow = { workspace = true }
dify-trace-opik = { workspace = true }
dify-trace-tencent = { workspace = true }
dify-trace-weave = { workspace = true }
graphon = { git = "https://github.com/langgenius/graphon.git", rev = "a24cf1f227c4b91494779be40c754cb4107c60ed" }
[tool.uv]
default-groups = ["storage", "tools", "vdb-all", "trace-all"]

View File

@ -100,6 +100,7 @@ class TestAdvancedChatAppRunnerConversationVariables:
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.trace_manager = None
mock_app_generate_entity.stream = False
# Create runner
runner = AdvancedChatAppRunner(
@ -245,6 +246,7 @@ class TestAdvancedChatAppRunnerConversationVariables:
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.trace_manager = None
mock_app_generate_entity.stream = False
# Create runner
runner = AdvancedChatAppRunner(
@ -405,6 +407,7 @@ class TestAdvancedChatAppRunnerConversationVariables:
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.trace_manager = None
mock_app_generate_entity.stream = False
# Create runner
runner = AdvancedChatAppRunner(

View File

@ -64,6 +64,7 @@ def build_runner():
gen.single_iteration_run = None
gen.single_loop_run = None
gen.trace_manager = None
gen.stream = False
runner = AdvancedChatAppRunner(
application_generate_entity=gen,

View File

@ -234,6 +234,39 @@ class TestWorkflowBasedAppRunner:
assert graph is not None
assert variable_pool.get(["sys", "conversation_id"]).value == "conv-1"
@pytest.mark.parametrize("stream", [False, True])
def test_iter_workflow_events_filters_response_stream(self, stream: bool):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()),
start_at=0.0,
)
workflow_entry = SimpleNamespace(
graph_engine=SimpleNamespace(
graph=SimpleNamespace(nodes={}),
graph_runtime_state=graph_runtime_state,
)
)
events = iter(
[
GraphRunStartedEvent(),
NodeRunStreamChunkEvent(
id="exec",
node_id="llm",
node_type=BuiltinNodeTypes.LLM,
selector=["llm", "text"],
chunk="raw",
is_final=False,
),
GraphRunSucceededEvent(outputs={"answer": "done"}),
]
)
filtered_events = list(runner._iter_workflow_events(workflow_entry, events, stream=stream))
assert [type(event) for event in filtered_events] == [GraphRunStartedEvent, GraphRunSucceededEvent]
def test_handle_graph_run_events_and_pause_notifications(self, monkeypatch: pytest.MonkeyPatch):
published: list[object] = []

View File

@ -53,6 +53,7 @@ def test_run_uses_single_node_execution_branch(
app_generate_entity.trace_manager = None
app_generate_entity.single_iteration_run = single_iteration_run
app_generate_entity.single_loop_run = single_loop_run
app_generate_entity.stream = False
workflow = MagicMock(spec=Workflow)
workflow.tenant_id = "tenant"

View File

@ -1,3 +1,4 @@
from graphon.filters import GraphEventFilterContext, ResponseStreamFilter, filter_graph_events
from graphon.graph_engine import GraphEngine, GraphEngineConfig
from graphon.graph_engine.command_channels import InMemoryChannel
from graphon.graph_events import (
@ -31,7 +32,13 @@ def test_tool_in_chatflow():
config=GraphEngineConfig(),
)
events = list(engine.run())
events = list(
filter_graph_events(
engine.run(),
context=GraphEventFilterContext.from_engine(engine),
filters=[ResponseStreamFilter()],
)
)
# Check for successful completion
success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)]

8
api/uv.lock generated
View File

@ -1628,7 +1628,7 @@ requires-dist = [
{ name = "gmpy2", specifier = ">=2.3.0" },
{ name = "google-api-python-client", specifier = ">=2.196.0" },
{ name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" },
{ name = "graphon", specifier = "~=0.4.0" },
{ name = "graphon", git = "https://github.com/langgenius/graphon.git?rev=a24cf1f227c4b91494779be40c754cb4107c60ed" },
{ name = "gunicorn", specifier = ">=26.0.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" },
{ name = "httpx-sse", specifier = "~=0.4.0" },
@ -2985,7 +2985,7 @@ httpx = [
[[package]]
name = "graphon"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
source = { git = "https://github.com/langgenius/graphon.git?rev=a24cf1f227c4b91494779be40c754cb4107c60ed#a24cf1f227c4b91494779be40c754cb4107c60ed" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "httpx" },
@ -3005,10 +3005,6 @@ dependencies = [
{ name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] },
{ name = "webvtt-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/24/eb1e7983404dcac84816b76ea450e1bb97023e55e00c699d609340bc361e/graphon-0.4.0.tar.gz", hash = "sha256:afb0c7a58f89e09cfa585296429b4d08cd0df80b9ac54d550f88e7d76ec48ee0", size = 261812, upload-time = "2026-05-13T11:48:39.198Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/de/bad6b3fd1e4b4defc16e6ea106e55c44725a159f1d191a99877bce1c9931/graphon-0.4.0-py3-none-any.whl", hash = "sha256:b33f95886da823d5b1b53d663a4f5f8fa383c37740f3bd19297b8d140fcb804c", size = 372711, upload-time = "2026-05-13T11:48:37.712Z" },
]
[[package]]
name = "graphql-core"

View File

@ -34,16 +34,8 @@ if [[ -f "$EXCLUDES_FILE" ]]; then
fi
tmp_output="$(mktemp)"
pyrefly_command=(
uv run --directory api --dev pyrefly check
"${pyrefly_args[@]}"
)
if (( ${#target_paths[@]} > 0 )); then
pyrefly_command+=("${target_paths[@]}")
fi
set +e
"${pyrefly_command[@]}" >"$tmp_output" 2>&1
uv run --directory api --dev pyrefly check "${pyrefly_args[@]}" "${target_paths[@]}" >"$tmp_output" 2>&1
pyrefly_status=$?
set -e

View File

@ -8,14 +8,14 @@
Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`.
Are we OpenAPI ready? **No.** Current generated API contracts are **16.6% ready**.
Are we OpenAPI ready? **No.** Current generated API contracts are **16.7% ready**.
| Surface | Ready | Not ready | Total | Ready % |
| --------- | ------: | --------: | ------: | --------: |
| console | 95 | 475 | 570 | 16.7% |
| console | 96 | 474 | 570 | 16.8% |
| service | 16 | 72 | 88 | 18.2% |
| web | 5 | 36 | 41 | 12.2% |
| **total** | **116** | **583** | **699** | **16.6%** |
| **total** | **117** | **582** | **699** | **16.7%** |
Readiness here means the generated contract operation is not marked with:

View File

@ -426,16 +426,10 @@ export const imports = {
/**
* Get workflow online users
*
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post3 = oc
.route({
deprecated: true,
description:
'Get workflow online users\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
description: 'Get workflow online users',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsWorkflowsOnlineUsers',

View File

@ -1,7 +1,7 @@
{
"surfaces": {
"console": {
"notReady": 475,
"notReady": 474,
"total": 570
},
"service": {

View File

@ -59,6 +59,7 @@ const defaultProviderContext = {
const defaultModalContext: ModalContextState = {
setShowAccountSettingModal: noop,
setShowApiBasedExtensionModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,

View File

@ -55,6 +55,7 @@ const mockUseProviderContext = vi.fn<() => ProviderContextState>()
const buildModalContext = (): ModalContextState => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: vi.fn(),
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,

View File

@ -1,6 +1,8 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
import type { SetStateAction } from 'react'
import type { ModalContextState, ModalState } from '@/context/modal-context'
import { fireEvent, render, screen } from '@testing-library/react'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionPage from '../index'
@ -8,16 +10,19 @@ vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
updateApiBasedExtension: vi.fn(),
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
describe('ApiBasedExtensionPage', () => {
const mockRefetch = vi.fn<() => void>()
const mockSetShowApiBasedExtensionModal = vi.fn<(value: SetStateAction<ModalState<Partial<ApiBasedExtensionResponse>> | null>) => void>()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
})
describe('Rendering', () => {
@ -123,17 +128,13 @@ describe('ApiBasedExtensionPage', () => {
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
// Assert
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: {},
}))
})
it('should call refetch when add modal saves successfully', async () => {
it('should call refetch when onSaveCallback is executed from the modal', () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({
id: 'new-id',
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
})
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [],
isPending: false,
@ -143,23 +144,25 @@ describe('ApiBasedExtensionPage', () => {
// Act
render(<ApiBasedExtensionPage />)
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
// Trigger callback manually from the mock call
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
if (callArgs.onSaveCallback) {
callArgs.onSaveCallback()
// Assert
expect(mockRefetch).toHaveBeenCalled()
}
}
})
it('should call refetch when an item is updated', async () => {
it('should call refetch when an item is updated', () => {
// Arrange
const extension: ApiBasedExtensionResponse = { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'long-api-key' }
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...extension, name: 'Updated' })
const mockData: ApiBasedExtensionResponse[] = [
{ id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'key1' },
]
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [extension],
data: mockData,
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
@ -168,12 +171,16 @@ describe('ApiBasedExtensionPage', () => {
// Act - Click edit on the rendered item
fireEvent.click(screen.getByText('common.operation.edit'))
fireEvent.click(screen.getByText('common.operation.save'))
// Retrieve the onSaveCallback from the modal call and execute it
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
if (callArgs.onSaveCallback)
callArgs.onSaveCallback()
}
// Assert
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
expect(mockRefetch).toHaveBeenCalled()
})
})
})

View File

@ -1,10 +1,17 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { TFunction } from 'i18next'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { deleteApiBasedExtension } from '@/service/common'
import Item from '../item'
// Mock dependencies
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
deleteApiBasedExtension: vi.fn(),
}))
@ -17,16 +24,19 @@ describe('Item Component', () => {
api_key: 'test-api-key',
}
const mockOnUpdate = vi.fn()
const mockOnEdit = vi.fn()
const mockSetShowApiBasedExtensionModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
})
describe('Rendering', () => {
it('should render extension data correctly', () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Assert
// Assert
@ -44,7 +54,7 @@ describe('Item Component', () => {
}
// Act
render(<Item data={minimalData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={minimalData} onUpdate={mockOnUpdate} />)
// Assert
// Assert
@ -54,20 +64,41 @@ describe('Item Component', () => {
})
describe('Modal Interactions', () => {
it('should request editing with the current extension when clicking edit button', () => {
it('should open edit modal with correct payload when clicking edit button', () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
expect(mockOnEdit).toHaveBeenCalledWith(mockData)
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: mockData,
}))
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall)
expect(lastCall.onSaveCallback).toBeInstanceOf(Function)
})
it('should execute onUpdate callback when edit modal save callback is invoked', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
const modalCallArg = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof modalCallArg === 'object' && modalCallArg !== null && 'onSaveCallback' in modalCallArg) {
const onSaveCallback = modalCallArg.onSaveCallback
if (onSaveCallback) {
onSaveCallback()
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
}
}
})
})
describe('Deletion', () => {
it('should show delete confirmation dialog when clicking delete button', () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
// Assert
@ -78,7 +109,7 @@ describe('Item Component', () => {
it('should call delete API and triggers onUpdate when confirming deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
@ -98,7 +129,7 @@ describe('Item Component', () => {
it('should hide delete confirmation dialog after successful deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
@ -116,7 +147,7 @@ describe('Item Component', () => {
it('should close delete confirmation when clicking cancel button', async () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
@ -128,7 +159,7 @@ describe('Item Component', () => {
it('should not call delete API when canceling deletion', () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
@ -157,7 +188,7 @@ describe('Item Component', () => {
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
const allButtons = screen.getAllByRole('button')
const editBtn = screen.getByText('operation.edit')
const deleteBtn = allButtons.find(btn => btn !== editBtn)

View File

@ -1,6 +1,6 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { TFunction } from 'i18next'
import type { ComponentProps, ReactElement } from 'react'
import type { ReactElement } from 'react'
import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { useDocLink } from '@/context/i18n'
@ -34,7 +34,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
describe('ApiBasedExtensionModal', () => {
const mockOnOpenChange = vi.fn()
const mockOnCancel = vi.fn()
const mockOnSave = vi.fn()
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
const mockExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
@ -46,19 +46,6 @@ describe('ApiBasedExtensionModal', () => {
})
const render = (ui: ReactElement) => RTLRender(ui)
const renderModal = (props: Partial<ComponentProps<typeof ApiBasedExtensionModal>> = {}) => render(
<ApiBasedExtensionModal
open
extension={{}}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
{...props}
/>,
)
const expectCloseRequested = () => {
const calls = mockOnOpenChange.mock.calls
expect(calls[calls.length - 1]?.[0]).toBe(false)
}
beforeEach(() => {
vi.clearAllMocks()
@ -68,10 +55,9 @@ describe('ApiBasedExtensionModal', () => {
describe('Rendering', () => {
it('should render correctly for adding a new extension', () => {
// Act
renderModal()
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Assert
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
@ -83,7 +69,7 @@ describe('ApiBasedExtensionModal', () => {
const data = mockExtension()
// Act
renderModal({ extension: data })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Assert
expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
@ -91,14 +77,6 @@ describe('ApiBasedExtensionModal', () => {
expect(screen.getByDisplayValue('url')).toBeInTheDocument()
expect(screen.getByDisplayValue('key')).toBeInTheDocument()
})
it('should not render dialog content when closed', () => {
// Act
renderModal({ open: false })
// Assert
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
describe('Form Submissions', () => {
@ -111,7 +89,7 @@ describe('ApiBasedExtensionModal', () => {
api_key: 'secret-key',
})
vi.mocked(addApiBasedExtension).mockResolvedValue(newExtension)
renderModal()
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
@ -137,7 +115,7 @@ describe('ApiBasedExtensionModal', () => {
// Arrange
const data = mockExtension({ api_key: 'long-secret-key' })
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
renderModal({ extension: data })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
@ -147,11 +125,12 @@ describe('ApiBasedExtensionModal', () => {
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
body: {
body: expect.objectContaining({
id: '1',
name: 'Updated',
api_endpoint: 'url',
api_key: '[__HIDDEN__]',
},
}),
})
expect(mockToast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
expect(mockOnSave).toHaveBeenCalled()
@ -162,7 +141,7 @@ describe('ApiBasedExtensionModal', () => {
// Arrange
const data = mockExtension({ api_key: 'old-key' })
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
renderModal({ extension: data })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
@ -172,11 +151,9 @@ describe('ApiBasedExtensionModal', () => {
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
body: {
name: 'Existing',
api_endpoint: 'url',
body: expect.objectContaining({
api_key: 'new-longer-key',
},
}),
})
})
})
@ -185,7 +162,7 @@ describe('ApiBasedExtensionModal', () => {
describe('Validation', () => {
it('should show error if api key is too short', async () => {
// Arrange
renderModal()
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'Ext' } })
@ -203,7 +180,7 @@ describe('ApiBasedExtensionModal', () => {
it('should work when onSave is not provided', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue(mockExtension({ id: 'new-id' }))
renderModal({ onSave: undefined })
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
@ -217,56 +194,15 @@ describe('ApiBasedExtensionModal', () => {
})
})
it('should request closing when clicking cancel button', () => {
it('should call onCancel when clicking cancel button', () => {
// Arrange
renderModal()
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expectCloseRequested()
})
it('should request closing when clicking close button', async () => {
// Arrange
renderModal()
// Act
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
// Assert
await waitFor(() => {
expectCloseRequested()
})
})
it('should request closing when pressing Escape', async () => {
// Arrange
renderModal()
// Act
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
await waitFor(() => {
expectCloseRequested()
})
})
it('should keep open when clicking outside the dialog', () => {
// Arrange
renderModal()
// Act
const backdrop = document.querySelector('.bg-background-overlay')
expect(backdrop).toBeInTheDocument()
fireEvent.pointerDown(backdrop!)
fireEvent.pointerUp(backdrop!)
fireEvent.click(backdrop!)
// Assert
expect(mockOnOpenChange).not.toHaveBeenCalled()
expect(mockOnCancel).toHaveBeenCalled()
})
})
@ -294,7 +230,7 @@ describe('ApiBasedExtensionModal', () => {
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
const { container } = renderModal({ onSave: undefined })
const { container } = render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
// Assert
const inputs = container.querySelectorAll('input')

View File

@ -1,10 +1,9 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { UseQueryResult } from '@tanstack/react-query'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { addApiBasedExtension } from '@/service/common'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionSelector from '../selector'
@ -16,15 +15,12 @@ vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetShowApiBasedExtensionModal = vi.fn()
const mockRefetch = vi.fn()
const mockData: ApiBasedExtensionResponse[] = [
@ -36,6 +32,7 @@ describe('ApiBasedExtensionSelector', () => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
@ -106,29 +103,26 @@ describe('ApiBasedExtensionSelector', () => {
})
it('should open add modal when clicking add button and refetches on save', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({
id: 'new-id',
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
})
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
const addButton = await screen.findByText('common.operation.add')
fireEvent.click(addButton)
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: {},
}))
// Trigger callback
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall) {
if (lastCall.onSaveCallback) {
lastCall.onSaveCallback()
expect(mockRefetch).toHaveBeenCalled()
}
}
})
})
})

View File

@ -1,42 +1,24 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { useState } from 'react'
import {
RiAddLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import Empty from './empty'
import Item from './item'
import ApiBasedExtensionModal from './modal'
type ApiBasedExtensionDialogState = {
extension: Partial<ApiBasedExtensionResponse>
onSave: () => void
} | null
const ApiBasedExtensionPage = () => {
const { t } = useTranslation()
const { setShowApiBasedExtensionModal } = useModalContext()
const { data, refetch: mutate, isPending: isLoading } = useApiBasedExtensions()
const [dialogState, setDialogState] = useState<ApiBasedExtensionDialogState>(null)
const handleOpenApiBasedExtensionModal = () => {
setDialogState({
extension: {},
onSave: () => mutate(),
setShowApiBasedExtensionModal({
payload: {},
onSaveCallback: () => mutate(),
})
}
const handleEditApiBasedExtension = (extension: ApiBasedExtensionResponse) => {
setDialogState({
extension,
onSave: () => mutate(),
})
}
const handleSaveApiBasedExtension = () => {
dialogState?.onSave()
setDialogState(null)
}
const handleApiBasedExtensionModalOpenChange = (open: boolean) => {
if (!open)
setDialogState(null)
}
return (
<div>
@ -51,7 +33,6 @@ const ApiBasedExtensionPage = () => {
<Item
key={item.id}
data={item}
onEdit={handleEditApiBasedExtension}
onUpdate={() => mutate()}
/>
))
@ -62,19 +43,9 @@ const ApiBasedExtensionPage = () => {
className="w-full"
onClick={handleOpenApiBasedExtensionModal}
>
<span className="mr-1 i-ri-add-line h-4 w-4" aria-hidden="true" />
<RiAddLine className="mr-1 h-4 w-4" />
{t('apiBasedExtension.add', { ns: 'common' })}
</Button>
{
dialogState && (
<ApiBasedExtensionModal
open
extension={dialogState.extension}
onOpenChange={handleApiBasedExtensionModalOpenChange}
onSave={handleSaveApiBasedExtension}
/>
)
}
</div>
)
}

View File

@ -1,4 +1,5 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { FC } from 'react'
import {
AlertDialog,
AlertDialogActions,
@ -8,25 +9,32 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import {
RiDeleteBinLine,
RiEditLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { deleteApiBasedExtension } from '@/service/common'
type ItemProps = {
data: ApiBasedExtensionResponse
onEdit: (extension: ApiBasedExtensionResponse) => void
onUpdate: () => void
}
const Item = ({
const Item: FC<ItemProps> = ({
data,
onEdit,
onUpdate,
}: ItemProps) => {
}) => {
const { t } = useTranslation()
const { setShowApiBasedExtensionModal } = useModalContext()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const handleOpenApiBasedExtensionModal = () => {
onEdit(data)
setShowApiBasedExtensionModal({
payload: data,
onSaveCallback: () => onUpdate(),
})
}
const handleDeleteApiBasedExtension = async () => {
await deleteApiBasedExtension(`/api-based-extension/${data.id}`)
@ -36,27 +44,28 @@ const Item = ({
}
return (
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 focus-within:border-components-input-border-active focus-within:shadow-xs hover:border-components-input-border-active hover:shadow-xs">
<div className="min-w-0 grow">
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 hover:border-components-input-border-active hover:shadow-xs">
<div className="grow">
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{data.name}</div>
<div className="truncate text-xs text-text-tertiary">{data.api_endpoint}</div>
<div className="text-xs text-text-tertiary">{data.api_endpoint}</div>
</div>
<div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<div className="hidden items-center group-hover:flex">
<Button
className="mr-1"
onClick={handleOpenApiBasedExtensionModal}
>
<span className="mr-1 i-ri-edit-line h-4 w-4" aria-hidden="true" />
<RiEditLine className="mr-1 h-4 w-4" />
{t('operation.edit', { ns: 'common' })}
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
>
<span className="mr-1 i-ri-delete-bin-line h-4 w-4" aria-hidden="true" />
<RiDeleteBinLine className="mr-1 h-4 w-4" />
{t('operation.delete', { ns: 'common' })}
</Button>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{`${t('operation.delete', { ns: 'common' })} \u201C${data.name}\u201D?`}

View File

@ -2,57 +2,51 @@ import type {
ApiBasedExtensionPayload,
ApiBasedExtensionResponse,
} from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import { useDocLink } from '@/context/i18n'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
type ApiBasedExtensionField = 'name' | 'api_endpoint' | 'api_key'
type ApiBasedExtensionModalProps = {
open: boolean
extension: Partial<ApiBasedExtensionResponse>
onOpenChange: (open: boolean) => void
data: Partial<ApiBasedExtensionResponse>
onCancel: () => void
onSave?: (newData: ApiBasedExtensionResponse) => void
}
const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBasedExtensionModalProps) => {
const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({ data, onCancel, onSave }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const [localData, setLocalData] = useState(extension)
const [localeData, setLocaleData] = useState(data)
const [loading, setLoading] = useState(false)
const handleDataChange = (field: ApiBasedExtensionField, value: string) => {
setLocalData({ ...localData, [field]: value })
const handleDataChange = (type: string, value: string) => {
setLocaleData({ ...localeData, [type]: value })
}
const handleSave = async () => {
setLoading(true)
if (localData.api_key && localData.api_key.length < 5) {
if (localeData && localeData.api_key && localeData.api_key?.length < 5) {
toast.error(t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' }))
setLoading(false)
return
}
try {
const payload: ApiBasedExtensionPayload = {
name: localData.name || '',
api_endpoint: localData.api_endpoint || '',
api_key: localData.api_key || '',
}
let res = {} as ApiBasedExtensionResponse
if (!extension.id) {
if (!data.id) {
res = await addApiBasedExtension({
url: '/api-based-extension',
body: payload,
body: localeData as ApiBasedExtensionPayload,
})
}
else {
res = await updateApiBasedExtension({
url: `/api-based-extension/${extension.id}`,
url: `/api-based-extension/${data.id}`,
body: {
...payload,
api_key: extension.api_key === localData.api_key ? '[__HIDDEN__]' : payload.api_key,
},
...localeData,
api_key: data.api_key === localeData.api_key ? '[__HIDDEN__]' : localeData.api_key,
} as ApiBasedExtensionPayload,
})
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
}
@ -63,47 +57,44 @@ const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBa
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-160 border-none p-8 pb-6 text-left"
>
<DialogCloseButton />
<Dialog open>
<DialogContent className="w-[640px]! max-w-none! border-none p-8! pb-6! text-left align-middle">
<DialogTitle className="mb-2 pr-8 text-xl font-semibold text-text-primary">
{extension.name
<div className="mb-2 text-xl font-semibold text-text-primary">
{data.name
? t('apiBasedExtension.modal.editTitle', { ns: 'common' })
: t('apiBasedExtension.modal.title', { ns: 'common' })}
</DialogTitle>
</div>
<div className="py-2">
<div className="text-sm leading-9 font-medium text-text-primary">
{t('apiBasedExtension.modal.name.title', { ns: 'common' })}
</div>
<input value={localData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
<input value={localeData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
</div>
<div className="py-2">
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
{t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })}
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="flex items-center text-xs font-normal text-text-accent">
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="group flex items-center text-xs font-normal text-text-accent">
<BookOpen01 className="mr-1 h-3 w-3" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
<input value={localData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
<input value={localeData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
</div>
<div className="py-2">
<div className="text-sm leading-9 font-medium text-text-primary">
{t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })}
</div>
<input value={localData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
<div className="flex items-center">
<input value={localeData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button onClick={() => onOpenChange(false)}>
<div className="mt-6 flex items-center justify-end">
<Button onClick={onCancel} className="mr-2">
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" disabled={!localData.name || !localData.api_endpoint || !localData.api_key || loading} onClick={handleSave}>
<Button variant="primary" disabled={!localeData.name || !localeData.api_endpoint || !localeData.api_key || loading} onClick={handleSave}>
{t('operation.save', { ns: 'common' })}
</Button>
</div>

View File

@ -1,25 +1,32 @@
import type { FC } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiAddLine,
RiArrowDownSLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ArrowUpRight,
} from '@/app/components/base/icons/src/vender/line/arrows'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionModal from './modal'
type ApiBasedExtensionSelectorProps = {
value: string
onChange: (value: string) => void
}
const ApiBasedExtensionSelector = ({
const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
value,
onChange,
}: ApiBasedExtensionSelectorProps) => {
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [addModalOpen, setAddModalOpen] = useState(false)
const {
setShowAccountSettingModal,
setShowApiBasedExtensionModal,
} = useModalContext()
const { data, refetch: mutate } = useApiBasedExtensions()
const handleSelect = (id: string) => {
@ -29,115 +36,91 @@ const ApiBasedExtensionSelector = ({
const currentItem = data?.find(item => item.id === value)
const handleSaveApiBasedExtension = () => {
mutate()
setAddModalOpen(false)
}
const handleAddModalOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setAddModalOpen(false)
}
return (
<>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
render={(
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
{
currentItem
? (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
<div className="text-sm text-text-primary">{currentItem.name}</div>
<div className="flex items-center">
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
{currentItem.api_endpoint}
</div>
<span className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} aria-hidden="true" />
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
render={(
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
{
currentItem
? (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
<div className="text-sm text-text-primary">{currentItem.name}</div>
<div className="flex items-center">
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
{currentItem.api_endpoint}
</div>
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
</div>
)
: (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
<span className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} aria-hidden="true" />
</div>
)
}
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
className="w-[calc(100%-32px)] max-w-[576px]"
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div className="flex items-center justify-between px-3 pt-2 pb-1">
<div className="text-xs font-medium text-text-tertiary">
{t('apiBasedExtension.selector.title', { ns: 'common' })}
</div>
<button
type="button"
className="flex cursor-pointer items-center border-none bg-transparent p-0 text-xs text-text-accent"
onClick={() => {
setOpen(false)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION })
}}
>
{t('apiBasedExtension.selector.manage', { ns: 'common' })}
<span className="ml-0.5 i-custom-vender-line-arrows-arrow-up-right h-3 w-3" aria-hidden="true" />
</button>
</div>
)
: (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
</div>
)
}
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
className="w-[calc(100%-32px)] max-w-[576px]"
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div className="flex items-center justify-between px-3 pt-2 pb-1">
<div className="text-xs font-medium text-text-tertiary">
{t('apiBasedExtension.selector.title', { ns: 'common' })}
</div>
<div className="max-h-[250px] overflow-y-auto">
{
data?.map(item => (
<button
type="button"
key={item.id}
className="w-full cursor-pointer rounded-md border-none bg-transparent px-3 py-1.5 text-left hover:bg-state-base-hover"
onClick={() => handleSelect(item.id!)}
>
<div className="text-sm text-text-primary">{item.name}</div>
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
</button>
))
}
</div>
</div>
<div className="h-px bg-divider-regular" />
<div className="p-1">
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center border-none bg-transparent px-3 text-left text-sm text-text-accent"
<div
className="flex cursor-pointer items-center text-xs text-text-accent"
onClick={() => {
setOpen(false)
setAddModalOpen(true)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION })
}}
>
<span className="mr-2 i-ri-add-line h-4 w-4" aria-hidden="true" />
{t('operation.add', { ns: 'common' })}
</button>
{t('apiBasedExtension.selector.manage', { ns: 'common' })}
<ArrowUpRight className="ml-0.5 h-3 w-3" />
</div>
</div>
<div className="max-h-[250px] overflow-y-auto">
{
data?.map(item => (
<div
key={item.id}
className="w-full cursor-pointer rounded-md px-3 py-1.5 text-left hover:stroke-state-base-hover"
onClick={() => handleSelect(item.id)}
>
<div className="text-sm text-text-primary">{item.name}</div>
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
</div>
))
}
</div>
</div>
</PopoverContent>
</Popover>
{
addModalOpen && (
<ApiBasedExtensionModal
open
extension={{}}
onOpenChange={handleAddModalOpenChange}
onSave={handleSaveApiBasedExtension}
/>
)
}
</>
<div className="h-px bg-divider-regular" />
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center px-3 text-sm text-text-accent"
onClick={() => {
setOpen(false)
setShowApiBasedExtensionModal({ payload: {}, onSaveCallback: () => mutate() })
}}
>
<RiAddLine className="mr-2 h-4 w-4" />
{t('operation.add', { ns: 'common' })}
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -26,6 +26,7 @@ const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: (): ModalContextState => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: vi.fn(),
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,

View File

@ -1,5 +1,6 @@
'use client'
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { ReactNode, SetStateAction } from 'react'
import type { ModalState, ModelModalType } from './modal-context'
import type { OpeningStatement } from '@/app/components/base/features/types'
@ -35,6 +36,9 @@ import {
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
ssr: false,
})
const ApiBasedExtensionModal = dynamic(() => import('@/app/components/header/account-setting/api-based-extension-page/modal'), {
ssr: false,
})
const ModerationSettingModal = dynamic(() => import('@/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal'), {
ssr: false,
})
@ -86,6 +90,7 @@ export const ModalContextProvider = ({
? urlAccountModalState.payload
: DEFAULT_ACCOUNT_SETTING_TAB)
: null
const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState<ModalState<Partial<ApiBasedExtensionResponse>> | null>(null)
const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null)
const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | null>(null)
const [showModelModal, setShowModelModal] = useState<ModalState<ModelModalType> | null>(null)
@ -201,6 +206,12 @@ export const ModalContextProvider = ({
showOpeningModal.onCancelCallback()
}, [showOpeningModal])
const handleSaveApiBasedExtension = (newApiBasedExtension: ApiBasedExtensionResponse) => {
if (showApiBasedExtensionModal?.onSaveCallback)
showApiBasedExtensionModal.onSaveCallback(newApiBasedExtension)
setShowApiBasedExtensionModal(null)
}
const handleSaveModeration = (newModerationConfig: ModerationConfig) => {
if (showModerationSettingModal?.onSaveCallback)
showModerationSettingModal.onSaveCallback(newModerationConfig)
@ -236,6 +247,7 @@ export const ModalContextProvider = ({
return (
<ModalContext.Provider value={{
setShowAccountSettingModal,
setShowApiBasedExtensionModal,
setShowModerationSettingModal,
setShowExternalDataToolModal,
setShowPricingModal: handleShowPricingModal,
@ -261,6 +273,15 @@ export const ModalContextProvider = ({
)
}
{
!!showApiBasedExtensionModal && (
<ApiBasedExtensionModal
data={showApiBasedExtensionModal.payload}
onCancel={() => setShowApiBasedExtensionModal(null)}
onSave={handleSaveApiBasedExtension}
/>
)
}
{
!!showModerationSettingModal && (
<ModerationSettingModal

View File

@ -1,5 +1,6 @@
'use client'
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { Dispatch, SetStateAction } from 'react'
import type { TriggerEventsLimitModalPayload } from './hooks/use-trigger-events-limit-modal'
import type { OpeningStatement } from '@/app/components/base/features/types'
@ -47,6 +48,7 @@ export type ModelModalType = {
export type ModalContextState = {
setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<AccountSettingTab> | null>>
setShowApiBasedExtensionModal: Dispatch<SetStateAction<ModalState<Partial<ApiBasedExtensionResponse>> | null>>
setShowModerationSettingModal: Dispatch<SetStateAction<ModalState<ModerationConfig> | null>>
setShowExternalDataToolModal: Dispatch<SetStateAction<ModalState<ExternalDataTool> | null>>
setShowPricingModal: () => void
@ -66,6 +68,7 @@ export type ModalContextState = {
export const ModalContext = createContext<ModalContextState>({
setShowAccountSettingModal: noop,
setShowApiBasedExtensionModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,