feat(dify-ui): add drawer (#35917)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-05-08 16:53:32 +08:00 committed by GitHub
parent 82f24b336d
commit 8f93bb36ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 1686 additions and 1955 deletions

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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(
<Drawer>
<DrawerTrigger>Open settings</DrawerTrigger>
<DrawerPortal>
<DrawerBackdrop data-testid="drawer-backdrop" />
<DrawerViewport>
<DrawerPopup>
<DrawerTitle>Settings</DrawerTitle>
<DrawerDescription>Configure the current workspace.</DrawerDescription>
<DrawerContent>
<p>Workspace controls</p>
<DrawerCloseButton />
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>,
)
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()
})
})
})
})

View File

@ -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<Payload = unknown> = BaseDrawer.Root.Props<Payload>
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<Payload = unknown> = BaseDrawer.Trigger.Props<Payload>
export function DrawerBackdrop({
className,
...props
}: BaseDrawer.Backdrop.Props) {
return (
<BaseDrawer.Backdrop
className={cn(
'fixed inset-0 z-1002 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]',
'transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 motion-reduce:transition-none',
className,
)}
{...props}
/>
)
}
export function DrawerViewport({
className,
...props
}: BaseDrawer.Viewport.Props) {
return (
<BaseDrawer.Viewport
className={cn('fixed inset-0 z-1002 touch-none overflow-hidden overscroll-contain outline-hidden', className)}
{...props}
/>
)
}
export function DrawerPopup({
className,
...props
}: BaseDrawer.Popup.Props) {
return (
<BaseDrawer.Popup
className={cn(
'fixed z-1002 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none',
'transition-[transform,opacity,box-shadow] duration-200 data-swiping:select-none data-swiping:duration-0 motion-reduce:transition-none',
'data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:h-dvh data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-2rem)] data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-r-0 data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
'data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))]',
'data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:h-dvh data-[swipe-direction=left]:w-120 data-[swipe-direction=left]:max-w-[calc(100vw-2rem)] data-[swipe-direction=left]:rounded-r-2xl data-[swipe-direction=left]:border-l-0 data-[swipe-direction=left]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
'data-starting-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))] data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))]',
'data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=down]:w-full data-[swipe-direction=down]:rounded-t-2xl data-[swipe-direction=down]:border-b-0 data-[swipe-direction=down]:transform-[translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))]',
'data-starting-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))] data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))]',
'data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=up]:w-full data-[swipe-direction=up]:rounded-b-2xl data-[swipe-direction=up]:border-t-0 data-[swipe-direction=up]:transform-[translateY(var(--drawer-swipe-movement-y,0px))]',
'data-starting-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))] data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))]',
className,
)}
{...props}
/>
)
}
export function DrawerContent({
className,
...props
}: BaseDrawer.Content.Props) {
return (
<BaseDrawer.Content
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom,0))]', className)}
{...props}
/>
)
}
type DrawerCloseButtonProps = Omit<BaseDrawer.Close.Props, 'children'> & {
children?: ReactNode
}
export function DrawerCloseButton({
className,
children,
type = 'button',
'aria-label': ariaLabel = 'Close drawer',
...props
}: DrawerCloseButtonProps) {
return (
<BaseDrawer.Close
type={type}
aria-label={ariaLabel}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children ?? <span aria-hidden="true" className="i-ri-close-line h-4 w-4" />}
</BaseDrawer.Close>
)
}

View File

@ -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 }) => (
<div data-testid="workflow-tool-modal">
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>

View File

@ -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 }) => (
<div data-testid="workflow-tool-drawer">
workflow tool drawer
<button onClick={onHide}>close-workflow-tool-drawer</button>
</div>
),
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('../sections', () => ({
@ -143,6 +167,7 @@ vi.mock('../sections', () => ({
<div>
<button onClick={props.handleEmbed}>publisher-embed</button>
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
</div>
)
},
@ -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(
<AppPublisher
publishedAt={Date.now()}
/>,
)
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(
<AppPublisher

View File

@ -190,18 +190,17 @@ describe('app-publisher sections', () => {
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()}
/>,
)

View File

@ -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> | any
onRestore?: () => Promise<any> | 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> | unknown)
| ((params?: unknown) => Promise<unknown> | unknown)
type AppPublisherRestoreHandler = () => Promise<unknown> | 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 && (
<div className="border-t border-divider-subtle p-4">
<SuggestedAction
icon={<RiStoreLine className="h-4 w-4" />}
icon={<span className="i-ri-store-line h-4 w-4" />}
disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace}
>
@ -380,6 +413,15 @@ const AppPublisher = ({
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</Popover>
{workflowToolDrawerOpen && (
<WorkflowToolDrawer
isAdd={!workflowToolPublished}
payload={workflowTool.payload}
onHide={closeWorkflowToolDrawer}
onCreate={workflowTool.handleCreate}
onSave={workflowTool.handleUpdate}
/>
)}
</>
)
}

View File

@ -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<AppPublisherProps, | 'hasHumanInputNode'
| 'hasTriggerNode'
| 'inputs'
| 'missingStartNode'
| 'onRefreshData'
| 'toolPublished'
| 'outputs'
| 'publishedAt'
| 'workflowToolAvailable'> & {
appDetail: {
@ -67,9 +62,11 @@ type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
disabledFunctionTooltip?: string
handleEmbed: () => void
handleOpenInExplore: () => void
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
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 = ({
<SuggestedAction
onClick={handleEmbed}
disabled={!publishedAt}
icon={<CodeBrowser className="h-4 w-4" />}
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>
@ -340,18 +336,10 @@ export const PublisherActionsSection = ({
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published}
workflowAppId={appDetail?.id ?? ''}
icon={{
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}}
name={appDetail?.name ?? ''}
description={appDetail?.description ?? ''}
inputs={inputs}
outputs={outputs}
handlePublish={handlePublish}
onRefreshData={onRefreshData}
isLoading={workflowToolIsLoading}
outdated={workflowToolOutdated}
isCurrentWorkspaceManager={workflowToolIsCurrentWorkspaceManager}
onConfigure={onConfigureWorkflowTool}
disabledReason={workflowToolMessage}
/>
)}

View File

@ -54,22 +54,22 @@ const Operation: FC<Props> = ({
onOpenChange={setOpen}
>
<DropdownMenuTrigger
render={<div />}
render={(
<ActionButton
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
state={
isActive
? ActionButtonState.Active
: open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</ActionButton>
)}
onClick={e => e.stopPropagation()}
>
<ActionButton
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
state={
isActive
? ActionButtonState.Active
: open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</ActionButton>
</DropdownMenuTrigger>
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}

View File

@ -182,11 +182,13 @@ describe('useChatLayout', () => {
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')

View File

@ -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<HTMLDivElement>(null)
@ -21,6 +26,9 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
const userScrolledRef = useRef(false)
const isAutoScrollingRef = useRef(false)
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
const resizeObserverFrameRef = useRef<number | null>(null)
const pendingFooterBlockSizeRef = useRef<number | null>(null)
const pendingContainerInlineSizeRef = useRef<number | null>(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 = () => {

View File

@ -1,2 +1 @@
export { default as BracketsX } from './BracketsX'
export { default as CodeBrowser } from './CodeBrowser'

View File

@ -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 }) => (
<div data-testid="segment-add" data-embedding={embedding}>
<button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button>
<button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button>
</div>
),
ProcessStatus: {
WAITING: 'waiting',
PROCESSING: 'processing',
ERROR: 'error',
COMPLETED: 'completed',
},
}))
vi.mock('../../components/operations', () => ({

View File

@ -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<IBatchModalProps> = ({
isShow,
type BatchModalContentProps = Omit<IBatchModalProps, 'isShow'>
const BatchModalContent: FC<BatchModalContentProps> = ({
docForm,
onCancel,
onConfirm,
@ -35,17 +39,13 @@ const BatchModal: FC<IBatchModalProps> = ({
onConfirm(currentCSV)
}
useEffect(() => {
if (!isShow)
setCurrentCSV(undefined)
}, [isShow])
return (
<Modal isShow={isShow} onClose={noop} className="max-w-[520px]! rounded-xl! px-8 py-6">
<div className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</div>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}>
<RiCloseLine className="h-4 w-4 text-text-secondary" />
</div>
<DialogContent className="w-[520px]! overflow-hidden! rounded-xl! border-0! px-8 py-6">
<DialogTitle className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</DialogTitle>
<DialogCloseButton
className="top-4 right-4"
aria-label={t('list.batchModal.cancel', { ns: 'datasetDocuments' })}
/>
<CSVUploader
file={currentCSV}
updateFile={handleFile}
@ -61,7 +61,33 @@ const BatchModal: FC<IBatchModalProps> = ({
{t('list.batchModal.run', { ns: 'datasetDocuments' })}
</Button>
</div>
</Modal>
</DialogContent>
)
}
const BatchModal: FC<IBatchModalProps> = ({
isShow,
docForm,
onCancel,
onConfirm,
}) => {
return (
<Dialog
open={isShow}
onOpenChange={open => !open && onCancel()}
disablePointerDismissal
>
{isShow
? (
<BatchModalContent
docForm={docForm}
onCancel={onCancel}
onConfirm={onConfirm}
/>
)
: null}
</Dialog>
)
}
export default React.memo(BatchModal)

View File

@ -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', () => ({
)}
</div>
),
}))
vi.mock('../components/drawer-group', () => ({
DrawerGroup: () => <div data-testid="drawer-group" />,
}))
vi.mock('../components/segment-list-content', () => ({
FullDocModeContent: () => <div data-testid="full-doc-mode-content" />,
GeneralModeContent: () => <div data-testid="general-mode-content" />,
}))
@ -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(<Completed {...defaultProps} importStatus="completed" />, { wrapper: createWrapper() })
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()

View File

@ -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(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
fireEvent.click(screen.getByTestId('regenerate-btn'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(true)
})
it('should close modal when cancel is clicked', () => {
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
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(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onCancel={mockOnCancel}
onModalStateChange={mockOnModalStateChange}
/>,
)
// Open regeneration modal
fireEvent.click(screen.getByTestId('regenerate-btn'))
fireEvent.click(screen.getByTestId('close-regeneration'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
expect(mockOnCancel).toHaveBeenCalled()
})
})

View File

@ -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<HTMLElement>('[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(
<Drawer open={false} onClose={vi.fn()}>
<CompletedDrawer open={false} onClose={vi.fn()}>
<span>Content</span>
</Drawer>,
</CompletedDrawer>,
)
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 {...defaultProps}>
<CompletedDrawer {...defaultProps}>
<span>Drawer content</span>
</Drawer>,
</CompletedDrawer>,
)
expect(screen.getByText('Drawer content')).toBeInTheDocument()
})
it('should render dialog with role="dialog"', () => {
render(
<Drawer {...defaultProps}>
<span>Content</span>
</Drawer>,
)
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(
<Drawer {...defaultProps} showOverlay={true}>
<CompletedDrawer {...defaultProps}>
<span>Content</span>
</Drawer>,
)
const overlay = document.querySelector('[aria-hidden="true"]')
expect(overlay).toBeInTheDocument()
})
it('should hide overlay when showOverlay is false', () => {
render(
<Drawer {...defaultProps} showOverlay={false}>
<span>Content</span>
</Drawer>,
)
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(
<Drawer {...defaultProps} modal={true}>
<span>Content</span>
</Drawer>,
)
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
})
it('should set aria-modal="false" when modal is false', () => {
render(
<Drawer {...defaultProps} modal={false}>
<span>Content</span>
</Drawer>,
</CompletedDrawer>,
)
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(
<Drawer open={true} onClose={onClose}>
<CompletedDrawer {...defaultProps} modal>
<span>Content</span>
</Drawer>,
</CompletedDrawer>,
)
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(
<CompletedDrawer open={true} onClose={onClose}>
<span>Content</span>
</CompletedDrawer>,
)
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(
<>
<button type="button">Outside</button>
<CompletedDrawer open={true} onClose={onClose}>
<span>Content</span>
</CompletedDrawer>
</>,
)
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(
<CompletedDrawer open={true} onClose={onClose}>
<button type="button">Inside</button>
</CompletedDrawer>,
)
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(
<CompletedDrawer open={true} onClose={onClose} modal>
<span>Content</span>
</CompletedDrawer>,
)
fireEvent.click(getOverlay()!)
expect(onClose).toHaveBeenCalledTimes(1)
})

View File

@ -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(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
@ -41,9 +39,9 @@ describe('FullScreenDrawer', () => {
it('should not render when closed', () => {
render(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<DocumentDetailDrawer open={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
@ -51,9 +49,9 @@ describe('FullScreenDrawer', () => {
it('should render children content', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Test Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
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(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
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(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
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(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('true')
})
it('should pass showOverlay=false when specified', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('false')
})
it('should pass needCheckChunks prop with default false', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
})
it('should pass needCheckChunks=true when specified', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
})
it('should pass modal prop with default false', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
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(
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
<DocumentDetailDrawer open={true} fullScreen={false} modal>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
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(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
@ -167,9 +125,9 @@ describe('FullScreenDrawer', () => {
it('should apply panel content classes without border for fullScreen mode', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
@ -184,24 +142,24 @@ describe('FullScreenDrawer', () => {
// Arrange & Act & Assert - should not throw
expect(() => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
}).not.toThrow()
})
it('should maintain structure when rerendered', () => {
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
rerender(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Updated Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByText('Updated Content')).toBeInTheDocument()
@ -209,16 +167,16 @@ describe('FullScreenDrawer', () => {
it('should handle toggle between open and closed states', () => {
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
rerender(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<DocumentDetailDrawer open={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()

View File

@ -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<ComponentProps<typeof Drawer>['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<DrawerSide, DrawerSwipeDirection> = {
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<DrawerProps>) => {
const panelContentRef = useRef<HTMLDivElement>(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 = (
<div className="pointer-events-none fixed inset-0 z-9999">
{showOverlay && (
<div
onClick={modal ? onClose : undefined}
aria-hidden="true"
className={cn(
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
open && 'opacity-100',
overlayPointerEvents,
)}
/>
)}
<div
role="dialog"
aria-modal={modal ? 'true' : 'false'}
className={cn(
'pointer-events-auto fixed flex flex-col',
SIDE_POSITION_CLASS[side],
isHorizontal ? 'h-screen' : 'w-screen',
panelClassName,
)}
>
<div ref={panelContentRef} className={cn('flex grow flex-col', panelContentClassName)}>
{children}
</div>
</div>
</div>
)
}
if (!open)
return null
return createPortal(content, document.body)
return (
<Drawer
open={open}
modal={modal}
swipeDirection={SIDE_TO_SWIPE_DIRECTION[side]}
disablePointerDismissal
onOpenChange={handleOpenChange}
>
<DrawerPortal>
{modal && (
<DrawerBackdrop
onClick={onClose}
/>
)}
<DrawerViewport className="pointer-events-none">
<DrawerPopup
aria-modal={modal ? 'true' : 'false'}
className={cn(DRAWER_POPUP_CLASS_NAME, panelClassName)}
>
<DrawerContent
className={cn('flex grow flex-col overflow-visible p-0 pb-0', panelContentClassName)}
>
{children}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
export default Drawer

View File

@ -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<IFullScreenDrawerProps>) => {
}: DocumentDetailDrawerProps) {
return (
<Drawer
open={isOpen}
<CompletedDrawer
open={open}
onClose={onClose}
panelClassName={cn(
fullScreen
? 'w-full'
: 'w-[568px] pt-16 pr-2 pb-2',
? 'w-full data-[swipe-direction=left]:w-full data-[swipe-direction=right]:w-full'
: 'w-[568px] pt-16 pr-2 pb-2 data-[swipe-direction=left]:w-[568px] data-[swipe-direction=right]:w-[568px]',
)}
panelContentClassName={cn(
'bg-components-panel-bg',
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
)}
showOverlay={showOverlay}
needCheckChunks={needCheckChunks}
modal={modal}
>
{children}
</Drawer>
</CompletedDrawer>
)
}
export default FullScreenDrawer

View File

@ -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 ? <div data-testid="full-screen-drawer">{children}</div> : null
DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => (
open ? <div data-testid="document-detail-drawer" data-modal={modal}>{children}</div> : null
),
}))
vi.mock('../../segment-detail', () => ({
default: () => <div data-testid="segment-detail" />,
SegmentDetail: () => <div data-testid="segment-detail" />,
}))
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(<DrawerGroup {...defaultProps} />)
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', () => {
<DrawerGroup {...defaultProps} showNewSegmentModal={true} />,
)
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', () => {
<DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />,
)
expect(screen.getByTestId('new-child-segment')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
})
it('should render multiple drawers simultaneously', () => {

View File

@ -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<void>
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<void>
// New child segment drawer
showNewChildSegmentModal: boolean
onCloseNewChildChunkModal: () => void
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
viewNewlyAddedChildChunk: () => void
// Common props
fullScreen: boolean
docForm: ChunkingMode
}
const DrawerGroup: FC<DrawerGroupProps> = ({
// 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 */}
<FullScreenDrawer
isOpen={currSegment.showModal}
<DocumentDetailDrawer
open={currSegment.showModal}
fullScreen={fullScreen}
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
@ -94,13 +75,11 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
isEditMode={currSegment.isEditMode}
onUpdate={onUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
<DocumentDetailDrawer
open={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
@ -111,15 +90,12 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onSave={onSaveNewSegment}
viewNewlyAddedChunk={viewNewlyAddedChunk}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Edit or view child segment detail */}
<FullScreenDrawer
isOpen={currChildChunk.showModal}
<DocumentDetailDrawer
open={currChildChunk.showModal}
fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
>
<ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id}
@ -129,11 +105,10 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onUpdate={onUpdateChildChunk}
onCancel={onCloseChildSegmentDetail}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Create New Child Segment */}
<FullScreenDrawer
isOpen={showNewChildSegmentModal}
<DocumentDetailDrawer
open={showNewChildSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal}
modal
@ -144,9 +119,7 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onSave={onSaveNewChildChunk}
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
</>
)
}
export default DrawerGroup

View File

@ -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'

View File

@ -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)
})
})

View File

@ -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(),

View File

@ -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<CurrSegmentType>({ showModal: false })
// Child segment detail modal state
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ 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,
}

View File

@ -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()
}

View File

@ -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<ICompletedProps> = ({
currSegment={modalState.currSegment}
onCloseSegmentDetail={modalState.onCloseSegmentDetail}
onUpdateSegment={segmentListDataHook.handleUpdateSegment}
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
showNewSegmentModal={showNewSegmentModal}
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
onSaveNewSegment={segmentListDataHook.resetList}

View File

@ -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<ISegmentDetailProps> = ({
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<ISegmentDetailProps> = ({
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<ISegmentDetailProps> = ({
</div>
)
}
export default React.memo(SegmentDetail)

View File

@ -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<DocumentDetailProps> = ({ datasetId, documentId }) => {
const [showMetadata, setShowMetadata] = useState(!isMobile)
const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)
const [batchModalVisible, setBatchModalVisible] = useState(false)
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
const [importStatus, setImportStatus] = useState<SegmentImportStatus>()
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<DocumentDetailProps> = ({ datasetId, documentId }) => {
<>
<SegmentAdd
importStatus={importStatus}
clearProcessStatus={resetProcessStatus}
clearImportStatus={resetImportStatus}
showNewSegmentModal={showNewSegmentModal}
showBatchModal={showBatchModal}
embedding={embedding}

View File

@ -1,8 +1,10 @@
import type { SegmentImportStatus } from '@/types/dataset'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Plan } from '@/app/components/billing/type'
import { segmentImportStatus } from '@/types/dataset'
import SegmentAdd, { ProcessStatus } from '../index'
import { SegmentAdd } from '../index'
// Mock provider context
let mockPlan = { type: Plan.professional }
@ -22,8 +24,8 @@ describe('SegmentAdd', () => {
})
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(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
})
it('should show processing indicator when status is PROCESSING', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
})
it('should show completed status with ok button', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.COMPLETED} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.completed} />)
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(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.ERROR} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.error} />)
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(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
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(
<SegmentAdd
{...defaultProps}
importStatus={ProcessStatus.COMPLETED}
clearProcessStatus={mockClearProcessStatus}
importStatus={segmentImportStatus.completed}
clearImportStatus={mockClearImportStatus}
/>,
)
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(
<SegmentAdd
{...defaultProps}
importStatus={ProcessStatus.ERROR}
clearProcessStatus={mockClearProcessStatus}
importStatus={segmentImportStatus.error}
clearImportStatus={mockClearImportStatus}
/>,
)
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(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
const progressBar = container.querySelector('.w-3\\/12')
expect(progressBar).toBeInTheDocument()
})
it('should show 2/3 width progress bar for PROCESSING status', () => {
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
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(<SegmentAdd {...defaultProps} importStatus="unknown" />)
// 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(<SegmentAdd {...defaultProps} />)

View File

@ -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<ISegmentAddProps> = ({
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<HTMLDivElement>(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) && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border
bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
>
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === ProcessStatus.WAITING ? 'w-3/12' : 'w-2/3')} />
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === segmentImportStatus.waiting ? 'w-3/12' : 'w-2/3')} />
<span aria-hidden className="mr-1 i-ri-loader-2-line h-4 w-4 animate-spin" />
<span className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span>
</div>
)}
{importStatus === ProcessStatus.COMPLETED && (
{importStatus === segmentImportStatus.completed && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success">
<span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span>
</div>
<div className="m-1 inline-flex items-center">
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
</div>
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40" />
</div>
)}
{importStatus === ProcessStatus.ERROR && (
{importStatus === segmentImportStatus.error && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive">
<span aria-hidden className="mr-1 i-ri-error-warning-fill h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span>
</div>
<div className="m-1 inline-flex items-center">
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
</div>
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40" />
</div>
@ -116,7 +113,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
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}
>
<span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} />
@ -142,25 +139,20 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
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)]"
>
<div className="w-full p-1">
<DropdownMenuItem
className="h-auto w-full px-2 py-1.5 system-md-regular"
onClick={() => {
setIsBatchMenuOpen(false)
withNeedUpgradeCheck(showBatchModal)()
}}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</DropdownMenuItem>
</div>
<DropdownMenuItem
className="system-md-regular"
onClick={handleBatchAddClick}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isShowPlanUpgradeModal && (
{isPlanUpgradeModalOpen && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
onClose={() => setIsPlanUpgradeModalOpen(false)}
title={t('upgrade.addChunks.title', { ns: 'billing' })!}
description={t('upgrade.addChunks.description', { ns: 'billing' })!}
/>
@ -169,4 +161,3 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
)
}
export default React.memo(SegmentAdd)

View File

@ -55,10 +55,6 @@ vi.mock('../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/encrypted-bottom', () => ({
EncryptedBottom: () => <div data-testid="encrypted-bottom" />,
}))

View File

@ -41,10 +41,6 @@ vi.mock('../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
mockAuthFormProps = props

View File

@ -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 = ({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
)}
{
isLoading && (

View File

@ -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 = ({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
)}
<AuthForm
formFromProps={form}

View File

@ -14,7 +14,6 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
@ -318,7 +317,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props)
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<MultiSteps currentStep={currentStep} onStepClick={handleBack} />

View File

@ -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)
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<BaseForm
formSchemas={formSchemas}

View File

@ -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'
@ -173,7 +172,7 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) =
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<BaseForm
formSchemas={formSchemas}

View File

@ -1,31 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@langgenius/dify-ui/cn', () => ({
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(<ReadmeEntrance pluginDetail={pluginDetail} />)
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(<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />)
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(<ReadmeEntrance pluginDetail={pluginDetail} />)
expect(container.innerHTML).toBe('')

View File

@ -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 }) => (
<div data-testid="detail-header" data-is-readme-view={isReadmeView}>
@ -32,10 +32,6 @@ vi.mock('../../plugin-detail-panel/detail-header', () => ({
),
}))
// ================================
// Test Data Factories
// ================================
const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-plugin-id',
created_at: '2024-01-01T00:00:00Z',
@ -93,10 +89,6 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): 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(
<QueryClientProvider client={queryClient}>
@ -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(<ReadmePanel />)
it('should return null when no readme panel is open', () => {
const { container } = renderWithQueryClient(<ReadmePanel />)
expect(container.firstChild).toBeNull()
expect(container.firstChild).toBeNull()
})
it('should render drawer presentation with plugin header content', () => {
openReadmePanel()
renderWithQueryClient(<ReadmePanel />)
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(<ReadmePanel />)
expect(screen.getByRole('dialog')).toHaveClass('max-w-200')
})
it('should close the active panel when close button is clicked', () => {
openReadmePanel()
renderWithQueryClient(<ReadmePanel />)
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(<ReadmePanel />)
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(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to fetch'),
})
rerender(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
it('should render DetailHeader component', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
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(<ReadmePanel />)
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(<ReadmePanel />)
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
})
renderWithQueryClient(<ReadmePanel />)
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(<ReadmePanel />)
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(<ReadmePanel />)
renderWithQueryClient(<ReadmePanel />)
// 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(
<>
<ReadmeEntrance pluginDetail={detail} />
<ReadmePanel />
</>,
)
renderWithQueryClient(<ReadmePanel />)
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(<ReadmePanel />)
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(<ReadmePanel />)
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(<ReadmePanel />)
// 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(<ReadmePanel />)
// 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(<ReadmePanel />)
// 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(<ReadmePanel />)
// 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(<ReadmePanel />)
// 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(<ReadmePanel />)
// 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(<ReadmePanel />)
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(<ReadmePanel />)
// 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(<ReadmePanel />)
// 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(<ReadmePanel />)
// 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(<ReadmePanel />)
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(<ReadmePanel />)
// 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(<ReadmePanel />)
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<PluginDetail>).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(
<>
<ReadmeEntrance pluginDetail={mockDetail} />
<ReadmePanel />
</>,
)
// 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(
<QueryClientProvider client={createQueryClient()}>
<ReadmeEntrance pluginDetail={mockDetail} />
<ReadmePanel />
</QueryClientProvider>,
)
// 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(<ReadmePanel />)
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
})
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})

View File

@ -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')
})
})

View File

@ -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 = (
<div className="flex h-40 items-center justify-center">
<Loading type="area" />
</div>
)
}
else if (error) {
readmeContent = (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
</div>
)
}
else if (readmeData?.readme) {
readmeContent = (
<Markdown
content={readmeData.readme}
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
/>
)
}
else {
readmeContent = (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
</div>
)
}
return (
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="shrink-0 rounded-t-xl bg-background-body px-4 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3 shrink-0 text-text-tertiary" />
{title}
</div>
{closeButton}
</div>
<DetailHeader detail={detail} isReadmeView={true} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
{readmeContent}
</div>
</div>
)
}

View File

@ -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 (
<Dialog
open={open}
onOpenChange={onOpenChange}
triggerId={triggerId}
>
<DialogContent className="h-[calc(100dvh-16px)] w-full max-w-200 overflow-hidden p-0">
<ReadmePanelContent
detail={detail}
title={(
<DialogTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</DialogTitle>
)}
closeButton={(
<DialogCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="static h-8 w-8 rounded-lg"
/>
)}
/>
</DialogContent>
</Dialog>
)
}

View File

@ -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 (
<Drawer
open={open}
onOpenChange={onOpenChange}
triggerId={triggerId}
modal
swipeDirection="left"
>
<DrawerPortal>
<DrawerBackdrop className="bg-transparent" />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=left]:top-16 data-[swipe-direction=left]:bottom-2 data-[swipe-direction=left]:left-2 data-[swipe-direction=left]:h-auto data-[swipe-direction=left]:w-150 data-[swipe-direction=left]:max-w-[calc(100vw-1rem)] data-[swipe-direction=left]:rounded-2xl data-[swipe-direction=left]:border-l-[0.5px]">
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0">
<ReadmePanelContent
detail={detail}
title={(
<DrawerTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</DrawerTitle>
)}
closeButton={(
<DrawerCloseButton aria-label={t('operation.close', { ns: 'common' })} />
)}
/>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}

View File

@ -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 (
<div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', showType === ReadmeShowType.drawer && 'px-4', className)}>
<div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', presentation === 'drawer' && 'px-4', className)}>
{!showShortTip && (
<div className="relative h-1 w-8 shrink-0">
<div className="h-px w-full bg-divider-regular"></div>
@ -36,11 +42,13 @@ export const ReadmeEntrance = ({
)}
<button
id={triggerId}
type="button"
onClick={handleReadmeClick}
className="flex w-full items-center justify-start gap-1 text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only"
className="flex w-full items-center justify-start gap-1 rounded-sm text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
>
<div className="relative flex h-3 w-3 items-center justify-center overflow-hidden">
<RiBookReadLine className="h-3 w-3" />
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3" />
</div>
<span className="text-xs leading-4 font-normal">
{!showShortTip ? t('readmeInfo.needHelpCheckReadme', { ns: 'plugin' }) : t('readmeInfo.title', { ns: 'plugin' })}

View File

@ -1,124 +1,38 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiBookReadLine, RiCloseLine } from '@remixicon/react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
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'
import { ReadmeShowType, useReadmePanelStore } from './store'
const ReadmePanel: FC = () => {
const { currentPluginDetail, setCurrentPluginDetail } = useReadmePanelStore()
const { detail, showType } = currentPluginDetail || {}
const { t } = useTranslation()
const language = useLanguage()
import { ReadmeDialog } from './dialog'
import { ReadmeDrawer } from './drawer'
import { useReadmePanelStore } from './store'
const pluginUniqueIdentifier = detail?.plugin_unique_identifier || ''
export default function ReadmePanel() {
const currentPanel = useReadmePanelStore(s => s.currentPanel)
const closeReadmePanel = useReadmePanelStore(s => s.closeReadmePanel)
const { data: readmeData, isLoading, error } = usePluginReadme(
{ plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language },
)
const onClose = () => {
setCurrentPluginDetail()
}
if (!detail)
if (!currentPanel)
return null
const children = (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="rounded-t-xl bg-background-body px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-1">
<RiBookReadLine className="h-3 w-3 text-text-tertiary" />
<span className="text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</span>
</div>
<ActionButton onClick={onClose}>
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
<DetailHeader detail={detail} isReadmeView={true} />
</div>
const onOpenChange = (open: boolean) => {
if (!open)
closeReadmePanel()
}
<div className="flex-1 overflow-y-auto px-4 py-3">
{(() => {
if (isLoading) {
return (
<div className="flex h-40 items-center justify-center">
<Loading type="area" />
</div>
)
}
if (currentPanel.presentation === 'dialog') {
return (
<ReadmeDialog
detail={currentPanel.detail}
open
onOpenChange={onOpenChange}
triggerId={currentPanel.triggerId}
/>
)
}
if (error) {
return (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
</div>
)
}
if (readmeData?.readme) {
return (
<Markdown
content={readmeData.readme}
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
/>
)
}
return (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
</div>
)
})()}
</div>
</div>
)
const portalContent = showType === ReadmeShowType.drawer
? (
<div className="fixed inset-0 z-1002 flex justify-start" onClick={onClose}>
<div
className={cn(
'pointer-events-auto mt-16 mr-2 mb-2 ml-2 w-[600px] max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
{children}
</div>
</div>
)
: (
<div className="fixed inset-0 z-1002 flex items-center justify-center p-2" onClick={onClose}>
<div
className={cn(
'pointer-events-auto relative h-[calc(100vh-16px)] w-full max-w-[800px] rounded-2xl bg-components-panel-bg p-0 shadow-xl',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
{children}
</div>
</div>
)
return createPortal(
portalContent,
document.body,
return (
<ReadmeDrawer
detail={currentPanel.detail}
open
onOpenChange={onOpenChange}
triggerId={currentPanel.triggerId}
/>
)
}
export default ReadmePanel

View File

@ -1,27 +1,34 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { create } from 'zustand'
export enum ReadmeShowType {
drawer = 'drawer',
modal = 'modal',
export type ReadmePanelPresentation = 'drawer' | 'dialog'
type ReadmePanelState = {
detail: PluginDetail
presentation: ReadmePanelPresentation
triggerId?: string
}
type OpenReadmePanelPayload = {
detail: PluginDetail
presentation?: ReadmePanelPresentation
triggerId?: string
}
type Shape = {
currentPluginDetail?: {
detail: PluginDetail
showType: ReadmeShowType
}
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => void
currentPanel?: ReadmePanelState
openReadmePanel: (payload: OpenReadmePanelPayload) => void
closeReadmePanel: () => void
}
export const useReadmePanelStore = create<Shape>(set => ({
currentPluginDetail: undefined,
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => set({
currentPluginDetail: !detail
? undefined
: {
detail,
showType: showType ?? ReadmeShowType.drawer,
},
currentPanel: undefined,
openReadmePanel: ({ detail, presentation = 'drawer', triggerId }) => set({
currentPanel: {
detail,
presentation,
triggerId,
},
}),
closeReadmePanel: () => set({ currentPanel: undefined }),
}))

View File

@ -68,13 +68,13 @@ const MenuDropdown: FC<Props> = ({
onOpenChange={setOpen}
>
<DropdownMenuTrigger
render={<div />}
render={(
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
</ActionButton>
)}
aria-label={t('operation.more', { ns: 'common' })}
>
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
</ActionButton>
</DropdownMenuTrigger>
/>
<DropdownMenuContent
placement={placement || 'bottom-end'}
sideOffset={4}

View File

@ -34,12 +34,28 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const PopoverTrigger = ({
children,
className,
render,
}: {
children?: React.ReactNode
className?: string
render?: React.ReactNode
}) => {
const { open, setOpen } = React.useContext(PopoverContext)
if (render) {
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
<button type="button" className={className} onClick={() => setOpen(!open)}>
{children}
</button>
)
}
@ -119,6 +135,12 @@ describe('LabelSelector', () => {
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
})
it('should render the trigger as a native button', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByRole('button', { name: 'tools.createTool.toolInput.labelPlaceholder' })).toHaveAttribute('type', 'button')
})
it('should display selected labels as comma-separated list', () => {
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)

View File

@ -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<LabelSelectorProps> = ({
<Popover open={open} onOpenChange={setOpen}>
<div className="relative">
<PopoverTrigger
render={(
<div 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 hover:bg-components-input-bg-hover',
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
)}
>
<div title={value.length > 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}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
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',
)}
/>
>
<div title={value.length > 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}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<span className="i-ri-arrow-down-s-line h-4 w-4" />
</div>
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={4}

View File

@ -133,8 +133,8 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
}))
vi.mock('@/app/components/tools/workflow-tool', () => ({
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-modal">
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-drawer">
<button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button>
<button data-testid="wf-remove" onClick={onRemove}>Remove</button>
<button data-testid="wf-close" onClick={onHide}>Close</button>
@ -581,7 +581,7 @@ describe('ProviderDetail', () => {
})
})
it('saves workflow tool via workflow modal', async () => {
it('saves workflow tool via workflow drawer', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
@ -593,7 +593,7 @@ describe('ProviderDetail', () => {
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(
<ProviderDetail
@ -665,7 +665,7 @@ describe('ProviderDetail', () => {
expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument()
})
it('closes WorkflowToolModal via onHide', async () => {
it('closes WorkflowToolDrawer via onHide', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
@ -677,9 +677,9 @@ describe('ProviderDetail', () => {
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()
})
})

View File

@ -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 = ({
</Button>
<Button
className={cn('my-3 w-[183px] shrink-0')}
onClick={() => setIsShowEditWorkflowToolModal(true)}
onClick={() => setWorkflowToolDrawerOpen(true)}
disabled={!isCurrentWorkspaceManager}
>
<div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div>
@ -401,10 +401,10 @@ const ProviderDetail = ({
onRemove={onClickCustomToolDelete}
/>
)}
{isShowEditWorkflowToolModal && (
<WorkflowToolModal
payload={customCollection as unknown as WorkflowToolModalPayload}
onHide={() => setIsShowEditWorkflowToolModal(false)}
{workflowToolDrawerOpen && (
<WorkflowToolDrawer
payload={customCollection as unknown as WorkflowToolDrawerPayload}
onHide={() => setWorkflowToolDrawerOpen(false)}
onRemove={onClickWorkflowToolDelete}
onSave={updateWorkflowToolProvider}
/>

View File

@ -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
? (
<div data-testid="drawer" role="dialog">
<span>{title}</span>
<button data-testid="drawer-close" onClick={onHide}>Close</button>
{body}
</div>
)
: 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
}) => (
<div>
{children}
@ -86,7 +73,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): WorkflowToolModalPayload => ({
const createPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): WorkflowToolDrawerPayload => ({
icon: { content: '🔧', background: '#ffffff' },
label: 'My Tool',
name: 'my_tool',
@ -105,7 +92,7 @@ const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): Workf
...overrides,
})
describe('WorkflowToolAsModal', () => {
describe('WorkflowToolDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@ -115,7 +102,7 @@ describe('WorkflowToolAsModal', () => {
const onCreate = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload()}
onHide={vi.fn()}
@ -144,7 +131,7 @@ describe('WorkflowToolAsModal', () => {
const onCreate = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload({ name: 'bad-name' })}
onHide={vi.fn()}
@ -165,7 +152,7 @@ describe('WorkflowToolAsModal', () => {
const onSave = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
payload={createPayload()}
onHide={vi.fn()}
onSave={onSave}
@ -187,7 +174,7 @@ describe('WorkflowToolAsModal', () => {
it('should show duplicate reserved output warnings', () => {
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload()}
onHide={vi.fn()}

View File

@ -1,70 +1,33 @@
'use client'
import type { Emoji } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator'
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
import { useRouter } from '@/next/navigation'
import Divider from '../../base/divider'
import { useConfigureButton } from './hooks/use-configure-button'
type Props = {
disabled: boolean
published: boolean
detailNeedUpdate: boolean
workflowAppId: string
icon: Emoji
name: string
description: string
inputs?: InputVar[]
outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
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 = ({
? (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={() => !disabled && !published && openModal()}
onClick={() => {
if (!disabled && !published)
onConfigure()
}}
>
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
<span className={cn('relative i-ri-hammer-line h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className={cn('shrink grow basis-0 truncate system-sm-medium text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')}
@ -100,7 +66,7 @@ const WorkflowToolConfigureButton = ({
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
>
<RiHammerLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-hammer-line h-4 w-4 text-text-tertiary" />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="shrink grow basis-0 truncate system-sm-medium text-text-tertiary"
@ -120,7 +86,7 @@ const WorkflowToolConfigureButton = ({
<Button
size="small"
className="w-[140px]"
onClick={openModal}
onClick={onConfigure}
disabled={!isCurrentWorkspaceManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
@ -129,11 +95,11 @@ const WorkflowToolConfigureButton = ({
<Button
size="small"
className="w-[140px]"
onClick={navigateToTools}
onClick={() => router.push('/tools?category=workflow')}
disabled={disabled}
>
{t('common.manageInTools', { ns: 'workflow' })}
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
<span className="ml-1 i-ri-arrow-right-up-line h-4 w-4" />
</Button>
</div>
{outdated && (
@ -146,15 +112,6 @@ const WorkflowToolConfigureButton = ({
</div>
)}
{published && isLoading && <div className="pt-2"><Loading type="app" /></div>}
{showModal && (
<WorkflowToolModal
isAdd={!published}
payload={payload}
onHide={closeModal}
onCreate={handleCreate}
onSave={handleUpdate}
/>
)}
</>
)
}

View File

@ -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<WorkflowToolProviderResponse> = {})
})
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

View File

@ -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<void>
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,
}
}

View File

@ -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<NonNullable<DrawerRootProps['onOpenChange']>>((open) => {
if (!open)
onHide()
}, [onHide])
return (
<Dialog open disablePointerDismissal>
<DialogContent
className={cn(
'top-2 right-2 bottom-2 left-auto h-[calc(100dvh-16px)] max-h-[calc(100dvh-16px)] w-[640px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! overflow-hidden rounded-xl border-none bg-transparent p-0 shadow-none',
'data-ending-style:translate-x-4 data-ending-style:scale-100 data-starting-style:translate-x-4 data-starting-style:scale-100',
)}
backdropClassName="bg-background-overlay"
>
<div data-testid="drawer" className="flex h-full w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl">
<div className="shrink-0 border-b border-divider-subtle py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DialogTitle data-testid="drawer-title" className="system-xl-semibold text-text-primary">
{title}
</DialogTitle>
<button
type="button"
data-testid="drawer-close"
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover"
aria-label="Close"
onClick={onHide}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
</div>
</div>
<div className="grow overflow-hidden">
{children}
</div>
</div>
</DialogContent>
</Dialog>
<Drawer open modal disablePointerDismissal swipeDirection="right" onOpenChange={handleOpenChange}>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup
data-testid="drawer"
className={cn(
'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-160 data-[swipe-direction=right]:max-w-[calc(100vw-16px)]',
'data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle',
)}
>
<DrawerContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-0 pb-0">
<div className="shrink-0 border-b border-divider-subtle py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DrawerTitle data-testid="drawer-title" className="min-w-0 truncate system-xl-semibold text-text-primary">
{title}
</DrawerTitle>
<DrawerCloseButton
data-testid="drawer-close"
className="h-6 w-6 rounded-md"
aria-label={closeLabel}
/>
</div>
</div>
<div className="grow overflow-hidden">
{children}
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
@ -158,15 +175,14 @@ const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerP
)
}
// Add and Edit
const WorkflowToolAsModal: FC<Props> = ({
export function WorkflowToolDrawer({
isAdd,
payload,
onHide,
onRemove,
onSave,
onCreate,
}) => {
}: WorkflowToolDrawerProps) {
const { t } = useTranslation()
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false)
@ -200,7 +216,7 @@ const WorkflowToolAsModal: FC<Props> = ({
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<Props> = ({
return (
<>
<WorkflowToolDrawer
<WorkflowToolDrawerFrame
onHide={onHide}
title={t('common.workflowAsTool', { ns: 'workflow' })!}
closeLabel={t('operation.close', { ns: 'common' })!}
>
<div className="flex h-full flex-col">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
@ -427,7 +444,7 @@ const WorkflowToolAsModal: FC<Props> = ({
if (isAdd)
onConfirm()
else
setShowModal(true)
setConfirmModalOpen(true)
}}
>
{t('operation.save', { ns: 'common' })}
@ -435,7 +452,7 @@ const WorkflowToolAsModal: FC<Props> = ({
</div>
</div>
</div>
</WorkflowToolDrawer>
</WorkflowToolDrawerFrame>
{showEmojiPicker && (
<WorkflowToolEmojiPicker
onSelect={(icon, icon_background) => {
@ -447,10 +464,10 @@ const WorkflowToolAsModal: FC<Props> = ({
}}
/>
)}
{showModal && (
{confirmModalOpen && (
<ConfirmModal
show={showModal}
onClose={() => setShowModal(false)}
show={confirmModalOpen}
onClose={() => setConfirmModalOpen(false)}
onConfirm={onConfirm}
/>
)}
@ -458,4 +475,3 @@ const WorkflowToolAsModal: FC<Props> = ({
)
}
export default React.memo(WorkflowToolAsModal)

View File

@ -124,7 +124,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
publisher-publish
</button>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-1/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
publisher-publish-with-params
</button>
</div>

View File

@ -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,

View File

@ -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 = {

View File

@ -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<ChunkingMode | 'external', React.ComponentType<{ className: string }>> = {

8
web/types/dataset.ts Normal file
View File

@ -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]