From 8f93bb36ba9d8895d990a17569d6fe0e67945c40 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 8 May 2026 16:53:32 +0800 Subject: [PATCH] feat(dify-ui): add drawer (#35917) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 125 ++-- packages/dify-ui/README.md | 27 +- packages/dify-ui/package.json | 4 + .../src/drawer/__tests__/index.spec.tsx | 61 ++ packages/dify-ui/src/drawer/index.tsx | 116 ++++ .../tools/tool-provider-detail-flow.test.tsx | 2 +- .../app-publisher/__tests__/index.spec.tsx | 44 ++ .../app-publisher/__tests__/sections.spec.tsx | 26 +- .../components/app/app-publisher/index.tsx | 64 +- .../components/app/app-publisher/sections.tsx | 38 +- .../chat-with-history/sidebar/operation.tsx | 30 +- .../chat/__tests__/use-chat-layout.spec.tsx | 2 + .../base/chat/chat/use-chat-layout.ts | 52 +- .../src/vender/line/development/index.ts | 1 - .../documents/detail/__tests__/index.spec.tsx | 8 +- .../documents/detail/batch-modal/index.tsx | 60 +- .../detail/completed/__tests__/index.spec.tsx | 13 +- .../__tests__/segment-detail.spec.tsx | 32 +- .../common/__tests__/drawer.spec.tsx | 167 +++-- .../__tests__/full-screen-drawer.spec.tsx | 116 ++-- .../detail/completed/common/drawer.tsx | 187 ++---- .../completed/common/full-screen-drawer.tsx | 33 +- .../__tests__/drawer-group.spec.tsx | 16 +- .../completed/components/drawer-group.tsx | 59 +- .../detail/completed/components/index.ts | 3 - .../hooks/__tests__/use-modal-state.spec.ts | 70 +- .../__tests__/use-segment-list-data.spec.ts | 7 +- .../detail/completed/hooks/use-modal-state.ts | 35 +- .../completed/hooks/use-segment-list-data.ts | 7 +- .../documents/detail/completed/index.tsx | 10 +- .../detail/completed/segment-detail.tsx | 24 +- .../datasets/documents/detail/index.tsx | 14 +- .../segment-add/__tests__/index.spec.tsx | 51 +- .../documents/detail/segment-add/index.tsx | 109 ++-- .../__tests__/api-key-modal.spec.tsx | 4 - .../__tests__/oauth-client-settings.spec.tsx | 4 - .../plugin-auth/authorize/api-key-modal.tsx | 3 +- .../authorize/oauth-client-settings.tsx | 3 +- .../edit/apikey-edit-modal.tsx | 3 +- .../edit/manual-edit-modal.tsx | 3 +- .../edit/oauth-edit-modal.tsx | 3 +- .../readme-panel/__tests__/entrance.spec.tsx | 49 +- .../readme-panel/__tests__/index.spec.tsx | 597 ++++-------------- .../readme-panel/__tests__/store.spec.ts | 54 +- .../plugins/readme-panel/content.tsx | 81 +++ .../plugins/readme-panel/dialog.tsx | 52 ++ .../plugins/readme-panel/drawer.tsx | 62 ++ .../plugins/readme-panel/entrance.tsx | 30 +- .../components/plugins/readme-panel/index.tsx | 142 +---- .../components/plugins/readme-panel/store.ts | 39 +- .../share/text-generation/menu-dropdown.tsx | 12 +- .../tools/labels/__tests__/selector.spec.tsx | 30 +- web/app/components/tools/labels/selector.tsx | 28 +- .../tools/provider/__tests__/detail.spec.tsx | 16 +- web/app/components/tools/provider/detail.tsx | 20 +- .../__tests__/configure-button.spec.tsx | 420 +++++------- .../workflow-tool/__tests__/index.spec.tsx | 35 +- .../tools/workflow-tool/configure-button.tsx | 81 +-- .../__tests__/use-configure-button.spec.ts | 76 +-- .../hooks/use-configure-button.ts | 32 +- .../components/tools/workflow-tool/index.tsx | 112 ++-- .../__tests__/features-trigger.spec.tsx | 2 +- web/docs/overlay-migration.md | 15 +- web/eslint.constants.mjs | 9 + web/models/datasets.ts | 3 +- web/types/dataset.ts | 8 + 66 files changed, 1686 insertions(+), 1955 deletions(-) create mode 100644 packages/dify-ui/src/drawer/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/drawer/index.tsx delete mode 100644 web/app/components/datasets/documents/detail/completed/components/index.ts create mode 100644 web/app/components/plugins/readme-panel/content.tsx create mode 100644 web/app/components/plugins/readme-panel/dialog.tsx create mode 100644 web/app/components/plugins/readme-panel/drawer.tsx create mode 100644 web/types/dataset.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b5e67df509..cb41ef5f83 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -202,6 +202,11 @@ "count": 1 } }, + "web/app/components/app/annotation/add-annotation-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -230,6 +235,11 @@ "count": 1 } }, + "web/app/components/app/annotation/edit-annotation-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/annotation/header-opts/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -252,6 +262,9 @@ "erasable-syntax-only/enums": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 5 }, @@ -269,11 +282,6 @@ "count": 4 } }, - "web/app/components/app/app-publisher/index.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "web/app/components/app/app-publisher/version-info-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -344,6 +352,9 @@ } }, "web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/exhaustive-deps": { "count": 1 }, @@ -401,6 +412,16 @@ "count": 2 } }, + "web/app/components/app/configuration/configuration-view.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/app/components/app/configuration/dataset-config/card-item/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/configuration/dataset-config/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -531,6 +552,9 @@ } }, "web/app/components/app/log/list.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 6 }, @@ -580,6 +604,9 @@ } }, "web/app/components/app/workflow-log/list.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 2 } @@ -904,6 +931,11 @@ "count": 1 } }, + "web/app/components/base/drawer-plus/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/base/emoji-picker/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1029,6 +1061,11 @@ "count": 3 } }, + "web/app/components/base/float-right-container/index.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "web/app/components/base/form/components/base/base-form.tsx": { "ts/no-explicit-any": { "count": 6 @@ -1233,7 +1270,7 @@ }, "web/app/components/base/icons/src/vender/line/development/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 2 + "count": 1 } }, "web/app/components/base/icons/src/vender/line/editor/index.ts": { @@ -2144,14 +2181,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/batch-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": { "react/set-state-in-effect": { "count": 1 @@ -2162,11 +2191,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/components/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 3 - } - }, "web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -2231,14 +2255,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/segment-add/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": { "ts/no-explicit-any": { "count": 6 @@ -2280,6 +2296,9 @@ } }, "web/app/components/datasets/hit-testing/index.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/unsupported-syntax": { "count": 1 } @@ -2319,7 +2338,7 @@ }, "web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": { "no-restricted-imports": { - "count": 2 + "count": 3 } }, "web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": { @@ -2813,10 +2832,18 @@ } }, "web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 7 } }, + "web/app/components/plugins/plugin-detail-panel/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/model-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2838,6 +2865,9 @@ } }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } @@ -2896,6 +2926,9 @@ } }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 5 } @@ -2933,16 +2966,6 @@ "count": 1 } }, - "web/app/components/plugins/readme-panel/index.tsx": { - "react/unsupported-syntax": { - "count": 1 - } - }, - "web/app/components/plugins/readme-panel/store.ts": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -3170,7 +3193,7 @@ }, "web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { @@ -3179,6 +3202,9 @@ } }, "web/app/components/tools/edit-custom-collection-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 4 }, @@ -3187,6 +3213,9 @@ } }, "web/app/components/tools/edit-custom-collection-modal/test-api.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -3196,6 +3225,11 @@ "count": 1 } }, + "web/app/components/tools/mcp/detail/provider-detail.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/tools/mcp/mcp-server-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -3224,12 +3258,20 @@ "count": 1 } }, + "web/app/components/tools/provider/detail.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/tools/provider/empty.tsx": { "ts/no-explicit-any": { "count": 1 } }, "web/app/components/tools/setting/build-in/config-credentials.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } @@ -4061,6 +4103,11 @@ "count": 1 } }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index bdeeec33cb..2915fe5db7 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel: import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import '@langgenius/dify-ui/styles.css' // once, in the app root ``` @@ -36,12 +37,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported ## Primitives -| Category | Subpath | Notes | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | -| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | -| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | -| Media | `./avatar`, `./button` | Button exposes `cva` variants. | +| Category | Subpath | Notes | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | +| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | +| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | +| Media | `./avatar`, `./button` | Button exposes `cva` variants. | Utilities: @@ -65,7 +66,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s ## Overlay & portal contract -All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually. +Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure. ### Root isolation requirement @@ -83,19 +84,19 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites. -| Layer | z-index | Where | -| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | -| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | -| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | +| Layer | z-index | Where | +| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | +| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | +| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | -Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. +Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`. ### Rules - Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated. -- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal. +- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal. - When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites. ## Development diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 20e94c7dee..894e92bfd6 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -37,6 +37,10 @@ "types": "./src/dialog/index.tsx", "import": "./src/dialog/index.tsx" }, + "./drawer": { + "types": "./src/drawer/index.tsx", + "import": "./src/drawer/index.tsx" + }, "./dropdown-menu": { "types": "./src/dropdown-menu/index.tsx", "import": "./src/dropdown-menu/index.tsx" diff --git a/packages/dify-ui/src/drawer/__tests__/index.spec.tsx b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx new file mode 100644 index 0000000000..8c3a93f02c --- /dev/null +++ b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx @@ -0,0 +1,61 @@ +import { render } from 'vitest-browser-react' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerTrigger, + DrawerViewport, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Drawer wrapper', () => { + describe('User Interactions', () => { + it('should open a portalled drawer and close it with the default close button', async () => { + const screen = await render( + + Open settings + + + + + Settings + Configure the current workspace. + +

Workspace controls

+ +
+
+
+
+
, + ) + + expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument() + + asHTMLElement(screen.getByRole('button', { name: 'Open settings' }).element()).click() + + await vi.waitFor(() => { + expect(document.body.querySelector('[role="dialog"]')).toBeInTheDocument() + }) + + const dialog = asHTMLElement(document.body.querySelector('[role="dialog"]')!) + expect(document.body).toContainElement(dialog) + expect(screen.container).not.toContainElement(dialog) + await expect.element(dialog).toHaveTextContent('Workspace controls') + await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument() + await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-1002') + + asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click() + + await vi.waitFor(() => { + expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/packages/dify-ui/src/drawer/index.tsx b/packages/dify-ui/src/drawer/index.tsx new file mode 100644 index 0000000000..c63bc8174e --- /dev/null +++ b/packages/dify-ui/src/drawer/index.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { ReactNode } from 'react' +import { Drawer as BaseDrawer } from '@base-ui/react/drawer' +import { cn } from '../cn' + +export const Drawer = BaseDrawer.Root +export const DrawerProvider = BaseDrawer.Provider +export const DrawerIndent = BaseDrawer.Indent +export const DrawerIndentBackground = BaseDrawer.IndentBackground +export const DrawerTrigger = BaseDrawer.Trigger +export const DrawerSwipeArea = BaseDrawer.SwipeArea +export const DrawerPortal = BaseDrawer.Portal +export const DrawerTitle = BaseDrawer.Title +export const DrawerDescription = BaseDrawer.Description +export const DrawerClose = BaseDrawer.Close +export const createDrawerHandle = BaseDrawer.createHandle + +export type DrawerRootProps = BaseDrawer.Root.Props +export type DrawerRootActions = BaseDrawer.Root.Actions +export type DrawerRootChangeEventDetails = BaseDrawer.Root.ChangeEventDetails +export type DrawerRootChangeEventReason = BaseDrawer.Root.ChangeEventReason +export type DrawerRootSnapPoint = BaseDrawer.Root.SnapPoint +export type DrawerRootSnapPointChangeEventDetails = BaseDrawer.Root.SnapPointChangeEventDetails +export type DrawerRootSnapPointChangeEventReason = BaseDrawer.Root.SnapPointChangeEventReason +export type DrawerTriggerProps = BaseDrawer.Trigger.Props + +export function DrawerBackdrop({ + className, + ...props +}: BaseDrawer.Backdrop.Props) { + return ( + + ) +} + +export function DrawerViewport({ + className, + ...props +}: BaseDrawer.Viewport.Props) { + return ( + + ) +} + +export function DrawerPopup({ + className, + ...props +}: BaseDrawer.Popup.Props) { + return ( + + ) +} + +export function DrawerContent({ + className, + ...props +}: BaseDrawer.Content.Props) { + return ( + + ) +} + +type DrawerCloseButtonProps = Omit & { + children?: ReactNode +} + +export function DrawerCloseButton({ + className, + children, + type = 'button', + 'aria-label': ariaLabel = 'Close drawer', + ...props +}: DrawerCloseButtonProps) { + return ( + + {children ?? + ) +} diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index 9cf4772152..c0dd6da1c5 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({ })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => ( + WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index cbfd679ace..1fad833933 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -91,6 +91,21 @@ vi.mock('@/service/use-workflow', () => ({ useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, })) +vi.mock('@/service/use-tools', () => ({ + useWorkflowToolDetailByAppID: () => ({ + data: undefined, + isLoading: false, + }), + useInvalidateAllWorkflowTools: () => vi.fn(), + useInvalidateWorkflowToolDetailByAppID: () => vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), @@ -121,6 +136,15 @@ vi.mock('../../app-access-control', () => ({ ), })) +vi.mock('@/app/components/tools/workflow-tool', () => ({ + WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => ( +
+ workflow tool drawer + +
+ ), +})) + vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('../sections', () => ({ @@ -143,6 +167,7 @@ vi.mock('../sections', () => ({
+
) }, @@ -231,6 +256,25 @@ describe('AppPublisher', () => { expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument() }) + it('should keep workflow tool drawer mounted after closing the publish popover', () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + expect(screen.getByTestId('workflow-tool-drawer')).toBeInTheDocument() + }) + it('should close embedded and access control panels through child callbacks', async () => { render( { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} - handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} missingStartNode={false} - onRefreshData={vi.fn()} - outputs={[]} - published={true} publishedAt={Date.now()} toolPublished workflowToolAvailable={false} + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager workflowToolMessage="workflow-disabled" + onConfigureWorkflowTool={vi.fn()} />, ) @@ -223,17 +222,16 @@ describe('app-publisher sections', () => { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} - handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} missingStartNode - onRefreshData={vi.fn()} - outputs={[]} - published={false} publishedAt={Date.now()} toolPublished={false} workflowToolAvailable + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager + onConfigureWorkflowTool={vi.fn()} />, ) @@ -248,16 +246,16 @@ describe('app-publisher sections', () => { disabledFunctionButton={false} handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} - handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode - inputs={[]} missingStartNode={false} - outputs={[]} - published={false} publishedAt={undefined} toolPublished={false} workflowToolAvailable + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager + onConfigureWorkflowTool={vi.fn()} />, ) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index fe6fe5806f..a066233107 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -5,13 +5,12 @@ import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { RiStoreLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { memo, + use, useCallback, - useContext, useEffect, useMemo, useState, @@ -20,9 +19,12 @@ import { useTranslation } from 'react-i18next' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' +import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' +import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' +import { appDefaultIconBackground } from '@/config' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { AccessMode } from '@/models/access-control' @@ -57,8 +59,8 @@ export type AppPublisherProps = { debugWithMultipleModel?: boolean multipleModelConfigs?: ModelAndParameter[] /** modelAndParameter is passed when debugWithMultipleModel is true */ - onPublish?: (params?: any) => Promise | any - onRestore?: () => Promise | any + onPublish?: AppPublisherPublishHandler + onRestore?: AppPublisherRestoreHandler onToggle?: (state: boolean) => void crossAxisOffset?: number toolPublished?: boolean @@ -74,6 +76,12 @@ export type AppPublisherProps = { const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] +type AppPublisherPublishHandler + = | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise | unknown) + | ((params?: unknown) => Promise | unknown) + +type AppPublisherRestoreHandler = () => Promise | unknown + const AppPublisher = ({ disabled = false, publishDisabled = false, @@ -100,11 +108,12 @@ const AppPublisher = ({ const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) const [showAppAccessControl, setShowAppAccessControl] = useState(false) + const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) - const workflowStore = useContext(WorkflowContext) + const workflowStore = use(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -273,6 +282,31 @@ const AppPublisher = ({ const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined + const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode + const workflowToolPublished = !!toolPublished + const closeWorkflowToolDrawer = useCallback(() => setWorkflowToolDrawerOpen(false), []) + const workflowToolIcon = useMemo(() => ({ + content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', + background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground, + }), [appDetail?.icon, appDetail?.icon_background, appDetail?.icon_type]) + const workflowTool = useConfigureButton({ + enabled: workflowToolVisible, + published: workflowToolPublished, + detailNeedUpdate: workflowToolPublished && published, + workflowAppId: appDetail?.id ?? '', + icon: workflowToolIcon, + name: appDetail?.name ?? '', + description: appDetail?.description ?? '', + inputs, + outputs, + handlePublish, + onRefreshData, + onConfigured: closeWorkflowToolDrawer, + }) + const openWorkflowToolDrawer = useCallback(() => { + handleOpenChange(false) + setWorkflowToolDrawerOpen(true) + }, [handleOpenChange]) const upgradeHighlightStyle = useMemo(() => ({ background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', WebkitBackgroundClip: 'text', @@ -343,23 +377,22 @@ const AppPublisher = ({ handleOpenChange(false) handleOpenInExplore() }} - handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} - inputs={inputs} missingStartNode={missingStartNode} - onRefreshData={onRefreshData} - outputs={outputs} - published={published} publishedAt={publishedAt} toolPublished={toolPublished} workflowToolAvailable={workflowToolAvailable} + workflowToolIsLoading={workflowTool.isLoading} + workflowToolOutdated={workflowTool.outdated} + workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager} workflowToolMessage={workflowToolMessage} + onConfigureWorkflowTool={openWorkflowToolDrawer} /> {systemFeatures.enable_creators_platform && (
} + icon={} disabled={!publishedAt || publishingToMarketplace} onClick={handlePublishToMarketplace} > @@ -380,6 +413,15 @@ const AppPublisher = ({ /> {showAppAccessControl && { setShowAppAccessControl(false) }} />} + {workflowToolDrawerOpen && ( + + )} ) } diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index 57522095ae..36422e0055 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -10,11 +10,9 @@ import { } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' import Loading from '@/app/components/base/loading' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' -import { appDefaultIconBackground } from '@/config' import { AppModeEnum } from '@/types/app' import ShortcutsName from '../../workflow/shortcuts-name' import PublishWithMultipleModel from './publish-with-multiple-model' @@ -46,11 +44,8 @@ type AccessSectionProps = { type ActionsSectionProps = Pick & { appDetail: { @@ -67,9 +62,11 @@ type ActionsSectionProps = Pick void handleOpenInExplore: () => void - handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise - published: boolean + workflowToolIsLoading: boolean + workflowToolOutdated: boolean + workflowToolIsCurrentWorkspaceManager: boolean workflowToolMessage?: string + onConfigureWorkflowTool: () => void } export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => { @@ -256,18 +253,17 @@ export const PublisherActionsSection = ({ disabledFunctionTooltip, handleEmbed, handleOpenInExplore, - handlePublish, hasHumanInputNode = false, hasTriggerNode = false, - inputs, missingStartNode = false, - onRefreshData, - outputs, - published, publishedAt, toolPublished, workflowToolAvailable = true, + workflowToolIsLoading, + workflowToolOutdated, + workflowToolIsCurrentWorkspaceManager, workflowToolMessage, + onConfigureWorkflowTool, }: ActionsSectionProps) => { const { t } = useTranslation() @@ -305,7 +301,7 @@ export const PublisherActionsSection = ({ } + icon={} > {t('common.embedIntoSite', { ns: 'workflow' })} @@ -340,18 +336,10 @@ export const PublisherActionsSection = ({ )} diff --git a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx index a350e3f316..261ad1a280 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx @@ -54,22 +54,22 @@ const Operation: FC = ({ onOpenChange={setOpen} > } + render={( + + + + )} onClick={e => e.stopPropagation()} - > - - - - + /> { act(() => { capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver) + flushAnimationFrames() }) expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px') act(() => { capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver) + flushAnimationFrames() }) expect(screen.getByTestId('chat-footer').style.width).toBe('560px') diff --git a/web/app/components/base/chat/chat/use-chat-layout.ts b/web/app/components/base/chat/chat/use-chat-layout.ts index 712382070d..1983a928ca 100644 --- a/web/app/components/base/chat/chat/use-chat-layout.ts +++ b/web/app/components/base/chat/chat/use-chat-layout.ts @@ -12,6 +12,11 @@ type UseChatLayoutOptions = { sidebarCollapseState?: boolean } +const setStyleValue = (element: HTMLElement, property: 'paddingBottom' | 'width', value: string) => { + if (element.style[property] !== value) + element.style[property] = value +} + export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => { const [width, setWidth] = useState(0) const chatContainerRef = useRef(null) @@ -21,6 +26,9 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO const userScrolledRef = useRef(false) const isAutoScrollingRef = useRef(false) const prevFirstMessageIdRef = useRef(undefined) + const resizeObserverFrameRef = useRef(null) + const pendingFooterBlockSizeRef = useRef(null) + const pendingContainerInlineSizeRef = useRef(null) const handleScrollToBottom = useCallback(() => { if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { @@ -34,16 +42,39 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO }, [chatList.length]) const handleWindowResize = useCallback(() => { - if (chatContainerRef.current) - setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8) + if (chatContainerRef.current) { + const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8 + setWidth(currentWidth => currentWidth === nextWidth ? currentWidth : nextWidth) + } if (chatContainerRef.current && chatFooterRef.current) - chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` + setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`) if (chatContainerInnerRef.current && chatFooterInnerRef.current) - chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` + setStyleValue(chatFooterInnerRef.current, 'width', `${chatContainerInnerRef.current.clientWidth}px`) }, []) + const scheduleResizeObserverUpdate = useCallback(() => { + if (resizeObserverFrameRef.current !== null) + return + + resizeObserverFrameRef.current = requestAnimationFrame(() => { + resizeObserverFrameRef.current = null + + const footerBlockSize = pendingFooterBlockSizeRef.current + pendingFooterBlockSizeRef.current = null + if (footerBlockSize !== null && chatContainerRef.current) { + setStyleValue(chatContainerRef.current, 'paddingBottom', `${footerBlockSize}px`) + handleScrollToBottom() + } + + const containerInlineSize = pendingContainerInlineSizeRef.current + pendingContainerInlineSizeRef.current = null + if (containerInlineSize !== null && chatFooterRef.current) + setStyleValue(chatFooterRef.current, 'width', `${containerInlineSize}px`) + }) + }, [handleScrollToBottom]) + useEffect(() => { handleScrollToBottom() const animationFrame = requestAnimationFrame(handleWindowResize) @@ -77,26 +108,31 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO const resizeContainerObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { blockSize } = entry.borderBoxSize[0]! - chatContainerRef.current!.style.paddingBottom = `${blockSize}px` - handleScrollToBottom() + pendingFooterBlockSizeRef.current = blockSize } + scheduleResizeObserverUpdate() }) resizeContainerObserver.observe(chatFooterRef.current) const resizeFooterObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { inlineSize } = entry.borderBoxSize[0]! - chatFooterRef.current!.style.width = `${inlineSize}px` + pendingContainerInlineSizeRef.current = inlineSize } + scheduleResizeObserverUpdate() }) resizeFooterObserver.observe(chatContainerRef.current) return () => { + if (resizeObserverFrameRef.current !== null) { + cancelAnimationFrame(resizeObserverFrameRef.current) + resizeObserverFrameRef.current = null + } resizeContainerObserver.disconnect() resizeFooterObserver.disconnect() } } - }, [handleScrollToBottom]) + }, [scheduleResizeObserverUpdate]) useEffect(() => { const setUserScrolled = () => { diff --git a/web/app/components/base/icons/src/vender/line/development/index.ts b/web/app/components/base/icons/src/vender/line/development/index.ts index 7c3c48aa5e..4278370eec 100644 --- a/web/app/components/base/icons/src/vender/line/development/index.ts +++ b/web/app/components/base/icons/src/vender/line/development/index.ts @@ -1,2 +1 @@ export { default as BracketsX } from './BracketsX' -export { default as CodeBrowser } from './CodeBrowser' diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index dc0dd438ce..900c12a416 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -120,18 +120,12 @@ vi.mock('../document-title', () => ({ })) vi.mock('../segment-add', () => ({ - default: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => ( + SegmentAdd: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
), - ProcessStatus: { - WAITING: 'waiting', - PROCESSING: 'processing', - ERROR: 'error', - COMPLETED: 'completed', - }, })) vi.mock('../../components/operations', () => ({ diff --git a/web/app/components/datasets/documents/detail/batch-modal/index.tsx b/web/app/components/datasets/documents/detail/batch-modal/index.tsx index c0d9a58e98..4e190ef3fd 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/index.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/index.tsx @@ -2,12 +2,15 @@ import type { FC } from 'react' import type { ChunkingMode, FileItem } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' -import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import * as React from 'react' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' import CSVDownloader from './csv-downloader' import CSVUploader from './csv-uploader' @@ -18,8 +21,9 @@ type IBatchModalProps = { onConfirm: (file: FileItem) => void } -const BatchModal: FC = ({ - isShow, +type BatchModalContentProps = Omit + +const BatchModalContent: FC = ({ docForm, onCancel, onConfirm, @@ -35,17 +39,13 @@ const BatchModal: FC = ({ onConfirm(currentCSV) } - useEffect(() => { - if (!isShow) - setCurrentCSV(undefined) - }, [isShow]) - return ( - -
{t('list.batchModal.title', { ns: 'datasetDocuments' })}
-
- -
+ + {t('list.batchModal.title', { ns: 'datasetDocuments' })} + = ({ {t('list.batchModal.run', { ns: 'datasetDocuments' })}
- + ) } + +const BatchModal: FC = ({ + isShow, + docForm, + onCancel, + onConfirm, +}) => { + return ( + !open && onCancel()} + disablePointerDismissal + > + {isShow + ? ( + + ) + : null} + + ) +} + export default React.memo(BatchModal) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx index f50b405c6f..900c974252 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx @@ -137,9 +137,8 @@ vi.mock('../hooks/use-child-segment-data', () => ({ }, })) -// Mock child components to simplify testing -vi.mock('../components', () => ({ - MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: { +vi.mock('../components/menu-bar', () => ({ + default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: { totalText: string onInputChange: (value: string) => void inputValue: string @@ -167,7 +166,13 @@ vi.mock('../components', () => ({ )}
), +})) + +vi.mock('../components/drawer-group', () => ({ DrawerGroup: () =>
, +})) + +vi.mock('../components/segment-list-content', () => ({ FullDocModeContent: () =>
, GeneralModeContent: () =>
, })) @@ -563,7 +568,7 @@ describe('Edge Cases', () => { expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument() }) - it('should handle ProcessStatus.COMPLETED importStatus', () => { + it('should handle completed importStatus', () => { render(, { wrapper: createWrapper() }) expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument() diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx index 4e17cd39b3..1f10053596 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode } from '@/models/datasets' -import SegmentDetail from '../segment-detail' +import { SegmentDetail } from '../segment-detail' // Mock dataset detail context let mockIndexingTechnique = IndexingType.QUALIFIED @@ -167,7 +167,6 @@ describe('SegmentDetail', () => { onCancel: vi.fn(), isEditMode: false, docForm: ChunkingMode.text, - onModalStateChange: vi.fn(), } describe('Rendering', () => { @@ -352,35 +351,12 @@ describe('SegmentDetail', () => { expect(screen.getByTestId('regeneration-modal'))!.toBeInTheDocument() }) - it('should call onModalStateChange when regeneration modal opens', () => { - const mockOnModalStateChange = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByTestId('regenerate-btn')) - - expect(mockOnModalStateChange).toHaveBeenCalledWith(true) - }) - it('should close modal when cancel is clicked', () => { - const mockOnModalStateChange = vi.fn() - render( - , - ) + render() fireEvent.click(screen.getByTestId('regenerate-btn')) fireEvent.click(screen.getByTestId('cancel-regeneration')) - expect(mockOnModalStateChange).toHaveBeenCalledWith(false) expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument() }) }) @@ -504,22 +480,18 @@ describe('SegmentDetail', () => { it('should close modal and edit drawer when close after regeneration is clicked', () => { const mockOnCancel = vi.fn() - const mockOnModalStateChange = vi.fn() render( , ) - // Open regeneration modal fireEvent.click(screen.getByTestId('regenerate-btn')) fireEvent.click(screen.getByTestId('close-regeneration')) - expect(mockOnModalStateChange).toHaveBeenCalledWith(false) expect(mockOnCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx index d9a87ea3e4..4ba28f3335 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx @@ -1,27 +1,16 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Drawer from '../drawer' +import { CompletedDrawer } from '../drawer' -let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined +( + globalThis as typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean + } +).BASE_UI_ANIMATIONS_DISABLED = true -// Mock useKeyPress: required because tests capture the registered callback -// and invoke it directly to verify ESC key handling behavior. -vi.mock('ahooks', () => ({ - useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => { - capturedKeyPressCallback = cb - }), -})) - -vi.mock('../..', () => ({ - useSegmentListContext: (selector: (state: { - currSegment: { showModal: boolean } - currChildChunk: { showModal: boolean } - }) => unknown) => - selector({ - currSegment: { showModal: false }, - currChildChunk: { showModal: false }, - }), -})) +const getOverlay = () => + Array.from(document.querySelectorAll('[class]')) + .find(element => element.className.includes('bg-background-overlay')) describe('Drawer', () => { const defaultProps = { @@ -31,103 +20,109 @@ describe('Drawer', () => { beforeEach(() => { vi.clearAllMocks() - capturedKeyPressCallback = undefined }) describe('Rendering', () => { it('should return null when open is false', () => { const { container } = render( - + Content - , + , ) expect(container.innerHTML).toBe('') expect(screen.queryByText('Content')).not.toBeInTheDocument() }) - it('should render children in portal when open is true', () => { + it('should render children in the drawer portal when open is true', () => { render( - + Drawer content - , + , ) expect(screen.getByText('Drawer content')).toBeInTheDocument() - }) - - it('should render dialog with role="dialog"', () => { - render( - - Content - , - ) - expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) - // Overlay visibility - describe('Overlay', () => { - it('should show overlay when showOverlay is true', () => { + describe('Variant', () => { + it('should render a panel drawer without overlay by default', () => { render( - + Content - , - ) - - const overlay = document.querySelector('[aria-hidden="true"]') - expect(overlay).toBeInTheDocument() - }) - - it('should hide overlay when showOverlay is false', () => { - render( - - Content - , - ) - - const overlay = document.querySelector('[aria-hidden="true"]') - expect(overlay).not.toBeInTheDocument() - }) - }) - - // aria-modal attribute - describe('aria-modal', () => { - it('should set aria-modal="true" when modal is true', () => { - render( - - Content - , - ) - - expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true') - }) - - it('should set aria-modal="false" when modal is false', () => { - render( - - Content - , + , ) + expect(getOverlay()).toBeUndefined() expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false') }) - }) - // ESC key handling - describe('ESC Key', () => { - it('should call onClose when ESC is pressed and drawer is open', () => { - const onClose = vi.fn() + it('should render a modal drawer with overlay', () => { render( - + Content - , + , ) - expect(capturedKeyPressCallback).toBeDefined() - const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent - capturedKeyPressCallback!(fakeEvent) + expect(getOverlay()).toBeInTheDocument() + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true') + }) + }) + + describe('Dismissal', () => { + it('should call onClose when Escape is pressed', async () => { + const onClose = vi.fn() + render( + + Content + , + ) + + fireEvent.keyDown(document, { key: 'Escape' }) + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + it('should keep a panel drawer open when the underlying page is clicked', () => { + const onClose = vi.fn() + render( + <> + + + Content + + , + ) + + fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' })) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('should keep a panel drawer open when the pointer down starts inside content', () => { + const onClose = vi.fn() + render( + + + , + ) + + fireEvent.pointerDown(screen.getByRole('button', { name: 'Inside' })) + + expect(onClose).not.toHaveBeenCalled() + }) + it('should close a modal drawer when the overlay is clicked', () => { + const onClose = vi.fn() + render( + + Content + , + ) + + fireEvent.click(getOverlay()!) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx index ae870c8e1c..6b6227492a 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx @@ -1,11 +1,11 @@ import type { ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import FullScreenDrawer from '../full-screen-drawer' +import { DocumentDetailDrawer } from '../full-screen-drawer' // Mock the Drawer component since it has high complexity vi.mock('../drawer', () => ({ - default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => { + CompletedDrawer: ({ children, open, panelClassName, panelContentClassName, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, modal: boolean }) => { if (!open) return null return ( @@ -13,8 +13,6 @@ vi.mock('../drawer', () => ({ data-testid="drawer-mock" data-panel-class={panelClassName} data-panel-content-class={panelContentClassName} - data-show-overlay={showOverlay} - data-need-check-chunks={needCheckChunks} data-modal={modal} > {children} @@ -23,7 +21,7 @@ vi.mock('../drawer', () => ({ }, })) -describe('FullScreenDrawer', () => { +describe('DocumentDetailDrawer', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -31,9 +29,9 @@ describe('FullScreenDrawer', () => { describe('Rendering', () => { it('should render without crashing when open', () => { render( - +
Content
-
, + , ) expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() @@ -41,9 +39,9 @@ describe('FullScreenDrawer', () => { it('should not render when closed', () => { render( - +
Content
-
, + , ) expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() @@ -51,9 +49,9 @@ describe('FullScreenDrawer', () => { it('should render children content', () => { render( - +
Test Content
-
, + , ) expect(screen.getByText('Test Content')).toBeInTheDocument() @@ -63,86 +61,46 @@ describe('FullScreenDrawer', () => { describe('Props', () => { it('should pass fullScreen=true to Drawer with full width class', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-full') + expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-full') + expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-full') }) it('should pass fullScreen=false to Drawer with fixed width class', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]') + expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-[568px]') + expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-[568px]') }) - it('should pass showOverlay prop with default true', () => { + it('should render as non-modal by default', () => { render( - +
Content
-
, - ) - - const drawer = screen.getByTestId('drawer-mock') - expect(drawer.getAttribute('data-show-overlay')).toBe('true') - }) - - it('should pass showOverlay=false when specified', () => { - render( - -
Content
-
, - ) - - const drawer = screen.getByTestId('drawer-mock') - expect(drawer.getAttribute('data-show-overlay')).toBe('false') - }) - - it('should pass needCheckChunks prop with default false', () => { - render( - -
Content
-
, - ) - - const drawer = screen.getByTestId('drawer-mock') - expect(drawer.getAttribute('data-need-check-chunks')).toBe('false') - }) - - it('should pass needCheckChunks=true when specified', () => { - render( - -
Content
-
, - ) - - const drawer = screen.getByTestId('drawer-mock') - expect(drawer.getAttribute('data-need-check-chunks')).toBe('true') - }) - - it('should pass modal prop with default false', () => { - render( - -
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-modal')).toBe('false') }) - it('should pass modal=true when specified', () => { + it('should pass modal when specified', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') @@ -154,9 +112,9 @@ describe('FullScreenDrawer', () => { describe('Styling', () => { it('should apply panel content classes for non-fullScreen mode', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') @@ -167,9 +125,9 @@ describe('FullScreenDrawer', () => { it('should apply panel content classes without border for fullScreen mode', () => { render( - +
Content
-
, + , ) const drawer = screen.getByTestId('drawer-mock') @@ -184,24 +142,24 @@ describe('FullScreenDrawer', () => { // Arrange & Act & Assert - should not throw expect(() => { render( - +
Content
-
, + , ) }).not.toThrow() }) it('should maintain structure when rerendered', () => { const { rerender } = render( - +
Content
-
, + , ) rerender( - +
Updated Content
-
, + , ) expect(screen.getByText('Updated Content')).toBeInTheDocument() @@ -209,16 +167,16 @@ describe('FullScreenDrawer', () => { it('should handle toggle between open and closed states', () => { const { rerender } = render( - +
Content
-
, + , ) expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() rerender( - +
Content
-
, + , ) expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() diff --git a/web/app/components/datasets/documents/detail/completed/common/drawer.tsx b/web/app/components/datasets/documents/detail/completed/common/drawer.tsx index 47b381e0ff..86c40a7367 100644 --- a/web/app/components/datasets/documents/detail/completed/common/drawer.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/drawer.tsx @@ -1,143 +1,92 @@ +import type { ComponentProps, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { useKeyPress } from 'ahooks' -import * as React from 'react' -import { useCallback, useEffect, useRef } from 'react' -import { createPortal } from 'react-dom' -import { useSegmentListContext } from '..' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' -type DrawerProps = { +type DrawerSide = 'right' | 'left' | 'bottom' | 'top' +type DrawerSwipeDirection = 'right' | 'left' | 'down' | 'up' +type DrawerOpenChange = NonNullable['onOpenChange']> + +type CompletedDrawerProps = { open: boolean onClose: () => void - side?: 'right' | 'left' | 'bottom' | 'top' - showOverlay?: boolean - modal?: boolean // click outside event can pass through if modal is false - closeOnOutsideClick?: boolean + side?: DrawerSide panelClassName?: string panelContentClassName?: string - needCheckChunks?: boolean + modal?: boolean + children: ReactNode } -const SIDE_POSITION_CLASS = { - right: 'right-0', - left: 'left-0', - bottom: 'bottom-0', - top: 'top-0', -} as const - -function containsTarget(selector: string, target: Node | null): boolean { - const elements = document.querySelectorAll(selector) - return Array.from(elements).some(el => el?.contains(target)) +const SIDE_TO_SWIPE_DIRECTION: Record = { + right: 'right', + left: 'left', + bottom: 'down', + top: 'up', } -function shouldReopenChunkDetail( - isClickOnChunk: boolean, - isClickOnChildChunk: boolean, - segmentModalOpen: boolean, - childChunkModalOpen: boolean, -): boolean { - if (segmentModalOpen && isClickOnChildChunk) - return true - if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk) - return true - return !isClickOnChunk && !isClickOnChildChunk -} +const DRAWER_POPUP_CLASS_NAME = [ + 'pointer-events-auto overflow-visible border-0 bg-transparent shadow-none', + 'data-[swipe-direction=right]:h-screen data-[swipe-direction=right]:max-w-none data-[swipe-direction=right]:rounded-none data-[swipe-direction=right]:border-0', + 'data-[swipe-direction=left]:h-screen data-[swipe-direction=left]:max-w-none data-[swipe-direction=left]:rounded-none data-[swipe-direction=left]:border-0', + 'data-[swipe-direction=down]:max-h-none data-[swipe-direction=down]:rounded-none data-[swipe-direction=down]:border-0', + 'data-[swipe-direction=up]:max-h-none data-[swipe-direction=up]:rounded-none data-[swipe-direction=up]:border-0', +].join(' ') -const Drawer = ({ +export function CompletedDrawer({ open, onClose, side = 'right', - showOverlay = true, - modal = false, - needCheckChunks = false, children, panelClassName, panelContentClassName, -}: React.PropsWithChildren) => { - const panelContentRef = useRef(null) - const currSegment = useSegmentListContext(s => s.currSegment) - const currChildChunk = useSegmentListContext(s => s.currChildChunk) - - useKeyPress('esc', (e) => { - if (!open) + modal = false, +}: CompletedDrawerProps) { + const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => { + if (nextOpen) return - e.preventDefault() + + if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press') + return + onClose() - }, { exactMatch: true, useCapture: true }) - - const shouldCloseDrawer = useCallback((target: Node | null) => { - const panelContent = panelContentRef.current - if (!panelContent || !target) - return false - - if (panelContent.contains(target)) - return false - - if (containsTarget('.image-previewer', target)) - return false - - if (!needCheckChunks) - return true - - const isClickOnChunk = containsTarget('.chunk-card', target) - const isClickOnChildChunk = containsTarget('.child-chunk', target) - return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal) - }, [currSegment.showModal, currChildChunk.showModal, needCheckChunks]) - - const onDownCapture = useCallback((e: PointerEvent) => { - if (!open || modal) - return - const panelContent = panelContentRef.current - if (!panelContent) - return - const target = e.target as Node | null - if (shouldCloseDrawer(target)) - queueMicrotask(onClose) - }, [shouldCloseDrawer, onClose, open, modal]) - - useEffect(() => { - window.addEventListener('pointerdown', onDownCapture, { capture: true }) - return () => - window.removeEventListener('pointerdown', onDownCapture, { capture: true }) - }, [onDownCapture]) - - const isHorizontal = side === 'left' || side === 'right' - - const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none' - - const content = ( -
- {showOverlay && ( - - ) + } if (!open) return null - return createPortal(content, document.body) + return ( + + + {modal && ( + + )} + + + + {children} + + + + + + ) } - -export default Drawer diff --git a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx index 01cc264a76..28a0d89262 100644 --- a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx @@ -1,46 +1,39 @@ +import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { noop } from 'es-toolkit/function' -import * as React from 'react' -import Drawer from './drawer' +import { CompletedDrawer } from './drawer' -type IFullScreenDrawerProps = { - isOpen: boolean +type DocumentDetailDrawerProps = { + open: boolean onClose?: () => void fullScreen: boolean - showOverlay?: boolean - needCheckChunks?: boolean modal?: boolean + children: ReactNode } -const FullScreenDrawer = ({ - isOpen, +export function DocumentDetailDrawer({ + open, onClose = noop, fullScreen, children, - showOverlay = true, - needCheckChunks = false, modal = false, -}: React.PropsWithChildren) => { +}: DocumentDetailDrawerProps) { return ( - {children} - + ) } - -export default FullScreenDrawer diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx index dfcb02215c..4e3c935f0a 100644 --- a/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx @@ -2,16 +2,16 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import DrawerGroup from '../drawer-group' +import { DrawerGroup } from '../drawer-group' vi.mock('../../common/full-screen-drawer', () => ({ - default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => ( - isOpen ?
{children}
: null + DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => ( + open ?
{children}
: null ), })) vi.mock('../../segment-detail', () => ({ - default: () =>
, + SegmentDetail: () =>
, })) vi.mock('../../child-segment-detail', () => ({ @@ -31,8 +31,6 @@ describe('DrawerGroup', () => { currSegment: { segInfo: undefined, showModal: false, isEditMode: false }, onCloseSegmentDetail: vi.fn(), onUpdateSegment: vi.fn(), - isRegenerationModalOpen: false, - setIsRegenerationModalOpen: vi.fn(), showNewSegmentModal: false, onCloseNewSegmentModal: vi.fn(), onSaveNewSegment: vi.fn(), @@ -55,7 +53,7 @@ describe('DrawerGroup', () => { it('should render nothing when all modals are closed', () => { const { container } = render() - expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull() + expect(container.querySelector('[data-testid="document-detail-drawer"]')).toBeNull() }) it('should render segment detail when segment modal is open', () => { @@ -66,6 +64,7 @@ describe('DrawerGroup', () => { />, ) expect(screen.getByTestId('segment-detail')).toBeInTheDocument() + expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false') }) it('should render new segment modal when showNewSegmentModal is true', () => { @@ -73,6 +72,7 @@ describe('DrawerGroup', () => { , ) expect(screen.getByTestId('new-segment')).toBeInTheDocument() + expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true') }) it('should render child segment detail when child chunk modal is open', () => { @@ -83,6 +83,7 @@ describe('DrawerGroup', () => { />, ) expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument() + expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false') }) it('should render new child segment modal when showNewChildSegmentModal is true', () => { @@ -90,6 +91,7 @@ describe('DrawerGroup', () => { , ) expect(screen.getByTestId('new-child-segment')).toBeInTheDocument() + expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true') }) it('should render multiple drawers simultaneously', () => { diff --git a/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx b/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx index 04f993b98c..d79433e0ff 100644 --- a/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/drawer-group.tsx @@ -1,15 +1,13 @@ 'use client' -import type { FC } from 'react' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets' import NewSegment from '@/app/components/datasets/documents/detail/new-segment' import ChildSegmentDetail from '../child-segment-detail' -import FullScreenDrawer from '../common/full-screen-drawer' +import { DocumentDetailDrawer } from '../common/full-screen-drawer' import NewChildSegment from '../new-child-segment' -import SegmentDetail from '../segment-detail' +import { SegmentDetail } from '../segment-detail' type DrawerGroupProps = { - // Segment detail drawer currSegment: { segInfo?: SegmentDetailModel showModal: boolean @@ -25,14 +23,10 @@ type DrawerGroupProps = { summary?: string, needRegenerate?: boolean, ) => Promise - isRegenerationModalOpen: boolean - setIsRegenerationModalOpen: (open: boolean) => void - // New segment drawer showNewSegmentModal: boolean onCloseNewSegmentModal: () => void onSaveNewSegment: () => void viewNewlyAddedChunk: () => void - // Child segment detail drawer currChildChunk: { childChunkInfo?: ChildChunkDetail showModal: boolean @@ -40,52 +34,39 @@ type DrawerGroupProps = { currChunkId: string onCloseChildSegmentDetail: () => void onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise - // New child segment drawer showNewChildSegmentModal: boolean onCloseNewChildChunkModal: () => void onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void viewNewlyAddedChildChunk: () => void - // Common props fullScreen: boolean docForm: ChunkingMode } -const DrawerGroup: FC = ({ - // Segment detail drawer +export function DrawerGroup({ currSegment, onCloseSegmentDetail, onUpdateSegment, - isRegenerationModalOpen, - setIsRegenerationModalOpen, - // New segment drawer showNewSegmentModal, onCloseNewSegmentModal, onSaveNewSegment, viewNewlyAddedChunk, - // Child segment detail drawer currChildChunk, currChunkId, onCloseChildSegmentDetail, onUpdateChildChunk, - // New child segment drawer showNewChildSegmentModal, onCloseNewChildChunkModal, onSaveNewChildChunk, viewNewlyAddedChildChunk, - // Common props fullScreen, docForm, -}) => { +}: DrawerGroupProps) { return ( <> - {/* Edit or view segment detail */} - = ({ isEditMode={currSegment.isEditMode} onUpdate={onUpdateSegment} onCancel={onCloseSegmentDetail} - onModalStateChange={setIsRegenerationModalOpen} /> - + - {/* Create New Segment */} - = ({ onSave={onSaveNewSegment} viewNewlyAddedChunk={viewNewlyAddedChunk} /> - + - {/* Edit or view child segment detail */} - = ({ onUpdate={onUpdateChildChunk} onCancel={onCloseChildSegmentDetail} /> - + - {/* Create New Child Segment */} - = ({ onSave={onSaveNewChildChunk} viewNewlyAddedChildChunk={viewNewlyAddedChildChunk} /> - + ) } - -export default DrawerGroup diff --git a/web/app/components/datasets/documents/detail/completed/components/index.ts b/web/app/components/datasets/documents/detail/completed/components/index.ts deleted file mode 100644 index 67bd6ae643..0000000000 --- a/web/app/components/datasets/documents/detail/completed/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as DrawerGroup } from './drawer-group' -export { default as MenuBar } from './menu-bar' -export { FullDocModeContent, GeneralModeContent } from './segment-list-content' diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts index 57e7ae5d5e..0f887083c2 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts @@ -1,7 +1,9 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useModalState } from '../use-modal-state' +import * as modalStateHooks from '../use-modal-state' + +const renderDatasetModalState = modalStateHooks.useModalState describe('useModalState', () => { const onNewSegmentModalChange = vi.fn() @@ -10,22 +12,21 @@ describe('useModalState', () => { vi.clearAllMocks() }) - const renderUseModalState = () => - renderHook(() => useModalState({ onNewSegmentModalChange })) + const renderModalState = () => + renderHook(() => renderDatasetModalState({ onNewSegmentModalChange })) it('should initialize with all modals closed', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() expect(result.current.currSegment.showModal).toBe(false) expect(result.current.currChildChunk.showModal).toBe(false) expect(result.current.showNewChildSegmentModal).toBe(false) - expect(result.current.isRegenerationModalOpen).toBe(false) expect(result.current.fullScreen).toBe(false) expect(result.current.isCollapsed).toBe(true) }) it('should open segment detail on card click', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel act(() => { @@ -37,8 +38,25 @@ describe('useModalState', () => { expect(result.current.currSegment.isEditMode).toBe(true) }) + it('should close child detail when opening segment detail', () => { + const { result } = renderModalState() + const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail + const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel + + act(() => { + result.current.onClickSlice(childDetail) + }) + act(() => { + result.current.onClickCard(segmentDetail) + }) + + expect(result.current.currSegment.showModal).toBe(true) + expect(result.current.currSegment.segInfo).toBe(segmentDetail) + expect(result.current.currChildChunk.showModal).toBe(false) + }) + it('should close segment detail and reset fullscreen', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel) @@ -55,7 +73,7 @@ describe('useModalState', () => { }) it('should open child segment detail on slice click', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail act(() => { @@ -67,8 +85,25 @@ describe('useModalState', () => { expect(result.current.currChunkId).toBe('seg-1') }) + it('should close segment detail when opening child detail', () => { + const { result } = renderModalState() + const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel + const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail + + act(() => { + result.current.onClickCard(segmentDetail) + }) + act(() => { + result.current.onClickSlice(childDetail) + }) + + expect(result.current.currSegment.showModal).toBe(false) + expect(result.current.currChildChunk.showModal).toBe(true) + expect(result.current.currChildChunk.childChunkInfo).toBe(childDetail) + }) + it('should close child segment detail', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail) @@ -81,7 +116,7 @@ describe('useModalState', () => { }) it('should handle new child chunk modal', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.handleAddNewChildChunk('parent-chunk-1') @@ -98,7 +133,7 @@ describe('useModalState', () => { }) it('should close new segment modal and notify parent', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.onCloseNewSegmentModal() @@ -108,7 +143,7 @@ describe('useModalState', () => { }) it('should toggle full screen', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.toggleFullScreen() @@ -122,7 +157,7 @@ describe('useModalState', () => { }) it('should toggle collapsed', () => { - const { result } = renderUseModalState() + const { result } = renderModalState() act(() => { result.current.toggleCollapsed() @@ -134,13 +169,4 @@ describe('useModalState', () => { }) expect(result.current.isCollapsed).toBe(true) }) - - it('should set regeneration modal state', () => { - const { result } = renderUseModalState() - - act(() => { - result.current.setIsRegenerationModalOpen(true) - }) - expect(result.current.isRegenerationModalOpen).toBe(true) - }) }) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts index 5b8f8d7e53..5616af241d 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts @@ -1,11 +1,12 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets' +import type { SegmentImportStatus } from '@/types/dataset' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import * as React from 'react' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' -import { ProcessStatus } from '../../../segment-add' +import { segmentImportStatus } from '@/types/dataset' import { useSegmentListData } from '../use-segment-list-data' // Type for mutation callbacks @@ -176,7 +177,7 @@ const defaultOptions = { searchValue: '', selectedStatus: 'all' as boolean | 'all', selectedSegmentIds: [] as string[], - importStatus: undefined as ProcessStatus | string | undefined, + importStatus: undefined as SegmentImportStatus | undefined, currentPage: 1, limit: 10, onCloseSegmentDetail: vi.fn(), @@ -689,7 +690,7 @@ describe('useSegmentListData', () => { renderHook(() => useSegmentListData({ ...defaultOptions, - importStatus: ProcessStatus.COMPLETED, + importStatus: segmentImportStatus.completed, clearSelection, }), { wrapper: createWrapper(), diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts index fa314bec25..71e21a5a80 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts @@ -13,29 +13,20 @@ type CurrChildChunkType = { } type UseModalStateReturn = { - // Segment detail modal currSegment: CurrSegmentType onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void onCloseSegmentDetail: () => void - // Child segment detail modal currChildChunk: CurrChildChunkType currChunkId: string onClickSlice: (detail: ChildChunkDetail) => void onCloseChildSegmentDetail: () => void - // New segment modal onCloseNewSegmentModal: () => void - // New child segment modal showNewChildSegmentModal: boolean handleAddNewChildChunk: (parentChunkId: string) => void onCloseNewChildChunkModal: () => void - // Regeneration modal - isRegenerationModalOpen: boolean - setIsRegenerationModalOpen: (open: boolean) => void - // Full screen fullScreen: boolean toggleFullScreen: () => void setFullScreen: (fullScreen: boolean) => void - // Collapsed state isCollapsed: boolean toggleCollapsed: () => void } @@ -47,25 +38,15 @@ type UseModalStateOptions = { export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => { const { onNewSegmentModalChange } = options - // Segment detail modal state const [currSegment, setCurrSegment] = useState({ showModal: false }) - - // Child segment detail modal state const [currChildChunk, setCurrChildChunk] = useState({ showModal: false }) const [currChunkId, setCurrChunkId] = useState('') - - // New child segment modal state const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false) - - // Regeneration modal state - const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false) - - // Display state const [fullScreen, setFullScreen] = useState(false) const [isCollapsed, setIsCollapsed] = useState(true) - // Segment detail handlers const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => { + setCurrChildChunk({ showModal: false }) setCurrSegment({ segInfo: detail, showModal: true, isEditMode }) }, []) @@ -74,8 +55,8 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur setFullScreen(false) }, []) - // Child segment detail handlers const onClickSlice = useCallback((detail: ChildChunkDetail) => { + setCurrSegment({ showModal: false }) setCurrChildChunk({ childChunkInfo: detail, showModal: true }) setCurrChunkId(detail.segment_id) }, []) @@ -85,13 +66,11 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur setFullScreen(false) }, []) - // New segment modal handlers const onCloseNewSegmentModal = useCallback(() => { onNewSegmentModalChange(false) setFullScreen(false) }, [onNewSegmentModalChange]) - // New child segment modal handlers const handleAddNewChildChunk = useCallback((parentChunkId: string) => { setShowNewChildSegmentModal(true) setCurrChunkId(parentChunkId) @@ -102,7 +81,6 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur setFullScreen(false) }, []) - // Display handlers - handles both direct calls and click events const toggleFullScreen = useCallback(() => { setFullScreen(prev => !prev) }, []) @@ -112,29 +90,20 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur }, []) return { - // Segment detail modal currSegment, onClickCard, onCloseSegmentDetail, - // Child segment detail modal currChildChunk, currChunkId, onClickSlice, onCloseChildSegmentDetail, - // New segment modal onCloseNewSegmentModal, - // New child segment modal showNewChildSegmentModal, handleAddNewChildChunk, onCloseNewChildChunkModal, - // Regeneration modal - isRegenerationModalOpen, - setIsRegenerationModalOpen, - // Full screen fullScreen, toggleFullScreen, setFullScreen, - // Collapsed state isCollapsed, toggleCollapsed, } diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts index 1c55d12d15..22bdecccb4 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts @@ -1,5 +1,6 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets' +import type { SegmentImportStatus } from '@/types/dataset' import { toast } from '@langgenius/dify-ui/toast' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -9,16 +10,16 @@ import { ChunkingMode } from '@/models/datasets' import { usePathname } from '@/next/navigation' import { useChunkListAllKey, useChunkListDisabledKey, useChunkListEnabledKey, useDeleteSegment, useDisableSegment, useEnableSegment, useSegmentList, useSegmentListKey, useUpdateSegment } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' +import { segmentImportStatus } from '@/types/dataset' import { formatNumber } from '@/utils/format' import { useDocumentContext } from '../../context' -import { ProcessStatus } from '../../segment-add' const DEFAULT_LIMIT = 10 type UseSegmentListDataOptions = { searchValue: string selectedStatus: boolean | 'all' selectedSegmentIds: string[] - importStatus: ProcessStatus | string | undefined + importStatus: SegmentImportStatus | undefined currentPage: number limit: number onCloseSegmentDetail: () => void @@ -92,7 +93,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme }, [pathname]) // Reset list on import completion useEffect(() => { - if (importStatus === ProcessStatus.COMPLETED) { + if (importStatus === segmentImportStatus.completed) { clearSelection() invalidSegmentList() } diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx index 0251919e26..d38dbb3bfe 100644 --- a/web/app/components/datasets/documents/detail/completed/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' -import type { ProcessStatus } from '../segment-add' import type { SegmentListContextValue } from './segment-list-context' +import type { SegmentImportStatus } from '@/types/dataset' import { useCallback, useMemo, useState } from 'react' import Divider from '@/app/components/base/divider' import Pagination from '@/app/components/base/pagination' @@ -13,7 +13,9 @@ import { import { useInvalid } from '@/service/use-base' import { useDocumentContext } from '../context' import BatchAction from './common/batch-action' -import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components' +import { DrawerGroup } from './components/drawer-group' +import MenuBar from './components/menu-bar' +import { FullDocModeContent, GeneralModeContent } from './components/segment-list-content' import { useChildSegmentData, useModalState, @@ -32,7 +34,7 @@ type ICompletedProps = { embeddingAvailable: boolean showNewSegmentModal: boolean onNewSegmentModalChange: (state: boolean) => void - importStatus: ProcessStatus | string | undefined + importStatus: SegmentImportStatus | undefined archived?: boolean } @@ -225,8 +227,6 @@ const Completed: FC = ({ currSegment={modalState.currSegment} onCloseSegmentDetail={modalState.onCloseSegmentDetail} onUpdateSegment={segmentListDataHook.handleUpdateSegment} - isRegenerationModalOpen={modalState.isRegenerationModalOpen} - setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen} showNewSegmentModal={showNewSegmentModal} onCloseNewSegmentModal={modalState.onCloseNewSegmentModal} onSaveNewSegment={segmentListDataHook.resetList} diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx index 91174c1bf6..fad94819b0 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx @@ -1,4 +1,3 @@ -import type { FC } from 'react' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { SegmentDetailModel } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' @@ -7,7 +6,6 @@ import { RiCollapseDiagonalLine, RiExpandDiagonalLine, } from '@remixicon/react' -import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' @@ -42,20 +40,15 @@ type ISegmentDetailProps = { onCancel: () => void isEditMode?: boolean docForm: ChunkingMode - onModalStateChange?: (isOpen: boolean) => void } -/** - * Show all the contents of the segment - */ -const SegmentDetail: FC = ({ +export function SegmentDetail({ segInfo, onUpdate, onCancel, isEditMode, docForm, - onModalStateChange, -}) => { +}: ISegmentDetailProps) { const { t } = useTranslation() const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '') const [answer, setAnswer] = useState(segInfo?.answer || '') @@ -99,19 +92,16 @@ const SegmentDetail: FC = ({ const handleRegeneration = useCallback(() => { setShowRegenerationModal(true) - onModalStateChange?.(true) - }, [onModalStateChange]) + }, []) const onCancelRegeneration = useCallback(() => { setShowRegenerationModal(false) - onModalStateChange?.(false) - }, [onModalStateChange]) + }, []) const onCloseAfterRegeneration = useCallback(() => { setShowRegenerationModal(false) - onModalStateChange?.(false) - onCancel() // Close the edit drawer - }, [onCancel, onModalStateChange]) + onCancel() + }, [onCancel]) const onConfirmRegeneration = useCallback(() => { onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true) @@ -241,5 +231,3 @@ const SegmentDetail: FC = ({
) } - -export default React.memo(SegmentDetail) diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 8a684a4e44..caae703f6b 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets' +import type { SegmentImportStatus } from '@/types/dataset' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' @@ -17,6 +18,7 @@ import { useRouter, useSearchParams } from '@/next/navigation' import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' +import { segmentImportStatus } from '@/types/dataset' import Operations from '../components/operations' import StatusItem from '../status-item' import BatchModal from './batch-modal' @@ -24,7 +26,7 @@ import Completed from './completed' import { DocumentContext } from './context' import { DocumentTitle } from './document-title' import Embedding from './embedding' -import SegmentAdd, { ProcessStatus } from './segment-add' +import { SegmentAdd } from './segment-add' import style from './style.module.css' type DocumentDetailProps = { @@ -53,20 +55,20 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { const [showMetadata, setShowMetadata] = useState(!isMobile) const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false) const [batchModalVisible, setBatchModalVisible] = useState(false) - const [importStatus, setImportStatus] = useState() + const [importStatus, setImportStatus] = useState() const showNewSegmentModal = () => setNewSegmentModalVisible(true) const showBatchModal = () => setBatchModalVisible(true) const hideBatchModal = () => setBatchModalVisible(false) - const resetProcessStatus = () => setImportStatus('') + const resetImportStatus = () => setImportStatus(undefined) const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress() const checkProcess = async (jobID: string) => { await checkSegmentBatchImportProgress({ jobID }, { onSuccess: (res) => { setImportStatus(res.job_status) - if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING) + if (res.job_status === segmentImportStatus.waiting || res.job_status === segmentImportStatus.processing) setTimeout(() => checkProcess(res.job_id), 2500) - if (res.job_status === ProcessStatus.ERROR) + if (res.job_status === segmentImportStatus.error) toast.error(`${t('list.batchModal.runError', { ns: 'datasetDocuments' })}`) }, onError: (e) => { @@ -222,7 +224,7 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { <> { }) const defaultProps = { - importStatus: undefined as ProcessStatus | string | undefined, - clearProcessStatus: vi.fn(), + importStatus: undefined as SegmentImportStatus | undefined, + clearImportStatus: vi.fn(), showNewSegmentModal: vi.fn(), showBatchModal: vi.fn(), embedding: false, @@ -52,33 +54,33 @@ describe('SegmentAdd', () => { // Import Status displays describe('Import Status Display', () => { it('should show processing indicator when status is WAITING', () => { - render() + render() expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() }) it('should show processing indicator when status is PROCESSING', () => { - render() + render() expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() }) it('should show completed status with ok button', () => { - render() + render() expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() }) it('should show error status with ok button', () => { - render() + render() expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() }) it('should not show add button when importStatus is set', () => { - render() + render() expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument() }) @@ -94,34 +96,34 @@ describe('SegmentAdd', () => { expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1) }) - it('should call clearProcessStatus when ok is clicked on completed status', () => { - const mockClearProcessStatus = vi.fn() + it('should call clearImportStatus when ok is clicked on completed status', () => { + const mockClearImportStatus = vi.fn() render( , ) fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) - expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) + expect(mockClearImportStatus).toHaveBeenCalledTimes(1) }) - it('should call clearProcessStatus when ok is clicked on error status', () => { - const mockClearProcessStatus = vi.fn() + it('should call clearImportStatus when ok is clicked on error status', () => { + const mockClearImportStatus = vi.fn() render( , ) fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) - expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) + expect(mockClearImportStatus).toHaveBeenCalledTimes(1) }) it('should render batch add option in dropdown', async () => { @@ -215,14 +217,14 @@ describe('SegmentAdd', () => { // Progress bar width tests describe('Progress Bar', () => { it('should show 3/12 width progress bar for WAITING status', () => { - const { container } = render() + const { container } = render() const progressBar = container.querySelector('.w-3\\/12') expect(progressBar).toBeInTheDocument() }) it('should show 2/3 width progress bar for PROCESSING status', () => { - const { container } = render() + const { container } = render() const progressBar = container.querySelector('.w-2\\/3') expect(progressBar).toBeInTheDocument() @@ -230,15 +232,6 @@ describe('SegmentAdd', () => { }) describe('Edge Cases', () => { - it('should handle unknown importStatus string', () => { - // Arrange & Act - pass unknown status - const { container } = render() - - // Assert - empty fragment is rendered for unknown status (container exists but has no visible content) - expect(container).toBeInTheDocument() - expect(container.textContent).toBe('') - }) - it('should maintain structure when rerendered', () => { const { rerender } = render() diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index 5ee0a2bcb3..da4a0109c0 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { FC } from 'react' +import type { SegmentImportStatus } from '@/types/dataset' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -7,95 +7,92 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' import { useProviderContext } from '@/context/provider-context' +import { segmentImportStatus } from '@/types/dataset' -type ISegmentAddProps = { - importStatus: ProcessStatus | string | undefined - clearProcessStatus: () => void +type SegmentAddProps = { + importStatus: SegmentImportStatus | undefined + clearImportStatus: () => void showNewSegmentModal: () => void showBatchModal: () => void embedding: boolean } -export enum ProcessStatus { - WAITING = 'waiting', - PROCESSING = 'processing', - COMPLETED = 'completed', - ERROR = 'error', -} - -const SegmentAdd: FC = ({ +export function SegmentAdd({ importStatus, - clearProcessStatus, + clearImportStatus, showNewSegmentModal, showBatchModal, embedding, -}) => { +}: SegmentAddProps) { const { t } = useTranslation() - const [isShowPlanUpgradeModal, { - setTrue: showPlanUpgradeModal, - setFalse: hidePlanUpgradeModal, - }] = useBoolean(false) - const { plan, enableBilling } = useProviderContext() - const { type } = plan - const canAdd = enableBilling ? type !== Plan.sandbox : true const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false) + const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false) const batchMenuAnchorRef = useRef(null) + const { plan, enableBilling } = useProviderContext() + const canAddChunks = !enableBilling || plan.type !== Plan.sandbox - const withNeedUpgradeCheck = useCallback((fn: () => void) => { - return () => { - if (!canAdd) { - showPlanUpgradeModal() - return - } - fn() + const textColor = embedding + ? 'text-components-button-secondary-accent-text-disabled' + : 'text-components-button-secondary-accent-text' + + const handleAddClick = () => { + if (!canAddChunks) { + setIsPlanUpgradeModalOpen(true) + return } - }, [canAdd, showPlanUpgradeModal]) - const textColor = useMemo(() => { - return embedding - ? 'text-components-button-secondary-accent-text-disabled' - : 'text-components-button-secondary-accent-text' - }, [embedding]) + + showNewSegmentModal() + } + + const handleBatchAddClick = () => { + setIsBatchMenuOpen(false) + + if (!canAddChunks) { + setIsPlanUpgradeModalOpen(true) + return + } + + showBatchModal() + } if (importStatus) { return ( <> - {(importStatus === ProcessStatus.WAITING || importStatus === ProcessStatus.PROCESSING) && ( + {(importStatus === segmentImportStatus.waiting || importStatus === segmentImportStatus.processing) && (
-
+
{t('list.batchModal.processing', { ns: 'datasetDocuments' })}
)} - {importStatus === ProcessStatus.COMPLETED && ( + {importStatus === segmentImportStatus.completed && (
{t('list.batchModal.completed', { ns: 'datasetDocuments' })}
- {t('list.batchModal.ok', { ns: 'datasetDocuments' })} + {t('list.batchModal.ok', { ns: 'datasetDocuments' })}
)} - {importStatus === ProcessStatus.ERROR && ( + {importStatus === segmentImportStatus.error && (
{t('list.batchModal.error', { ns: 'datasetDocuments' })}
- {t('list.batchModal.ok', { ns: 'datasetDocuments' })} + {t('list.batchModal.ok', { ns: 'datasetDocuments' })}
@@ -116,7 +113,7 @@ const SegmentAdd: FC = ({ type="button" className={`inline-flex items-center rounded-l-lg border-r border-r-divider-subtle px-2.5 py-2 hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`} - onClick={withNeedUpgradeCheck(showNewSegmentModal)} + onClick={handleAddClick} disabled={embedding} > @@ -142,25 +139,20 @@ const SegmentAdd: FC = ({ placement="bottom-start" sideOffset={4} positionerProps={{ anchor: batchMenuAnchorRef }} - popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-0 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]" + popupClassName="w-[var(--anchor-width)]" > -
- { - setIsBatchMenuOpen(false) - withNeedUpgradeCheck(showBatchModal)() - }} - > - {t('list.action.batchAdd', { ns: 'datasetDocuments' })} - -
+ + {t('list.action.batchAdd', { ns: 'datasetDocuments' })} + - {isShowPlanUpgradeModal && ( + {isPlanUpgradeModalOpen && ( setIsPlanUpgradeModalOpen(false)} title={t('upgrade.addChunks.title', { ns: 'billing' })!} description={t('upgrade.addChunks.description', { ns: 'billing' })!} /> @@ -169,4 +161,3 @@ const SegmentAdd: FC = ({ ) } -export default React.memo(SegmentAdd) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx index ad99f7ce8c..0cc374d113 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -55,10 +55,6 @@ vi.mock('../../../readme-panel/entrance', () => ({ ReadmeEntrance: () =>
, })) -vi.mock('../../../readme-panel/store', () => ({ - ReadmeShowType: { modal: 'modal' }, -})) - vi.mock('@/app/components/base/encrypted-bottom', () => ({ EncryptedBottom: () =>
, })) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx index 58bbd441ce..7509090be3 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx @@ -41,10 +41,6 @@ vi.mock('../../../readme-panel/entrance', () => ({ ReadmeEntrance: () =>
, })) -vi.mock('../../../readme-panel/store', () => ({ - ReadmeShowType: { modal: 'modal' }, -})) - vi.mock('@/app/components/base/form/form-scenarios/auth', () => { const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref } & Record) => { mockAuthFormProps = props diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx index e01886ccde..3a7e495a77 100644 --- a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -19,7 +19,6 @@ import AuthForm from '@/app/components/base/form/form-scenarios/auth' import { FormTypeEnum } from '@/app/components/base/form/types' import Loading from '@/app/components/base/loading' import { ReadmeEntrance } from '../../readme-panel/entrance' -import { ReadmeShowType } from '../../readme-panel/store' import { useAddPluginCredentialHook, useGetPluginCredentialSchemaHook, @@ -159,7 +158,7 @@ const ApiKeyModal = ({
{pluginPayload.detail && ( - + )} { isLoading && ( diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx index 50718d50db..a3bd35a865 100644 --- a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -19,7 +19,6 @@ import { import { useTranslation } from 'react-i18next' import AuthForm from '@/app/components/base/form/form-scenarios/auth' import { ReadmeEntrance } from '../../readme-panel/entrance' -import { ReadmeShowType } from '../../readme-panel/store' import { useDeletePluginOAuthCustomClientHook, useInvalidPluginOAuthClientSchemaHook, @@ -157,7 +156,7 @@ const OAuthClientSettings = ({
{pluginPayload.detail && ( - + )}
{pluginDetail && ( - + )} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx index 774eaa9fe9..a7b5c9f2c0 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx @@ -12,7 +12,6 @@ import { BaseForm } from '@/app/components/base/form/components/base' import { FormTypeEnum } from '@/app/components/base/form/types' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { useUpdateTriggerSubscription } from '@/service/use-triggers' -import { ReadmeShowType } from '../../../readme-panel/store' import { usePluginStore } from '../../store' import { useSubscriptionList } from '../use-subscription-list' @@ -159,7 +158,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props)
{pluginDetail && ( - + )}
{pluginDetail && ( - + )} ({ - cn: (...args: unknown[]) => args.filter(Boolean).join(' '), -})) - -const mockSetCurrentPluginDetail = vi.fn() - -vi.mock('../store', () => ({ - ReadmeShowType: { drawer: 'drawer', side: 'side', modal: 'modal' }, - useReadmePanelStore: () => ({ - setCurrentPluginDetail: mockSetCurrentPluginDetail, - }), -})) - -vi.mock('../constants', () => ({ - BUILTIN_TOOLS_ARRAY: ['google_search', 'bing_search'], -})) +import { beforeEach, describe, expect, it } from 'vitest' +import { ReadmeEntrance } from '../entrance' +import { useReadmePanelStore } from '../store' describe('ReadmeEntrance', () => { - let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance'] - - beforeEach(async () => { - vi.clearAllMocks() - const mod = await import('../entrance') - ReadmeEntrance = mod.ReadmeEntrance + beforeEach(() => { + useReadmePanelStore.setState({ currentPanel: undefined }) }) it('should render readme button for non-builtin plugin with unique identifier', () => { @@ -35,18 +15,31 @@ describe('ReadmeEntrance', () => { expect(screen.getByRole('button')).toBeInTheDocument() }) - it('should call setCurrentPluginDetail on button click', () => { + it('should open drawer presentation by default', () => { const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never render() const button = screen.getByRole('button') fireEvent.click(button) - expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer') + expect(useReadmePanelStore.getState().currentPanel).toEqual({ + detail: pluginDetail, + presentation: 'drawer', + triggerId: button.id, + }) + }) + + it('should open dialog presentation when requested', () => { + const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never + render() + + fireEvent.click(screen.getByRole('button')) + + expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog') }) it('should return null for builtin tools', () => { - const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never + const pluginDetail = { id: 'code', name: 'Code', plugin_unique_identifier: 'org/code' } as never const { container } = render() expect(container.innerHTML).toBe('') diff --git a/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx index d52a22cb61..433ac011c5 100644 --- a/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx +++ b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx @@ -1,29 +1,29 @@ +import type { ReactElement } from 'react' import type { PluginDetail } from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum, PluginSource } from '../../types' import { ReadmeEntrance } from '../entrance' import ReadmePanel from '../index' -import { ReadmeShowType, useReadmePanelStore } from '../store' +import { useReadmePanelStore } from '../store' -// ================================ -// Mock external dependencies only -// ================================ +( + globalThis as typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean + } +).BASE_UI_ANIMATIONS_DISABLED = true -// Mock usePluginReadme hook const mockUsePluginReadme = vi.fn() vi.mock('@/service/use-plugins', () => ({ usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params), })) -// Mock useLanguage hook let mockLanguage = 'en-US' vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => mockLanguage, })) -// Mock DetailHeader component (complex component with many dependencies) vi.mock('../../plugin-detail-panel/detail-header', () => ({ default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => (
@@ -32,10 +32,6 @@ vi.mock('../../plugin-detail-panel/detail-header', () => ({ ), })) -// ================================ -// Test Data Factories -// ================================ - const createMockPluginDetail = (overrides: Partial = {}): PluginDetail => ({ id: 'test-plugin-id', created_at: '2024-01-01T00:00:00Z', @@ -93,10 +89,6 @@ const createMockPluginDetail = (overrides: Partial = {}): PluginDe ...overrides, }) -// ================================ -// Test Utilities -// ================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { @@ -105,7 +97,7 @@ const createQueryClient = () => new QueryClient({ }, }) -const renderWithQueryClient = (ui: React.ReactElement) => { +const renderWithQueryClient = (ui: ReactElement) => { const queryClient = createQueryClient() return render( @@ -114,15 +106,23 @@ const renderWithQueryClient = (ui: React.ReactElement) => { ) } -// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts -// Store (useReadmePanelStore) tests moved to store.spec.ts -// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx +const openReadmePanel = ( + detail = createMockPluginDetail(), + presentation: 'drawer' | 'dialog' = 'drawer', +) => { + useReadmePanelStore.getState().openReadmePanel({ + detail, + presentation, + triggerId: 'readme-trigger', + }) + return detail +} -// ================================ -// ReadmePanel Component Tests -// ================================ describe('ReadmePanel', () => { beforeEach(() => { + vi.clearAllMocks() + mockLanguage = 'en-US' + useReadmePanelStore.setState({ currentPanel: undefined }) mockUsePluginReadme.mockReturnValue({ data: null, isLoading: false, @@ -130,487 +130,114 @@ describe('ReadmePanel', () => { }) }) - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should return null when no plugin detail is set', () => { - const { container } = renderWithQueryClient() + it('should return null when no readme panel is open', () => { + const { container } = renderWithQueryClient() - expect(container.firstChild).toBeNull() + expect(container.firstChild).toBeNull() + }) + + it('should render drawer presentation with plugin header content', () => { + openReadmePanel() + + renderWithQueryClient() + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument() + expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true') + expect(screen.getByRole('dialog')).toHaveClass('data-[swipe-direction=left]:w-150') + }) + + it('should render dialog presentation when requested', () => { + openReadmePanel(createMockPluginDetail(), 'dialog') + + renderWithQueryClient() + + expect(screen.getByRole('dialog')).toHaveClass('max-w-200') + }) + + it('should close the active panel when close button is clicked', () => { + openReadmePanel() + + renderWithQueryClient() + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) + + expect(useReadmePanelStore.getState().currentPanel).toBeUndefined() + }) + + it('should render loading, error, empty, and readme states from the readme query', () => { + openReadmePanel() + mockUsePluginReadme.mockReturnValue({ + data: null, + isLoading: true, + error: null, }) + const { rerender } = renderWithQueryClient() + expect(screen.getByRole('status')).toBeInTheDocument() - it('should render portal content when plugin detail is set', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument() + mockUsePluginReadme.mockReturnValue({ + data: null, + isLoading: false, + error: new Error('Failed to fetch'), }) + rerender() + expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument() - it('should render DetailHeader component', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByTestId('detail-header')).toBeInTheDocument() - expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true') + mockUsePluginReadme.mockReturnValue({ + data: { readme: '' }, + isLoading: false, + error: null, }) + rerender() + expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument() - it('should render close button', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) + mockUsePluginReadme.mockReturnValue({ + data: { readme: '# Test Readme Content' }, + isLoading: false, + error: null, + }) + rerender() + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) - renderWithQueryClient() + it('should call usePluginReadme with the plugin identifier and selected language', () => { + openReadmePanel(createMockPluginDetail({ + plugin_unique_identifier: 'custom-plugin@2.0.0', + })) - // ActionButton wraps the close icon - expect(screen.getByRole('button')).toBeInTheDocument() + renderWithQueryClient() + + expect(mockUsePluginReadme).toHaveBeenCalledWith({ + plugin_unique_identifier: 'custom-plugin@2.0.0', + language: 'en-US', }) }) - // ================================ - // Loading State Tests - // ================================ - describe('Loading State', () => { - it('should show loading indicator when isLoading is true', () => { - mockUsePluginReadme.mockReturnValue({ - data: null, - isLoading: true, - error: null, - }) + it('should pass undefined language for zh-Hans locale', () => { + mockLanguage = 'zh-Hans' + openReadmePanel(createMockPluginDetail({ + plugin_unique_identifier: 'zh-plugin@1.0.0', + })) - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) + renderWithQueryClient() - renderWithQueryClient() - - // Loading component should be rendered with role="status" - expect(screen.getByRole('status')).toBeInTheDocument() + expect(mockUsePluginReadme).toHaveBeenCalledWith({ + plugin_unique_identifier: 'zh-plugin@1.0.0', + language: undefined, }) }) - // ================================ - // Error State Tests - // ================================ - describe('Error State', () => { - it('should show error message when error occurs', () => { - mockUsePluginReadme.mockReturnValue({ - data: null, - isLoading: false, - error: new Error('Failed to fetch'), - }) + it('should open correctly from ReadmeEntrance through the global host', () => { + const detail = createMockPluginDetail() - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) + renderWithQueryClient( + <> + + + , + ) - renderWithQueryClient() + fireEvent.click(screen.getByRole('button', { name: /plugin\.readmeInfo\.needHelpCheckReadme/ })) - expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument() - }) - }) - - // ================================ - // No Readme Available State Tests - // ================================ - describe('No Readme Available', () => { - it('should show no readme message when readme is empty', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument() - }) - - it('should show no readme message when data is null', () => { - mockUsePluginReadme.mockReturnValue({ - data: null, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument() - }) - }) - - // ================================ - // Markdown Content Tests - // ================================ - describe('Markdown Content', () => { - it('should render markdown container when readme is available', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Test Readme Content' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Markdown component container should be rendered - // Note: The Markdown component uses dynamic import, so content may load asynchronously - const markdownContainer = document.querySelector('.markdown-body') - expect(markdownContainer).toBeInTheDocument() - }) - - it('should not show error or no-readme message when readme is available', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Test Readme Content' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Should not show error or no-readme message - expect(screen.queryByText('plugin.readmeInfo.failedToFetch')).not.toBeInTheDocument() - expect(screen.queryByText('plugin.readmeInfo.noReadmeAvailable')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Portal Rendering Tests (Drawer Mode) - // ================================ - describe('Portal Rendering - Drawer Mode', () => { - it('should render drawer styled container in drawer mode', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Test' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Drawer mode has specific max-width - const drawerContainer = document.querySelector('.max-w-\\[600px\\]') - expect(drawerContainer).toBeInTheDocument() - }) - - it('should have correct drawer positioning classes', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Check for drawer-specific classes - const backdrop = document.querySelector('.justify-start') - expect(backdrop).toBeInTheDocument() - }) - }) - - // ================================ - // Portal Rendering Tests (Modal Mode) - // ================================ - describe('Portal Rendering - Modal Mode', () => { - it('should render modal styled container in modal mode', () => { - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Test' }, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - - renderWithQueryClient() - - // Modal mode has different max-width - const modalContainer = document.querySelector('.max-w-\\[800px\\]') - expect(modalContainer).toBeInTheDocument() - }) - - it('should have correct modal positioning classes', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - - renderWithQueryClient() - - // Check for modal-specific classes - const backdrop = document.querySelector('.items-center.justify-center') - expect(backdrop).toBeInTheDocument() - }) - }) - - // ================================ - // User Interactions / Event Handlers - // ================================ - describe('User Interactions', () => { - it('should close panel when close button is clicked', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - fireEvent.click(screen.getByRole('button')) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - - it('should close panel when backdrop is clicked', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Click on the backdrop (outer div) - const backdrop = document.querySelector('.fixed.inset-0') - fireEvent.click(backdrop!) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - - it('should not close panel when content area is clicked', async () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // Click on the content container (should stop propagation) - const contentContainer = document.querySelector('.pointer-events-auto') - fireEvent.click(contentContainer!) - - await waitFor(() => { - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeDefined() - }) - }) - - it('should not close panel when content area is clicked in modal mode', async () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - - renderWithQueryClient() - - // Click on the content container in modal mode (should stop propagation) - const contentContainer = document.querySelector('.pointer-events-auto') - fireEvent.click(contentContainer!) - - await waitFor(() => { - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeDefined() - }) - }) - }) - - // ================================ - // API Call Tests - // ================================ - describe('API Calls', () => { - it('should call usePluginReadme with correct parameters', () => { - const mockDetail = createMockPluginDetail({ - plugin_unique_identifier: 'custom-plugin@2.0.0', - }) - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(mockUsePluginReadme).toHaveBeenCalledWith({ - plugin_unique_identifier: 'custom-plugin@2.0.0', - language: 'en-US', - }) - }) - - it('should pass undefined language for zh-Hans locale', () => { - // Set language to zh-Hans - mockLanguage = 'zh-Hans' - - const mockDetail = createMockPluginDetail({ - plugin_unique_identifier: 'zh-plugin@1.0.0', - }) - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - // The component should pass undefined for language when zh-Hans - expect(mockUsePluginReadme).toHaveBeenCalledWith({ - plugin_unique_identifier: 'zh-plugin@1.0.0', - language: undefined, - }) - - // Reset language - mockLanguage = 'en-US' - }) - - it('should handle empty plugin_unique_identifier', () => { - mockUsePluginReadme.mockReturnValue({ - data: null, - isLoading: false, - error: null, - }) - - const mockDetail = createMockPluginDetail({ - plugin_unique_identifier: '', - }) - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(mockUsePluginReadme).toHaveBeenCalledWith({ - plugin_unique_identifier: '', - language: 'en-US', - }) - }) - }) - - // ================================ - // Edge Cases - // ================================ - describe('Edge Cases', () => { - it('should handle detail with missing declaration', () => { - const mockDetail = createMockPluginDetail() - // Simulate missing fields - delete (mockDetail as Partial).declaration - - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // This should not throw - expect(() => setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)).not.toThrow() - }) - - it('should handle rapid open/close operations', async () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // Rapidly toggle the panel - act(() => { - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - setCurrentPluginDetail() - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - }) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail?.showType).toBe(ReadmeShowType.modal) - }) - - it('should handle switching between drawer and modal modes', () => { - const mockDetail = createMockPluginDetail() - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // Start with drawer - act(() => { - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - }) - - let state = useReadmePanelStore.getState() - expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.drawer) - - // Switch to modal - act(() => { - setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) - }) - - state = useReadmePanelStore.getState() - expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal) - }) - - it('should handle undefined detail gracefully', () => { - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - - // Set to undefined explicitly - act(() => { - setCurrentPluginDetail(undefined, ReadmeShowType.drawer) - }) - - const { currentPluginDetail } = useReadmePanelStore.getState() - expect(currentPluginDetail).toBeUndefined() - }) - }) - - // ================================ - // Integration Tests - // ================================ - describe('Integration', () => { - it('should work correctly when opened from ReadmeEntrance', () => { - const mockDetail = createMockPluginDetail() - - mockUsePluginReadme.mockReturnValue({ - data: { readme: '# Integration Test' }, - isLoading: false, - error: null, - }) - - // Render both components - const { rerender } = renderWithQueryClient( - <> - - - , - ) - - // Initially panel should not show content - expect(screen.queryByTestId('detail-header')).not.toBeInTheDocument() - - // Click the entrance button - fireEvent.click(screen.getByRole('button')) - - // Re-render to pick up store changes - rerender( - - - - , - ) - - // Panel should now show content - expect(screen.getByTestId('detail-header')).toBeInTheDocument() - // Markdown content renders in a container (dynamic import may not render content synchronously) - expect(document.querySelector('.markdown-body')).toBeInTheDocument() - }) - - it('should display correct plugin information in header', () => { - const mockDetail = createMockPluginDetail({ - name: 'my-awesome-plugin', - }) - - const { setCurrentPluginDetail } = useReadmePanelStore.getState() - setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) - - renderWithQueryClient() - - expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument() - }) + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/readme-panel/__tests__/store.spec.ts b/web/app/components/plugins/readme-panel/__tests__/store.spec.ts index a349659f42..f8f1ae035e 100644 --- a/web/app/components/plugins/readme-panel/__tests__/store.spec.ts +++ b/web/app/components/plugins/readme-panel/__tests__/store.spec.ts @@ -1,54 +1,52 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { beforeEach, describe, expect, it } from 'vitest' -import { ReadmeShowType, useReadmePanelStore } from '../store' +import { useReadmePanelStore } from '../store' describe('readme-panel/store', () => { beforeEach(() => { - useReadmePanelStore.setState({ currentPluginDetail: undefined }) + useReadmePanelStore.setState({ currentPanel: undefined }) }) - it('initializes with undefined currentPluginDetail', () => { + it('initializes without an active panel', () => { const state = useReadmePanelStore.getState() - expect(state.currentPluginDetail).toBeUndefined() + expect(state.currentPanel).toBeUndefined() }) - it('sets current plugin detail with drawer showType by default', () => { - const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail - useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail) + it('opens drawer presentation by default', () => { + const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().openReadmePanel({ detail, triggerId: 'readme-trigger' }) - const state = useReadmePanelStore.getState() - expect(state.currentPluginDetail).toEqual({ - detail: mockDetail, - showType: ReadmeShowType.drawer, + expect(useReadmePanelStore.getState().currentPanel).toEqual({ + detail, + presentation: 'drawer', + triggerId: 'readme-trigger', }) }) - it('sets current plugin detail with modal showType', () => { - const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail - useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) + it('opens dialog presentation when requested', () => { + const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().openReadmePanel({ detail, presentation: 'dialog' }) - const state = useReadmePanelStore.getState() - expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal) + expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog') }) - it('clears current plugin detail when called with undefined', () => { - const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail - useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail) - expect(useReadmePanelStore.getState().currentPluginDetail).toBeDefined() + it('closes the active panel', () => { + const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail + useReadmePanelStore.getState().openReadmePanel({ detail }) + expect(useReadmePanelStore.getState().currentPanel).toBeDefined() - useReadmePanelStore.getState().setCurrentPluginDetail(undefined) - expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined() + useReadmePanelStore.getState().closeReadmePanel() + expect(useReadmePanelStore.getState().currentPanel).toBeUndefined() }) - it('replaces previous detail with new one', () => { + it('replaces the active panel with the latest request', () => { const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail - useReadmePanelStore.getState().setCurrentPluginDetail(detail1) - expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1') + useReadmePanelStore.getState().openReadmePanel({ detail: detail1 }) + useReadmePanelStore.getState().openReadmePanel({ detail: detail2, presentation: 'dialog' }) - useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal) - expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2') - expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal) + expect(useReadmePanelStore.getState().currentPanel?.detail.id).toBe('plugin-2') + expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog') }) }) diff --git a/web/app/components/plugins/readme-panel/content.tsx b/web/app/components/plugins/readme-panel/content.tsx new file mode 100644 index 0000000000..1fde49b701 --- /dev/null +++ b/web/app/components/plugins/readme-panel/content.tsx @@ -0,0 +1,81 @@ +'use client' + +import type { ReactNode } from 'react' +import type { PluginDetail } from '../types' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { Markdown } from '@/app/components/base/markdown' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { usePluginReadme } from '@/service/use-plugins' +import DetailHeader from '../plugin-detail-panel/detail-header' + +type ReadmePanelContentProps = { + detail: PluginDetail + title: ReactNode + closeButton: ReactNode +} + +export function ReadmePanelContent({ + detail, + title, + closeButton, +}: ReadmePanelContentProps) { + const { t } = useTranslation() + const language = useLanguage() + const pluginUniqueIdentifier = detail.plugin_unique_identifier || '' + + const { data: readmeData, isLoading, error } = usePluginReadme({ + plugin_unique_identifier: pluginUniqueIdentifier, + language: language === 'zh-Hans' ? undefined : language, + }) + + let readmeContent: ReactNode + if (isLoading) { + readmeContent = ( +
+ +
+ ) + } + else if (error) { + readmeContent = ( +
+

{t('readmeInfo.failedToFetch', { ns: 'plugin' })}

+
+ ) + } + else if (readmeData?.readme) { + readmeContent = ( + + ) + } + else { + readmeContent = ( +
+

{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}

+
+ ) + } + + return ( +
+
+
+
+
+ {closeButton} +
+ +
+ +
+ {readmeContent} +
+
+ ) +} diff --git a/web/app/components/plugins/readme-panel/dialog.tsx b/web/app/components/plugins/readme-panel/dialog.tsx new file mode 100644 index 0000000000..36695196b3 --- /dev/null +++ b/web/app/components/plugins/readme-panel/dialog.tsx @@ -0,0 +1,52 @@ +'use client' + +import type { PluginDetail } from '../types' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { useTranslation } from 'react-i18next' +import { ReadmePanelContent } from './content' + +type ReadmeDialogProps = { + detail: PluginDetail + open: boolean + onOpenChange: (open: boolean) => void + triggerId?: string +} + +export function ReadmeDialog({ + detail, + open, + onOpenChange, + triggerId, +}: ReadmeDialogProps) { + const { t } = useTranslation() + + return ( + + + + {t('readmeInfo.title', { ns: 'plugin' })} + + )} + closeButton={( + + )} + /> + + + ) +} diff --git a/web/app/components/plugins/readme-panel/drawer.tsx b/web/app/components/plugins/readme-panel/drawer.tsx new file mode 100644 index 0000000000..97ce5185fd --- /dev/null +++ b/web/app/components/plugins/readme-panel/drawer.tsx @@ -0,0 +1,62 @@ +'use client' + +import type { PluginDetail } from '../types' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' +import { useTranslation } from 'react-i18next' +import { ReadmePanelContent } from './content' + +type ReadmeDrawerProps = { + detail: PluginDetail + open: boolean + onOpenChange: (open: boolean) => void + triggerId?: string +} + +export function ReadmeDrawer({ + detail, + open, + onOpenChange, + triggerId, +}: ReadmeDrawerProps) { + const { t } = useTranslation() + + return ( + + + + + + + + {t('readmeInfo.title', { ns: 'plugin' })} + + )} + closeButton={( + + )} + /> + + + + + + ) +} diff --git a/web/app/components/plugins/readme-panel/entrance.tsx b/web/app/components/plugins/readme-panel/entrance.tsx index 2d8188874d..067420f5f7 100644 --- a/web/app/components/plugins/readme-panel/entrance.tsx +++ b/web/app/components/plugins/readme-panel/entrance.tsx @@ -1,34 +1,40 @@ import type { PluginDetail } from '../types' +import type { ReadmePanelPresentation } from './store' import { cn } from '@langgenius/dify-ui/cn' -import { RiBookReadLine } from '@remixicon/react' -import * as React from 'react' +import { useId } from 'react' import { useTranslation } from 'react-i18next' import { BUILTIN_TOOLS_ARRAY } from './constants' -import { ReadmeShowType, useReadmePanelStore } from './store' +import { useReadmePanelStore } from './store' export const ReadmeEntrance = ({ pluginDetail, - showType = ReadmeShowType.drawer, + presentation = 'drawer', className, showShortTip = false, }: { pluginDetail: PluginDetail - showType?: ReadmeShowType + presentation?: ReadmePanelPresentation className?: string showShortTip?: boolean }) => { const { t } = useTranslation() - const { setCurrentPluginDetail } = useReadmePanelStore() + const triggerId = useId() + const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel) const handleReadmeClick = () => { - if (pluginDetail) - setCurrentPluginDetail(pluginDetail, showType) + if (pluginDetail) { + openReadmePanel({ + detail: pluginDetail, + presentation, + triggerId, + }) + } } if (!pluginDetail || !pluginDetail?.plugin_unique_identifier || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id)) return null return ( -
+
{!showShortTip && (
@@ -36,11 +42,13 @@ export const ReadmeEntrance = ({ )} ) } @@ -119,6 +135,12 @@ describe('LabelSelector', () => { expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument() }) + it('should render the trigger as a native button', () => { + render() + + expect(screen.getByRole('button', { name: 'tools.createTool.toolInput.labelPlaceholder' })).toHaveAttribute('type', 'button') + }) + it('should display selected labels as comma-separated list', () => { render() diff --git a/web/app/components/tools/labels/selector.tsx b/web/app/components/tools/labels/selector.tsx index b4dff0c0f2..40b890667b 100644 --- a/web/app/components/tools/labels/selector.tsx +++ b/web/app/components/tools/labels/selector.tsx @@ -6,7 +6,6 @@ import { PopoverContent, PopoverTrigger, } from '@langgenius/dify-ui/popover' -import { RiArrowDownSLine } from '@remixicon/react' import { useDebounceFn } from 'ahooks' import { noop } from 'es-toolkit/function' import { useMemo, useState } from 'react' @@ -60,22 +59,19 @@ const LabelSelector: FC = ({
-
0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}> - {!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })} - {!!value.length && selectedLabels} -
-
- -
-
+ className={cn( + 'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 text-left hover:bg-components-input-bg-hover', + open && 'bg-components-input-bg-hover hover:bg-components-input-bg-hover', )} - /> + > +
0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-4.5 text-text-secondary', !value.length && 'text-text-quaternary!')}> + {!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })} + {!!value.length && selectedLabels} +
+
+ +
+ ({ })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => ( -
+ WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => ( +
@@ -581,7 +581,7 @@ describe('ProviderDetail', () => { }) }) - it('saves workflow tool via workflow modal', async () => { + it('saves workflow tool via workflow drawer', async () => { render( { expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument() }) fireEvent.click(screen.getByText('tools.createTool.editAction')) - expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument() + expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument() await act(async () => { fireEvent.click(screen.getByTestId('wf-save')) }) @@ -627,7 +627,7 @@ describe('ProviderDetail', () => { }) }) - describe('Modal Close Actions', () => { + describe('Overlay Close Actions', () => { it('closes ConfigCredential when cancel is clicked', async () => { render( { expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument() }) - it('closes WorkflowToolModal via onHide', async () => { + it('closes WorkflowToolDrawer via onHide', async () => { render( { expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument() }) fireEvent.click(screen.getByText('tools.createTool.editAction')) - expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument() + expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument() fireEvent.click(screen.getByTestId('wf-close')) - expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument() + expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index eee3f423bb..9080ee2c7d 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -1,6 +1,6 @@ 'use client' import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types' -import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool' +import type { WorkflowToolDrawerPayload } from '@/app/components/tools/workflow-tool' import { AlertDialog, AlertDialogActions, @@ -31,7 +31,7 @@ import OrgInfo from '@/app/components/plugins/card/base/org-info' import Title from '@/app/components/plugins/card/base/title' import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal' import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' -import WorkflowToolModal from '@/app/components/tools/workflow-tool' +import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' import { useAppContext } from '@/context/app-context' import { useLocale } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' @@ -140,7 +140,7 @@ const ProviderDetail = ({ setIsShowEditCustomCollectionModal(false) } // workflow provider - const [isShowEditWorkflowToolModal, setIsShowEditWorkflowToolModal] = useState(false) + const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) const getWorkflowToolProvider = useCallback(async () => { setIsDetailLoading(true) const res = await fetchWorkflowToolDetail(collection.id) @@ -164,7 +164,7 @@ const ProviderDetail = ({ await deleteWorkflowTool(collection.id) onRefreshData() toast.success(t('api.actionSuccess', { ns: 'common' })) - setIsShowEditWorkflowToolModal(false) + setWorkflowToolDrawerOpen(false) } const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{ workflow_app_id: string @@ -175,7 +175,7 @@ const ProviderDetail = ({ onRefreshData() getWorkflowToolProvider() toast.success(t('api.actionSuccess', { ns: 'common' })) - setIsShowEditWorkflowToolModal(false) + setWorkflowToolDrawerOpen(false) } const onClickCustomToolDelete = () => { setDeleteAction('customTool') @@ -287,7 +287,7 @@ const ProviderDetail = ({ - {body} -
- ) - }, -})) - // Mock EmojiPickerInner - simplified for testing vi.mock('@/app/components/base/emoji-picker/Inner', () => ({ default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => ( @@ -120,22 +103,6 @@ const createMockEmoji = (overrides = {}) => ({ ...overrides, }) -const createMockInputVar = (overrides: Partial = {}): InputVar => ({ - variable: 'test_var', - label: 'Test Variable', - type: InputVarType.textInput, - required: true, - max_length: 100, - options: [], - ...overrides, -} as InputVar) - -const createMockVariable = (overrides: Partial = {}): Variable => ({ - variable: 'output_var', - value_type: 'string', - ...overrides, -} as Variable) - const createMockWorkflowToolDetail = (overrides: Partial = {}): WorkflowToolProviderResponse => ({ workflow_app_id: 'workflow-app-123', workflow_tool_id: 'workflow-tool-456', @@ -179,19 +146,14 @@ const createMockWorkflowToolDetail = (overrides: Partial ({ disabled: false, published: false, - detailNeedUpdate: false, - workflowAppId: 'workflow-app-123', - icon: createMockEmoji(), - name: 'Test Workflow', - description: 'Test workflow description', - inputs: [createMockInputVar()], - outputs: [createMockVariable()], - handlePublish: vi.fn().mockResolvedValue(undefined), - onRefreshData: vi.fn(), + isLoading: false, + outdated: false, + isCurrentWorkspaceManager: true, + onConfigure: vi.fn(), ...overrides, }) -const createDefaultModalPayload = (overrides: Partial = {}): WorkflowToolModalPayload => ({ +const createDefaultDrawerPayload = (overrides: Partial = {}): WorkflowToolDrawerPayload => ({ icon: createMockEmoji(), label: 'Test Tool', name: 'test_tool', @@ -297,8 +259,7 @@ describe('WorkflowToolConfigureButton', () => { it('should render loading state when published and fetching details', () => { // Arrange - mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true }) - const props = createDefaultConfigureButtonProps({ published: true }) + const props = createDefaultConfigureButtonProps({ published: true, isLoading: true }) // Act render() @@ -324,8 +285,7 @@ describe('WorkflowToolConfigureButton', () => { it('should render different UI for non-workspace manager', () => { // Arrange - mockIsCurrentWorkspaceManager.mockReturnValue(false) - const props = createDefaultConfigureButtonProps() + const props = createDefaultConfigureButtonProps({ isCurrentWorkspaceManager: false }) // Act render() @@ -346,53 +306,46 @@ describe('WorkflowToolConfigureButton', () => { expect(() => render()).not.toThrow() }) - it('should handle undefined inputs and outputs', () => { + it('should render without disabled reason', () => { // Arrange - const props = createDefaultConfigureButtonProps({ - inputs: undefined, - outputs: undefined, - }) + const props = createDefaultConfigureButtonProps({ disabledReason: undefined }) // Act & Assert expect(() => render()).not.toThrow() }) - it('should handle empty inputs and outputs arrays', () => { + it('should handle configured callback props', () => { // Arrange - const props = createDefaultConfigureButtonProps({ - inputs: [], - outputs: [], - }) + const props = createDefaultConfigureButtonProps({ onConfigure: vi.fn() }) // Act & Assert expect(() => render()).not.toThrow() }) }) - // Modal behavior tests - describe('Modal Behavior', () => { - it('should toggle modal visibility', async () => { + // Drawer behavior tests + describe('Drawer Behavior', () => { + it('should request configuration from the unpublished entry point', async () => { // Arrange const user = userEvent.setup() - const props = createDefaultConfigureButtonProps() + const onConfigure = vi.fn() + const props = createDefaultConfigureButtonProps({ onConfigure }) // Act render() - // Click to open modal + // Click to request opening the drawer const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') await user.click(triggerArea!) - // Assert - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + expect(onConfigure).toHaveBeenCalledTimes(1) }) - it('should not open modal when disabled', async () => { + it('should not request configuration when disabled', async () => { // Arrange const user = userEvent.setup() - const props = createDefaultConfigureButtonProps({ disabled: true }) + const onConfigure = vi.fn() + const props = createDefaultConfigureButtonProps({ disabled: true, onConfigure }) // Act render() @@ -400,45 +353,14 @@ describe('WorkflowToolConfigureButton', () => { const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') await user.click(triggerArea!) - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() + expect(onConfigure).not.toHaveBeenCalled() }) - it('should not open modal when published (use configure button instead)', async () => { + it('should request configuration from the published configure button only', async () => { // Arrange const user = userEvent.setup() - const props = createDefaultConfigureButtonProps({ published: true }) + const onConfigure = vi.fn() + const props = createDefaultConfigureButtonProps({ published: true, onConfigure }) // Act render() @@ -447,51 +369,16 @@ describe('WorkflowToolConfigureButton', () => { expect(screen.getByText('workflow.common.configure'))!.toBeInTheDocument() }) - // Click the main area (should not open modal) + // Click the main area (should not request opening the drawer) const mainArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') await user.click(mainArea!) - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - // Should not open modal from main click - expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() + expect(onConfigure).not.toHaveBeenCalled() // Click configure button await user.click(screen.getByText('workflow.common.configure')) - // Assert - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + expect(onConfigure).toHaveBeenCalledTimes(1) }) }) @@ -541,12 +428,11 @@ describe('WorkflowToolConfigureButton', () => { expect(screen.getByText('workflow.common.workflowAsTool'))!.toBeInTheDocument() }) - it('should handle paragraph type input conversion', async () => { + it('should keep the configure entry independent from workflow parameter shape', async () => { // Arrange const user = userEvent.setup() - const props = createDefaultConfigureButtonProps({ - inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })], - }) + const onConfigure = vi.fn() + const props = createDefaultConfigureButtonProps({ onConfigure }) // Act render() @@ -554,10 +440,7 @@ describe('WorkflowToolConfigureButton', () => { const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') await user.click(triggerArea!) - // Assert - should render without error - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + expect(onConfigure).toHaveBeenCalledTimes(1) }) }) @@ -579,8 +462,7 @@ describe('WorkflowToolConfigureButton', () => { it('should disable configure button when not workspace manager', async () => { // Arrange - mockIsCurrentWorkspaceManager.mockReturnValue(false) - const props = createDefaultConfigureButtonProps({ published: true }) + const props = createDefaultConfigureButtonProps({ published: true, isCurrentWorkspaceManager: false }) // Act render() @@ -595,9 +477,9 @@ describe('WorkflowToolConfigureButton', () => { }) // ============================================================================ -// WorkflowToolAsModal Tests +// WorkflowToolDrawer Tests // ============================================================================ -describe('WorkflowToolAsModal', () => { +describe('WorkflowToolDrawer', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -608,12 +490,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -624,12 +506,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -640,12 +522,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -656,12 +538,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -672,12 +554,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -688,12 +570,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -704,12 +586,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -722,12 +604,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -738,12 +620,12 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -754,13 +636,13 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onRemove: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -771,13 +653,13 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), onRemove: vi.fn(), } // Act - render() + render() // Assert // Assert @@ -819,7 +701,7 @@ describe('WorkflowToolAsModal', () => { describe('Props', () => { it('should initialize state from payload', () => { // Arrange - const payload = createDefaultModalPayload({ + const payload = createDefaultDrawerPayload({ label: 'Custom Label', name: 'custom_name', description: 'Custom description', @@ -831,7 +713,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() // Assert // Assert @@ -842,7 +724,7 @@ describe('WorkflowToolAsModal', () => { it('should pass labels to label selector', () => { // Arrange - const payload = createDefaultModalPayload({ labels: ['tag1', 'tag2'] }) + const payload = createDefaultDrawerPayload({ labels: ['tag1', 'tag2'] }) const props = { isAdd: true, payload, @@ -850,7 +732,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() // Assert // Assert @@ -865,12 +747,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ label: '' }), + payload: createDefaultDrawerPayload({ label: '' }), onHide: vi.fn(), } // Act - render() + render() const labelInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder') await user.type(labelInput, 'New Label') @@ -884,12 +766,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ name: '' }), + payload: createDefaultDrawerPayload({ name: '' }), onHide: vi.fn(), } // Act - render() + render() const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') await user.type(nameInput, 'new_name') @@ -903,12 +785,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ description: '' }), + payload: createDefaultDrawerPayload({ description: '' }), onHide: vi.fn(), } // Act - render() + render() const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') await user.type(descInput, 'New description') @@ -922,12 +804,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() const iconButton = screen.getByTestId('app-icon') await user.click(iconButton) @@ -941,12 +823,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() // Open emoji picker const iconButton = screen.getByTestId('app-icon') @@ -967,12 +849,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), } // Act - render() + render() const iconButton = screen.getByTestId('app-icon') await user.click(iconButton) @@ -1021,12 +903,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ labels: ['initial'] }), + payload: createDefaultDrawerPayload({ labels: ['initial'] }), onHide: vi.fn(), } // Act - render() + render() await user.click(screen.getByTestId('add-label')) // Assert @@ -1039,12 +921,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ privacy_policy: '' }), + payload: createDefaultDrawerPayload({ privacy_policy: '' }), onHide: vi.fn(), } // Act - render() + render() const privacyInput = screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder') await user.type(privacyInput, 'https://example.com/privacy') @@ -1062,12 +944,12 @@ describe('WorkflowToolAsModal', () => { const onHide = vi.fn() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide, } // Act - render() + render() await user.click(screen.getByText('common.operation.cancel')) // Assert @@ -1080,12 +962,12 @@ describe('WorkflowToolAsModal', () => { const onHide = vi.fn() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide, } // Act - render() + render() await user.click(screen.getByTestId('drawer-close')) // Assert @@ -1098,13 +980,13 @@ describe('WorkflowToolAsModal', () => { const onRemove = vi.fn() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onRemove, } // Act - render() + render() await user.click(screen.getByText('common.operation.delete')) // Assert @@ -1117,13 +999,13 @@ describe('WorkflowToolAsModal', () => { const onCreate = vi.fn() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), onCreate, } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Assert @@ -1138,13 +1020,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onSave: vi.fn(), } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Assert @@ -1158,13 +1040,13 @@ describe('WorkflowToolAsModal', () => { const onSave = vi.fn() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onSave, } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) await user.click(screen.getByText('common.operation.confirm')) @@ -1179,7 +1061,7 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ + payload: createDefaultDrawerPayload({ parameters: [{ name: 'param1', description: '', // Start with empty description @@ -1192,7 +1074,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() const descInput = screen.getByPlaceholderText('tools.createTool.toolInput.descriptionPlaceholder') await user.type(descInput, 'New parameter description') @@ -1209,13 +1091,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ label: '' }), + payload: createDefaultDrawerPayload({ label: '' }), onHide: vi.fn(), onCreate: vi.fn(), } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Assert @@ -1230,13 +1112,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ label: 'Test', name: '' }), + payload: createDefaultDrawerPayload({ label: 'Test', name: '' }), onHide: vi.fn(), onCreate: vi.fn(), } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Assert @@ -1251,12 +1133,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ name: '' }), + payload: createDefaultDrawerPayload({ name: '' }), onHide: vi.fn(), } // Act - render() + render() const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') await user.type(nameInput, 'invalid name with spaces') @@ -1270,12 +1152,12 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload({ name: '' }), + payload: createDefaultDrawerPayload({ name: '' }), onHide: vi.fn(), } // Act - render() + render() const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') await user.type(nameInput, 'valid_name_123') @@ -1321,31 +1203,31 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload({ parameters: [] }), + payload: createDefaultDrawerPayload({ parameters: [] }), onHide: vi.fn(), } // Act & Assert - expect(() => render()).not.toThrow() + expect(() => render()).not.toThrow() }) it('should handle empty output parameters', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload({ outputParameters: [] }), + payload: createDefaultDrawerPayload({ outputParameters: [] }), onHide: vi.fn(), } // Act & Assert - expect(() => render()).not.toThrow() + expect(() => render()).not.toThrow() }) it('should handle parameter with __image name specially', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload({ + payload: createDefaultDrawerPayload({ parameters: [{ name: '__image', description: 'Image parameter', @@ -1358,7 +1240,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() // Assert - __image should show method as text, not selector // Assert - __image should show method as text, not selector @@ -1369,7 +1251,7 @@ describe('WorkflowToolAsModal', () => { // Arrange const props = { isAdd: true, - payload: createDefaultModalPayload({ + payload: createDefaultDrawerPayload({ outputParameters: [{ name: 'text', // Collides with reserved description: 'Custom text output', @@ -1380,7 +1262,7 @@ describe('WorkflowToolAsModal', () => { } // Act - render() + render() // Assert - should show both reserved and custom with warning icon const textElements = screen.getAllByText('text') @@ -1392,13 +1274,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), // onSave is undefined } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) // Show confirm modal @@ -1415,13 +1297,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: true, - payload: createDefaultModalPayload(), + payload: createDefaultDrawerPayload(), onHide: vi.fn(), // onCreate is undefined } // Act & Assert - should not crash - render() + render() await user.click(screen.getByText('common.operation.save')) }) @@ -1430,13 +1312,13 @@ describe('WorkflowToolAsModal', () => { const user = userEvent.setup() const props = { isAdd: false, - payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }), + payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), onHide: vi.fn(), onSave: vi.fn(), } // Act - render() + render() await user.click(screen.getByText('common.operation.save')) await waitFor(() => { @@ -1690,25 +1572,22 @@ describe('Integration Tests', () => { })) }) - // Complete workflow: open modal -> fill form -> save + // Complete workflow: open drawer -> fill form -> save describe('Complete Workflow', () => { it('should complete full create workflow', async () => { // Arrange const user = userEvent.setup() - mockCreateWorkflowToolProvider.mockResolvedValue({}) - const onRefreshData = vi.fn() - const props = createDefaultConfigureButtonProps({ onRefreshData }) + const onCreate = vi.fn() // Act - render() - - // Open modal - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + render( + , + ) // Fill form const labelInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder') @@ -1716,6 +1595,7 @@ describe('Integration Tests', () => { await user.type(labelInput, 'My Custom Tool') const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') + await user.clear(nameInput) await user.type(nameInput, 'my_custom_tool') const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') @@ -1727,7 +1607,7 @@ describe('Integration Tests', () => { // Assert await waitFor(() => { - expect(mockCreateWorkflowToolProvider).toHaveBeenCalledWith( + expect(onCreate).toHaveBeenCalledWith( expect.objectContaining({ name: 'my_custom_tool', label: 'My Custom Tool', @@ -1735,36 +1615,22 @@ describe('Integration Tests', () => { }), ) }) - - await waitFor(() => { - expect(onRefreshData).toHaveBeenCalled() - }) }) it('should complete full update workflow', async () => { // Arrange const user = userEvent.setup() - const handlePublish = vi.fn().mockResolvedValue(undefined) - mockSaveWorkflowToolProvider.mockResolvedValue({}) - const props = createDefaultConfigureButtonProps({ - published: true, - handlePublish, - }) + const onSave = vi.fn() // Act - render() - - // Wait for detail to load - await waitFor(() => { - expect(screen.getByText('workflow.common.configure'))!.toBeInTheDocument() - }) - - // Open modal - await user.click(screen.getByText('workflow.common.configure')) - - await waitFor(() => { - expect(screen.getByTestId('drawer'))!.toBeInTheDocument() - }) + render( + , + ) // Modify description const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') @@ -1782,8 +1648,10 @@ describe('Integration Tests', () => { // Assert await waitFor(() => { - expect(handlePublish).toHaveBeenCalled() - expect(mockSaveWorkflowToolProvider).toHaveBeenCalled() + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + workflow_tool_id: 'workflow-tool-1', + description: 'Updated description', + })) }) }) }) @@ -1792,11 +1660,9 @@ describe('Integration Tests', () => { describe('Callback Stability', () => { it('should maintain callback references across rerenders', async () => { // Arrange - const handlePublish = vi.fn().mockResolvedValue(undefined) - const onRefreshData = vi.fn() + const onConfigure = vi.fn() const props = createDefaultConfigureButtonProps({ - handlePublish, - onRefreshData, + onConfigure, }) // Act diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx index 9f5532f1f7..8c35232d35 100644 --- a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -1,22 +1,9 @@ -import type { WorkflowToolModalPayload } from '../index' +import type { ReactNode } from 'react' +import type { WorkflowToolDrawerPayload } from '../index' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import WorkflowToolAsModal from '../index' - -vi.mock('@/app/components/base/drawer-plus', () => ({ - default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => ( - isShow - ? ( -
- {title} - - {body} -
- ) - : null - ), -})) +import { WorkflowToolDrawer } from '../index' vi.mock('@/app/components/base/emoji-picker/Inner', () => ({ default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => ( @@ -46,8 +33,8 @@ vi.mock('@/app/components/base/tooltip', () => ({ children, popupContent, }: { - children?: React.ReactNode - popupContent?: React.ReactNode + children?: ReactNode + popupContent?: ReactNode }) => (
{children} @@ -86,7 +73,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) -const createPayload = (overrides: Partial = {}): WorkflowToolModalPayload => ({ +const createPayload = (overrides: Partial = {}): WorkflowToolDrawerPayload => ({ icon: { content: '🔧', background: '#ffffff' }, label: 'My Tool', name: 'my_tool', @@ -105,7 +92,7 @@ const createPayload = (overrides: Partial = {}): Workf ...overrides, }) -describe('WorkflowToolAsModal', () => { +describe('WorkflowToolDrawer', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -115,7 +102,7 @@ describe('WorkflowToolAsModal', () => { const onCreate = vi.fn() render( - { const onCreate = vi.fn() render( - { const onSave = vi.fn() render( - { it('should show duplicate reserved output warnings', () => { render( - Promise - onRefreshData?: () => void + isLoading: boolean + outdated: boolean + isCurrentWorkspaceManager: boolean + onConfigure: () => void disabledReason?: string } const WorkflowToolConfigureButton = ({ disabled, published, - detailNeedUpdate, - workflowAppId, - icon, - name, - description, - inputs, - outputs, - handlePublish, - onRefreshData, + isLoading, + outdated, + isCurrentWorkspaceManager, + onConfigure, disabledReason, }: Props) => { const { t } = useTranslation() - const { - showModal, - isLoading, - outdated, - payload, - isCurrentWorkspaceManager, - openModal, - closeModal, - handleCreate, - handleUpdate, - navigateToTools, - } = useConfigureButton({ - published, - detailNeedUpdate, - workflowAppId, - icon, - name, - description, - inputs, - outputs, - handlePublish, - onRefreshData, - }) + const router = useRouter() return ( <> @@ -80,9 +43,12 @@ const WorkflowToolConfigureButton = ({ ? (
!disabled && !published && openModal()} + onClick={() => { + if (!disabled && !published) + onConfigure() + }} > - +
- +
{t('common.configure', { ns: 'workflow' })} @@ -129,11 +95,11 @@ const WorkflowToolConfigureButton = ({
{outdated && ( @@ -146,15 +112,6 @@ const WorkflowToolConfigureButton = ({
)} {published && isLoading &&
} - {showModal && ( - - )} ) } diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts index 8bc3db95da..fd800fe5b0 100644 --- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts +++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts @@ -4,11 +4,6 @@ import { act, renderHook } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import { isParametersOutdated, useConfigureButton } from '../use-configure-button' -const mockPush = vi.fn() -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ push: mockPush }), -})) - const mockIsCurrentWorkspaceManager = vi.fn(() => true) vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ @@ -98,6 +93,7 @@ const createMockDetail = (overrides: Partial = {}) }) const createDefaultOptions = (overrides = {}) => ({ + enabled: true, published: false, detailNeedUpdate: false, workflowAppId: 'app-123', @@ -213,9 +209,9 @@ describe('useConfigureButton', () => { }) describe('Initialization', () => { - it('should return showModal as false by default', () => { + it('should return workflow tool state without owning drawer visibility', () => { const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) - expect(result.current.showModal).toBe(false) + expect(result.current.payload).toMatchObject({ workflow_app_id: 'app-123' }) }) it('should forward isCurrentWorkspaceManager from context', () => { @@ -239,6 +235,11 @@ describe('useConfigureButton', () => { renderHook(() => useConfigureButton(createDefaultOptions({ published: false }))) expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false) }) + + it('should call query hook with enabled=false when controller is disabled', () => { + renderHook(() => useConfigureButton(createDefaultOptions({ enabled: false, published: true }))) + expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false) + }) }) // Computed values @@ -348,46 +349,13 @@ describe('useConfigureButton', () => { }) }) - // Modal controls - describe('Modal Controls', () => { - it('should open modal via openModal', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) - act(() => { - result.current.openModal() - }) - expect(result.current.showModal).toBe(true) - }) - - it('should close modal via closeModal', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) - act(() => { - result.current.openModal() - }) - act(() => { - result.current.closeModal() - }) - expect(result.current.showModal).toBe(false) - }) - - it('should navigate to tools page', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) - act(() => { - result.current.navigateToTools() - }) - expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow') - }) - }) - // Mutation handlers describe('handleCreate', () => { - it('should create provider, invalidate caches, refresh, and close modal', async () => { + it('should create provider, invalidate caches, refresh, and notify configured', async () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) const onRefreshData = vi.fn() - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData }))) - - act(() => { - result.current.openModal() - }) + const onConfigured = vi.fn() + const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured }))) await act(async () => { await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string }) @@ -398,7 +366,7 @@ describe('useConfigureButton', () => { expect(onRefreshData).toHaveBeenCalled() expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) }) - expect(result.current.showModal).toBe(false) + expect(onConfigured).toHaveBeenCalled() }) it('should show error toast on failure', async () => { @@ -414,20 +382,18 @@ describe('useConfigureButton', () => { }) describe('handleUpdate', () => { - it('should publish, save, invalidate caches, and close modal', async () => { + it('should publish, save, invalidate caches, and notify configured', async () => { mockSaveWorkflowToolProvider.mockResolvedValue({}) const handlePublish = vi.fn().mockResolvedValue(undefined) const onRefreshData = vi.fn() + const onConfigured = vi.fn() const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true, handlePublish, onRefreshData, + onConfigured, }))) - act(() => { - result.current.openModal() - }) - await act(async () => { await result.current.handleUpdate(createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>) }) @@ -437,7 +403,7 @@ describe('useConfigureButton', () => { expect(onRefreshData).toHaveBeenCalled() expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') - expect(result.current.showModal).toBe(false) + expect(onConfigured).toHaveBeenCalled() }) it('should show error toast when publish fails', async () => { @@ -491,6 +457,16 @@ describe('useConfigureButton', () => { expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled() }) + + it('should not invalidate detail while disabled', () => { + renderHook(() => useConfigureButton(createDefaultOptions({ + enabled: false, + published: true, + detailNeedUpdate: true, + }))) + + expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled() + }) }) // Edge cases diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts index 33965aa5ee..f4b6881f98 100644 --- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -2,10 +2,9 @@ import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderPa import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useAppContext } from '@/context/app-context' -import { useRouter } from '@/next/navigation' import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools' import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools' @@ -89,6 +88,7 @@ function buildExistingOutputParameters( // endregion type UseConfigureButtonOptions = { + enabled: boolean published: boolean detailNeedUpdate: boolean workflowAppId: string @@ -99,10 +99,12 @@ type UseConfigureButtonOptions = { outputs?: Variable[] handlePublish: (params?: PublishWorkflowParams) => Promise onRefreshData?: () => void + onConfigured?: () => void } export function useConfigureButton(options: UseConfigureButtonOptions) { const { + enabled, published, detailNeedUpdate, workflowAppId, @@ -113,16 +115,14 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { outputs, handlePublish, onRefreshData, + onConfigured, } = options const { t } = useTranslation() - const router = useRouter() const { isCurrentWorkspaceManager } = useAppContext() - const [showModal, setShowModal] = useState(false) - // Data fetching via React Query - const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, published) + const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, enabled && published) // Invalidation functions (store in ref for stable effect dependency) const invalidateDetail = useInvalidateWorkflowToolDetailByAppID() @@ -133,9 +133,9 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { // Refetch when detailNeedUpdate becomes true useEffect(() => { - if (detailNeedUpdate) + if (enabled && detailNeedUpdate) invalidateDetailRef.current(workflowAppId) - }, [detailNeedUpdate, workflowAppId]) + }, [detailNeedUpdate, enabled, workflowAppId]) // Computed values const outdated = useMemo( @@ -173,14 +173,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { } }, [detail, published, workflowAppId, icon, name, description, inputs, outputs]) - // Modal controls (stable callbacks) - const openModal = useCallback(() => setShowModal(true), []) - const closeModal = useCallback(() => setShowModal(false), []) - const navigateToTools = useCallback( - () => router.push('/tools?category=workflow'), - [router], - ) - // Mutation handlers (not memoized — only used in conditionally-rendered modal) const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => { try { @@ -189,7 +181,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData?.() invalidateDetail(workflowAppId) toast.success(t('api.actionSuccess', { ns: 'common' })) - setShowModal(false) + onConfigured?.() } catch (e) { toast.error((e as Error).message) @@ -206,7 +198,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData?.() invalidateAllWorkflowTools() invalidateDetail(workflowAppId) - setShowModal(false) + onConfigured?.() } catch (e) { toast.error((e as Error).message) @@ -214,15 +206,11 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { } return { - showModal, isLoading, outdated, payload, isCurrentWorkspaceManager, - openModal, - closeModal, handleCreate, handleUpdate, - navigateToTools, } } diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index 6f8258f185..c582980a49 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -1,9 +1,19 @@ 'use client' -import type { FC } from 'react' +import type { DrawerRootProps } from '@langgenius/dify-ui/drawer' import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { produce } from 'immer' @@ -26,7 +36,7 @@ import { isWorkflowToolNameValid, } from './helpers' -export type WorkflowToolModalPayload = { +export type WorkflowToolDrawerPayload = { icon: Emoji label: string name: string @@ -42,9 +52,9 @@ export type WorkflowToolModalPayload = { workflow_app_id?: string } -type Props = { +export type WorkflowToolDrawerProps = { isAdd?: boolean - payload: WorkflowToolModalPayload + payload: WorkflowToolDrawerPayload onHide: () => void onRemove?: () => void onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void @@ -54,8 +64,9 @@ type Props = { }>) => void } -type WorkflowToolDrawerProps = { +type WorkflowToolDrawerFrameProps = { title: string + closeLabel: string onHide: () => void children: React.ReactNode } @@ -77,39 +88,45 @@ const InfoTooltip = ({ children }: { children: React.ReactNode }) => { ) } -const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => { +const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: WorkflowToolDrawerFrameProps) => { + const handleOpenChange = React.useCallback>((open) => { + if (!open) + onHide() + }, [onHide]) + return ( - - -
-
-
- - {title} - - -
-
-
- {children} -
-
-
-
+ + + + + + +
+
+ + {title} + + +
+
+
+ {children} +
+
+
+
+
+
) } @@ -158,15 +175,14 @@ const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerP ) } -// Add and Edit -const WorkflowToolAsModal: FC = ({ +export function WorkflowToolDrawer({ isAdd, payload, onHide, onRemove, onSave, onCreate, -}) => { +}: WorkflowToolDrawerProps) { const { t } = useTranslation() const [showEmojiPicker, setShowEmojiPicker] = useState(false) @@ -200,7 +216,7 @@ const WorkflowToolAsModal: FC = ({ setLabels(value) } const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy) - const [showModal, setShowModal] = useState(false) + const [confirmModalOpen, setConfirmModalOpen] = useState(false) const onConfirm = () => { let errorMessage = '' @@ -243,9 +259,10 @@ const WorkflowToolAsModal: FC = ({ return ( <> -
@@ -427,7 +444,7 @@ const WorkflowToolAsModal: FC = ({ if (isAdd) onConfirm() else - setShowModal(true) + setConfirmModalOpen(true) }} > {t('operation.save', { ns: 'common' })} @@ -435,7 +452,7 @@ const WorkflowToolAsModal: FC = ({
- + {showEmojiPicker && ( { @@ -447,10 +464,10 @@ const WorkflowToolAsModal: FC = ({ }} /> )} - {showModal && ( + {confirmModalOpen && ( setShowModal(false)} + show={confirmModalOpen} + onClose={() => setConfirmModalOpen(false)} onConfirm={onConfirm} /> )} @@ -458,4 +475,3 @@ const WorkflowToolAsModal: FC = ({ ) } -export default React.memo(WorkflowToolAsModal) diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 41e47967b2..39dd8e4ccb 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -124,7 +124,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({ -
diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index 4457d9cddf..b849159867 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -10,12 +10,15 @@ This document tracks the Dify-web migration away from legacy overlay APIs. - `@/app/components/base/tooltip` - `@/app/components/base/modal` - `@/app/components/base/dialog` + - `@/app/components/base/drawer` + - `@/app/components/base/drawer-plus` - Replacement primitives: - `@langgenius/dify-ui/tooltip` - `@langgenius/dify-ui/dropdown-menu` - `@langgenius/dify-ui/context-menu` - `@langgenius/dify-ui/popover` - `@langgenius/dify-ui/dialog` + - `@langgenius/dify-ui/drawer` - `@langgenius/dify-ui/alert-dialog` - `@langgenius/dify-ui/autocomplete` - `@langgenius/dify-ui/combobox` @@ -49,12 +52,12 @@ All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index val During the migration period, legacy and new overlays coexist. Legacy overlays portal to `document.body` with explicit z-index values: -| Layer | z-index | Components | -| --------------------- | ------------ | -------------------------------------------------------------------------------- | -| Legacy Drawer | `z-30` | `base/drawer` | -| Legacy Modal | `z-60` | `base/modal` (default) | -| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) | -| Toast | `z-1003` | `@langgenius/dify-ui/toast` | +| Layer | z-index | Components | +| --------------------- | ------------ | ---------------------------------------------------------------------------------------- | +| Legacy Drawer | `z-30` | `base/drawer`, `base/drawer-plus` | +| Legacy Modal | `z-60` | `base/modal` (default) | +| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Drawer, Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) | +| Toast | `z-1003` | `@langgenius/dify-ui/toast` | `z-1002` sits above all common legacy overlays, so new primitives always render on top without needing per-call-site z-index hacks. Among themselves, diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index a55213ab49..f74c5c9115 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -66,6 +66,15 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ ], message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.', }, + { + group: [ + '**/base/drawer', + '**/base/drawer/index', + '**/base/drawer-plus', + '**/base/drawer-plus/index', + ], + message: 'Deprecated: use @langgenius/dify-ui/drawer instead. See issue #32767.', + }, ] export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = { diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 27850e62ad..b9edad48f0 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -5,6 +5,7 @@ import type { MetadataItemWithValue } from '@/app/components/datasets/metadata/t import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { Tag } from '@/contract/console/tags' import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app' +import type { SegmentImportStatus } from '@/types/dataset' import type { I18nKeysByPrefix } from '@/types/i18n' import { ExternalKnowledgeBase, General, ParentChild, Qa } from '@/app/components/base/icons/src/public/knowledge/dataset-card' @@ -783,7 +784,7 @@ export type UpdateDocumentBatchParams = { export type BatchImportResponse = { job_id: string - job_status: string + job_status: SegmentImportStatus } export const DOC_FORM_ICON_WITH_BG: Record> = { diff --git a/web/types/dataset.ts b/web/types/dataset.ts new file mode 100644 index 0000000000..167a740098 --- /dev/null +++ b/web/types/dataset.ts @@ -0,0 +1,8 @@ +export const segmentImportStatus = { + waiting: 'waiting', + processing: 'processing', + completed: 'completed', + error: 'error', +} as const + +export type SegmentImportStatus = typeof segmentImportStatus[keyof typeof segmentImportStatus]