diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 59ac466da8..b5e67df509 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -315,20 +315,12 @@ "count": 4 } }, - "web/app/components/app/configuration/config-var/config-modal/type-select.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config-var/index.tsx": { "no-restricted-imports": { "count": 1 } }, "web/app/components/app/configuration/config-var/select-var-type.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -363,9 +355,6 @@ } }, "web/app/components/app/configuration/config/assistant-type-picker/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -401,11 +390,6 @@ "count": 1 } }, - "web/app/components/app/configuration/config/automatic/version-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": { "no-restricted-imports": { "count": 1 @@ -774,11 +758,6 @@ "count": 1 } }, - "web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/chat/chat/answer/agent-content.tsx": { "style/multiline-ternary": { "count": 2 @@ -800,11 +779,6 @@ "count": 1 } }, - "web/app/components/base/chat/chat/answer/operation.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/base/chat/chat/answer/workflow-process.tsx": { "react/set-state-in-effect": { "count": 1 @@ -1055,14 +1029,6 @@ "count": 3 } }, - "web/app/components/base/form/components/base/base-field.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/base/form/components/base/base-form.tsx": { "ts/no-explicit-any": { "count": 6 @@ -1589,14 +1555,6 @@ "count": 1 } }, - "web/app/components/base/modal/modal.stories.tsx": { - "no-console": { - "count": 4 - }, - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/new-audio-button/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2116,11 +2074,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2389,11 +2342,6 @@ "count": 1 } }, - "web/app/components/datasets/settings/index-method/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/develop/code.tsx": { "ts/no-empty-object-type": { "count": 1 @@ -2609,11 +2557,6 @@ "count": 1 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/model-provider-page/model-auth/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 6 @@ -2909,44 +2852,11 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts": { - "erasable-syntax-only/enums": { - "count": 2 - } - }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": { - "no-barrel-files/no-barrel-files": { - "count": 3 - } - }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/subscription-list/create/types.ts": { "erasable-syntax-only/enums": { "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -2975,11 +2885,6 @@ "count": 7 } }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": { "no-restricted-imports": { "count": 1 @@ -2995,11 +2900,6 @@ "count": 5 } }, - "web/app/components/plugins/plugin-item/action.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-item/index.tsx": { "no-restricted-imports": { "count": 1 @@ -3678,11 +3578,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/error-handle/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -3779,11 +3674,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 @@ -4103,16 +3993,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/if-else/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -4148,11 +4028,6 @@ "count": 6 } }, - "web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx": { "ts/no-explicit-any": { "count": 4 @@ -4196,11 +4071,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-retrieval/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -4282,11 +4152,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts": { "ts/no-explicit-any": { "count": 1 @@ -4338,16 +4203,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/loop/components/condition-number-input.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx": { "ts/no-explicit-any": { "count": 3 diff --git a/eslint.config.mjs b/eslint.config.mjs index ae9fdaff01..1380ed67d2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,17 @@ import antfu, { GLOB_MARKDOWN } from '@antfu/eslint-config' import md from 'eslint-markdown' import markdownPreferences from 'eslint-plugin-markdown-preferences' +const GENERATED_IGNORES = [ + '**/storybook-static/', + '**/.next/', + 'web/next/', + 'web/next-env.d.ts', + '**/dist/', + '**/coverage/', + 'e2e/.auth/', + 'e2e/cucumber-report/', +] + export default antfu( { ignores: original => [ @@ -15,6 +26,7 @@ export default antfu( '!package.json', '!pnpm-workspace.yaml', '!vite.config.ts', + ...GENERATED_IGNORES, ...original, ], typescript: { diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 41e99d0952..bdeeec33cb 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -88,9 +88,9 @@ Every overlay primitive uses a single, shared z-index. Do **not** override it at | 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. | -Rationale: during Dify's migration from legacy `portal-to-follow-elem` / `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` 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 and the remaining legacy allowlist. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`. +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 diff --git a/web/AGENTS.md b/web/AGENTS.md index 5e9f7ed11c..dc72a293d1 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -5,9 +5,9 @@ ## Overlay Components (Mandatory) - `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-1002` / `z-1003` layering. -- `./docs/overlay-migration.md` is the source of truth for the ongoing migration (deprecated import paths, allowlist, coexistence rules). +- `./docs/overlay-migration.md` is the source of truth for the ongoing migration (deprecated import paths and coexistence rules). - In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`. -- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding). +- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them. ## Query & Mutation (Mandatory) diff --git a/web/__mocks__/base-ui-dropdown-menu.tsx b/web/__mocks__/base-ui-dropdown-menu.tsx new file mode 100644 index 0000000000..9e2bfa2d41 --- /dev/null +++ b/web/__mocks__/base-ui-dropdown-menu.tsx @@ -0,0 +1,172 @@ +import type { ReactNode } from 'react' +import * as React from 'react' + +const DropdownMenuContext = React.createContext({ + open: false, + onOpenChange: (_open: boolean) => {}, +}) + +type DropdownMenuProps = { + children?: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +type DropdownMenuTriggerProps = React.HTMLAttributes & { + children?: ReactNode + nativeButton?: boolean + render?: React.ReactElement +} + +type DropdownMenuContentProps = React.HTMLAttributes & { + children?: ReactNode + placement?: string + sideOffset?: number + alignOffset?: number + popupClassName?: string +} + +export const DropdownMenu = ({ + children, + open, + onOpenChange, +}: DropdownMenuProps) => { + const [localOpen, setLocalOpen] = React.useState(false) + const resolvedOpen = open ?? localOpen + const handleOpenChange = React.useCallback((nextOpen: boolean) => { + setLocalOpen(nextOpen) + onOpenChange?.(nextOpen) + }, [onOpenChange]) + + return ( + +
+ {children} +
+
+ ) +} + +export const DropdownMenuTrigger = ({ + children, + render, + nativeButton: _nativeButton, + onClick, + ...props +}: DropdownMenuTriggerProps) => { + const { open, onOpenChange } = React.useContext(DropdownMenuContext) + const node = render ?? children + const isNativeButton = React.isValidElement(node) && node.type === 'button' + const handleClick = (event: React.MouseEvent) => { + onClick?.(event) + if (!event.defaultPrevented) + onOpenChange(!open) + } + + if (React.isValidElement(node)) { + const triggerElement = node as React.ReactElement> + const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes & { 'data-testid'?: string } + const triggerProps = props as React.HTMLAttributes & { 'data-testid'?: string } + const role = childProps.role ?? triggerProps.role ?? (!isNativeButton && (childProps['aria-label'] || triggerProps['aria-label']) ? 'button' : undefined) + return React.cloneElement(triggerElement, { + ...props, + ...childProps, + 'data-testid': childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'dropdown-menu-trigger', + role, + 'tabIndex': childProps.tabIndex ?? triggerProps.tabIndex ?? (role === 'button' ? 0 : undefined), + 'onClick': (event: React.MouseEvent) => { + childProps.onClick?.(event) + handleClick(event) + }, + }, render ? (children ?? childProps.children) : childProps.children) + } + + return ( +
+ {node} +
+ ) +} + +export const DropdownMenuContent = ({ + children, + className, + popupClassName, + placement, + sideOffset, + alignOffset, + ...props +}: DropdownMenuContentProps) => { + const { open } = React.useContext(DropdownMenuContext) + if (!open) + return null + + return ( +
+ {children} +
+ ) +} + +export const DropdownMenuItem = ({ + children, + onClick, + ...props +}: React.HTMLAttributes & { children?: ReactNode }) => ( +
+ {children} +
+) + +export const DropdownMenuRadioGroup = ({ + children, + onValueChange, + ...props +}: React.HTMLAttributes & { children?: ReactNode, value?: unknown, onValueChange?: (value: unknown) => void }) => ( +
+ {React.Children.map(children, (child) => { + if (!React.isValidElement(child)) + return child + return React.cloneElement(child as React.ReactElement<{ __onValueChange?: (value: unknown) => void }>, { __onValueChange: onValueChange }) + })} +
+) + +export const DropdownMenuRadioItem = ({ + children, + value, + onClick, + __onValueChange, + ...props +}: React.HTMLAttributes & { children?: ReactNode, value?: unknown, __onValueChange?: (value: unknown) => void }) => ( +
{ + onClick?.(event) + __onValueChange?.(value) + }} + {...props} + > + {children} +
+) + +export const DropdownMenuRadioItemIndicator = ({ children }: { children?: ReactNode }) => <>{children} +export const DropdownMenuCheckboxItem = DropdownMenuItem +export const DropdownMenuCheckboxItemIndicator = ({ children }: { children?: ReactNode }) => <>{children} +export const DropdownMenuLabel = ({ children }: { children?: ReactNode }) => <>{children} +export const DropdownMenuSeparator = (props: React.HTMLAttributes) =>
+export const DropdownMenuSub = ({ children }: { children?: ReactNode }) => <>{children} +export const DropdownMenuSubTrigger = DropdownMenuItem +export const DropdownMenuSubContent = ({ children }: { children?: ReactNode }) => <>{children} diff --git a/web/__mocks__/base-ui-popover.tsx b/web/__mocks__/base-ui-popover.tsx index 8818f60f4e..c4d7a23827 100644 --- a/web/__mocks__/base-ui-popover.tsx +++ b/web/__mocks__/base-ui-popover.tsx @@ -23,17 +23,25 @@ type PopoverContentProps = React.HTMLAttributes & { placement?: string sideOffset?: number alignOffset?: number + popupClassName?: string positionerProps?: React.HTMLAttributes popupProps?: React.HTMLAttributes } export const Popover = ({ children, - open = false, + open, onOpenChange, }: PopoverProps) => { + const [localOpen, setLocalOpen] = React.useState(false) + const resolvedOpen = open ?? localOpen + const handleOpenChange = React.useCallback((nextOpen: boolean) => { + setLocalOpen(nextOpen) + onOpenChange?.(nextOpen) + }, [onOpenChange]) + React.useEffect(() => { - if (!open) + if (!resolvedOpen) return const handleMouseDown = (event: MouseEvent) => { @@ -41,12 +49,12 @@ export const Popover = ({ if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]')) return - onOpenChange?.(false) + handleOpenChange(false) } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') - onOpenChange?.(false) + handleOpenChange(false) } document.addEventListener('mousedown', handleMouseDown) @@ -56,15 +64,15 @@ export const Popover = ({ document.removeEventListener('mousedown', handleMouseDown) document.removeEventListener('keydown', handleKeyDown) } - }, [open, onOpenChange]) + }, [resolvedOpen, handleOpenChange]) return ( {}), + open: resolvedOpen, + onOpenChange: handleOpenChange, }} > -
+
{children}
@@ -84,11 +92,12 @@ export const PopoverTrigger = ({ if (React.isValidElement(node)) { const triggerElement = node as React.ReactElement> const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes & { 'data-testid'?: string } + const triggerProps = props as React.HTMLAttributes & { 'data-testid'?: string } return React.cloneElement(triggerElement, { ...props, ...childProps, - 'data-testid': childProps['data-testid'] ?? 'popover-trigger', + 'data-testid': childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'popover-trigger', 'data-popover-trigger': 'true', 'onClick': (event: React.MouseEvent) => { childProps.onClick?.(event) @@ -97,7 +106,7 @@ export const PopoverTrigger = ({ return onOpenChange(!open) }, - }) + }, render ? (children ?? childProps.children) : childProps.children) } return ( @@ -123,6 +132,7 @@ export const PopoverContent = ({ placement, sideOffset, alignOffset, + popupClassName, positionerProps, popupProps, ...props @@ -139,7 +149,7 @@ export const PopoverContent = ({ data-placement={placement} data-side-offset={sideOffset} data-align-offset={alignOffset} - className={className} + className={className || popupClassName} {...positionerProps} {...popupProps} {...props} diff --git a/web/__mocks__/base-ui-select.tsx b/web/__mocks__/base-ui-select.tsx new file mode 100644 index 0000000000..7655164419 --- /dev/null +++ b/web/__mocks__/base-ui-select.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react' +import * as React from 'react' + +const SelectContext = React.createContext({ + value: undefined as unknown, + onValueChange: (_value: unknown) => {}, +}) + +type SelectProps = { + children?: ReactNode + value?: unknown + onValueChange?: (value: unknown) => void +} + +export const Select = ({ + children, + value, + onValueChange, +}: SelectProps) => ( + {}) }}> +
{children}
+
+) + +export const SelectTrigger = ({ + children, + ...props +}: React.ButtonHTMLAttributes & { children?: ReactNode }) => ( + +) + +export const SelectValue = ({ placeholder }: { placeholder?: ReactNode }) => <>{placeholder} + +export const SelectContent = ({ children }: { children?: ReactNode }) => ( +
{children}
+) + +export const SelectItem = ({ + children, + value, + onClick, + ...props +}: React.HTMLAttributes & { children?: ReactNode, value?: unknown }) => { + const select = React.useContext(SelectContext) + return ( +
{ + onClick?.(event) + select.onValueChange(value) + }} + {...props} + > + {children} +
+ ) +} + +export const SelectItemText = ({ children }: { children?: ReactNode }) => <>{children} +export const SelectItemIndicator = ({ children }: { children?: ReactNode }) => <>{children} +export const SelectGroup = ({ children }: { children?: ReactNode }) => <>{children} +export const SelectLabel = ({ children }: { children?: ReactNode }) => <>{children} +export const SelectSeparator = (props: React.HTMLAttributes) =>
diff --git a/web/__mocks__/base-ui-tooltip.tsx b/web/__mocks__/base-ui-tooltip.tsx new file mode 100644 index 0000000000..23e05f0864 --- /dev/null +++ b/web/__mocks__/base-ui-tooltip.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from 'react' +import * as React from 'react' + +const TooltipContext = React.createContext({ + open: false, + onOpenChange: (_open: boolean) => {}, +}) + +type TooltipProps = { + children?: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export const Tooltip = ({ children, open, onOpenChange }: TooltipProps) => { + const [localOpen, setLocalOpen] = React.useState(false) + const resolvedOpen = open ?? localOpen + const handleOpenChange = React.useCallback((nextOpen: boolean) => { + setLocalOpen(nextOpen) + onOpenChange?.(nextOpen) + }, [onOpenChange]) + + return ( + + {children} + + ) +} + +export const TooltipTrigger = ({ + children, + render, + nativeButton: _nativeButton, + ...props +}: React.HTMLAttributes & { children?: ReactNode, render?: React.ReactElement, nativeButton?: boolean }) => { + const { open, onOpenChange } = React.useContext(TooltipContext) + const node = render ?? children + + if (React.isValidElement(node)) { + const triggerElement = node as React.ReactElement> + const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes + + return React.cloneElement(triggerElement, { + ...props, + ...childProps, + onMouseEnter: (event: React.MouseEvent) => { + childProps.onMouseEnter?.(event) + props.onMouseEnter?.(event) + onOpenChange(true) + }, + onMouseLeave: (event: React.MouseEvent) => { + childProps.onMouseLeave?.(event) + props.onMouseLeave?.(event) + onOpenChange(false) + }, + onClick: (event: React.MouseEvent) => { + childProps.onClick?.(event) + props.onClick?.(event) + onOpenChange(!open) + }, + }) + } + + return ( + { + props.onMouseEnter?.(event) + onOpenChange(true) + }} + onMouseLeave={(event) => { + props.onMouseLeave?.(event) + onOpenChange(false) + }} + onClick={(event) => { + props.onClick?.(event) + onOpenChange(!open) + }} + > + {node} + + ) +} + +export const TooltipContent = ({ + children, + ...props +}: React.HTMLAttributes & { children?: ReactNode }) => { + const { open } = React.useContext(TooltipContext) + if (!open) + return null + return
{children}
+} + +export const TooltipProvider = ({ children }: { children?: ReactNode }) => <>{children} diff --git a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx index a7c660105d..ef765c06f2 100644 --- a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx +++ b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx @@ -95,37 +95,8 @@ vi.mock('@/app/components/workflow/utils', () => ({ getKeyboardKeyNameBySystem: (key: string) => key, })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { - const React = await vi.importActual('react') - const OpenContext = React.createContext(false) - - return { - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( - -
{children}
-
- ), - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick?: () => void - }) => ( - - ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { - const open = React.useContext(OpenContext) - return open ?
{children}
: null - }, - } -}) - -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children?: React.ReactNode }) => <>{children}, -})) +vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu')) +vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip')) vi.mock('@/app/components/app-sidebar/app-info', () => ({ default: ({ diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index d4bf56e7e4..ff2e3f6f44 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -122,33 +122,7 @@ vi.mock('@/app/components/app/app-access-control', () => ({ default: () =>
, })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { - const React = await vi.importActual('react') - const OpenContext = React.createContext(false) - - return { - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( - -
{children}
-
- ), - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick?: () => void - }) => ( -
- {children} -
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { - const open = React.useContext(OpenContext) - return open ?
{children}
: null - }, - } -}) +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('@/app/components/workflow/utils', () => ({ getKeyboardKeyCodeBySystem: () => 'ctrl', diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 5df331767b..cbfd679ace 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -121,25 +121,7 @@ vi.mock('../../app-access-control', () => ({ ), })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { - const ReactModule = await vi.importActual('react') - const OpenContext = ReactModule.createContext(false) - - return { - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( - -
{children}
-
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { - const open = ReactModule.useContext(OpenContext) - return open ?
{children}
: null - }, - } -}) +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('../sections', () => ({ PublisherSummarySection: (props: Record) => { diff --git a/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx index 611aaa1c8a..42d0de5ed7 100644 --- a/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx @@ -1,8 +1,8 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import SelectVarType from '../select-var-type' describe('SelectVarType', () => { - it('should open the menu and return the selected variable type', () => { + it('should open the menu and return the selected variable type', async () => { const onChange = vi.fn() render() @@ -11,6 +11,8 @@ describe('SelectVarType', () => { fireEvent.click(screen.getByText('appDebug.variableConfig.checkbox')) expect(onChange).toHaveBeenCalledWith('checkbox') - expect(screen.queryByText('appDebug.variableConfig.checkbox')).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByText('appDebug.variableConfig.checkbox')).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx index 2512aa93e8..f1356c9b61 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx @@ -2,13 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import TypeSelector from '../type-select' -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children }: { children: React.ReactNode }) =>
{children}
, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( - - ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) +vi.mock('@langgenius/dify-ui/select', () => import('@/__mocks__/base-ui-select')) vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({ default: ({ type }: { type: string }) => {type}, diff --git a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx index 5fd7c88b82..acd3253f6b 100644 --- a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx @@ -1,16 +1,17 @@ 'use client' import type { FC } from 'react' import type { InputVarType } from '@/app/components/workflow/types' -import { ChevronDownIcon } from '@heroicons/react/20/solid' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useState } from 'react' -import Badge from '@/app/components/base/badge' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import * as React from 'react' +import Badge from '@/app/components/base/badge' import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon' import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' @@ -35,21 +36,26 @@ const TypeSelector: FC = ({ popupInnerClassName, readonly, }) => { - const [open, setOpen] = useState(false) const selectedItem = value ? items.find(item => item.value === value) : undefined return ( - { + const selected = items.find(item => item.value === nextValue) + if (selected) + onSelect(selected) + }} > - !readonly && setOpen(v => !v)} className="w-full"> -
+ +
= ({ {selectedItem?.name}
-
+
{inputVarTypeToVarType(selectedItem?.value as InputVarType)} -
- - - -
- {items.map((item: Item) => ( -
{ - onSelect(item) - setOpen(false) - }} + + + {items.map((item: Item) => ( + + -
- - {item.name} -
- {inputVarTypeToVarType(item.value)} -
- ))} -
-
- + + {item.name} + + {inputVarTypeToVarType(item.value)} + + + ))} + + ) } diff --git a/web/app/components/app/configuration/config-var/select-var-type.tsx b/web/app/components/app/configuration/config-var/select-var-type.tsx index fc59bcd54b..4d3a96775c 100644 --- a/web/app/components/app/configuration/config-var/select-var-type.tsx +++ b/web/app/components/app/configuration/config-var/select-var-type.tsx @@ -1,15 +1,16 @@ 'use client' import type { FC } from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' -import { useState } from 'react' import { useTranslation } from 'react-i18next' import OperationBtn from '@/app/components/app/configuration/base/operation-btn' import { ApiConnection } from '@/app/components/base/icons/src/vender/solid/development' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon' import { InputVarType } from '@/app/components/workflow/types' @@ -27,13 +28,14 @@ type ItemProps = { const SelectItem: FC = ({ text, type, value, Icon, onClick }) => { return ( -
onClick(value)} > {Icon ? : }
{text}
-
+ ) } @@ -41,40 +43,36 @@ const SelectVarType: FC = ({ onChange, }) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) const handleChange = (value: string) => { onChange(value) - setOpen(false) } return ( - - setOpen(v => !v)}> + + } + > - - -
-
- - - - - -
-
-
- -
+ + +
+ + + + +
- - + +
+ +
+
+ ) } export default React.memo(SelectVarType) diff --git a/web/app/components/app/configuration/config/assistant-type-picker/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/assistant-type-picker/__tests__/index.spec.tsx index b32f14d089..7b2d3414f6 100644 --- a/web/app/components/app/configuration/config/assistant-type-picker/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/assistant-type-picker/__tests__/index.spec.tsx @@ -841,11 +841,11 @@ describe('AssistantTypePicker', () => { it('should have proper ARIA state for dropdown', async () => { // Arrange const user = userEvent.setup() - const { container } = renderComponent() + renderComponent() // Act - Check initial state - const portalContainer = container.querySelector('[data-state]') - expect(portalContainer)!.toHaveAttribute('data-state', 'closed') + const triggerButton = screen.getByRole('button', { name: /chatAssistant\.name/i }) + expect(triggerButton).toHaveAttribute('aria-expanded', 'false') // Open dropdown const trigger = screen.getByText(/chatAssistant.name/i) @@ -853,23 +853,22 @@ describe('AssistantTypePicker', () => { // Assert - State should change to open await waitFor(() => { - const openPortal = container.querySelector('[data-state="open"]') - expect(openPortal)!.toBeInTheDocument() + expect(triggerButton).toHaveAttribute('aria-expanded', 'true') }) }) it('should have proper data-state attribute', () => { // Arrange & Act - const { container } = renderComponent() + renderComponent() - // Assert - Portal should have data-state for accessibility - const portalContainer = container.querySelector('[data-state]') - expect(portalContainer)!.toBeInTheDocument() - expect(portalContainer)!.toHaveAttribute('data-state') + // Assert - Trigger should expose expanded state for accessibility + const triggerButton = screen.getByRole('button', { name: /chatAssistant\.name/i }) + expect(triggerButton).toBeInTheDocument() + expect(triggerButton).toHaveAttribute('aria-expanded') // Should start in closed state // Should start in closed state - expect(portalContainer)!.toHaveAttribute('data-state', 'closed') + expect(triggerButton).toHaveAttribute('aria-expanded', 'false') }) it('should maintain accessible structure for screen readers', () => { diff --git a/web/app/components/app/configuration/config/assistant-type-picker/index.tsx b/web/app/components/app/configuration/config/assistant-type-picker/index.tsx index df0baa2f36..801645940f 100644 --- a/web/app/components/app/configuration/config/assistant-type-picker/index.tsx +++ b/web/app/components/app/configuration/config/assistant-type-picker/index.tsx @@ -2,6 +2,11 @@ import type { FC } from 'react' import type { AgentConfig } from '@/models/debug' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' @@ -10,11 +15,6 @@ import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows import { Settings04 } from '@/app/components/base/icons/src/vender/line/general' import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication' import { BubbleText } from '@/app/components/base/icons/src/vender/solid/education' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import Radio from '@/app/components/base/radio/ui' import AgentSetting from '../agent/agent-setting' @@ -107,47 +107,48 @@ const AssistantTypePicker: FC = ({ ) return ( <> - - setOpen(v => !v)}> -
- {isAgent ? : } -
{t(`assistantType.${isAgent ? 'agentAssistant' : 'chatAssistant'}.name`, { ns: 'appDebug' })}
- -
-
- -
-
{t('assistantType.name', { ns: 'appDebug' })}
- - - {!disabled && agentConfigUI} -
-
-
+ + )} + > + {isAgent ? : } +
{t(`assistantType.${isAgent ? 'agentAssistant' : 'chatAssistant'}.name`, { ns: 'appDebug' })}
+ +
+ +
{t('assistantType.name', { ns: 'appDebug' })}
+ + + {!disabled && agentConfigUI} +
+ {isShowAgentSetting && ( ({ @@ -25,7 +25,7 @@ describe('VersionSelector', () => { expect(onChange).not.toHaveBeenCalled() }) - it('should open the selector and switch versions when multiple versions exist', () => { + it('should open the selector and switch versions when multiple versions exist', async () => { const onChange = vi.fn() render( @@ -44,6 +44,8 @@ describe('VersionSelector', () => { fireEvent.click(screen.getByText('generate.version 1')) expect(onChange).toHaveBeenCalledWith(0) - expect(screen.queryByText('generate.versions')).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByText('generate.versions')).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/components/app/configuration/config/automatic/version-selector.tsx b/web/app/components/app/configuration/config/automatic/version-selector.tsx index 25846e9b54..13b99dafde 100644 --- a/web/app/components/app/configuration/config/automatic/version-selector.tsx +++ b/web/app/components/app/configuration/config/automatic/version-selector.tsx @@ -1,10 +1,16 @@ import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' type VersionSelectorProps = { versionLen: number @@ -16,19 +22,14 @@ const VersionSelector: React.FC = ({ versionLen, value, on const { t } = useTranslation() const [isOpen, { setFalse: handleOpenFalse, - toggle: handleOpenToggle, set: handleOpenSet, }] = useBoolean(false) const moreThanOneVersion = versionLen > 1 - const handleOpen = useCallback((value: boolean) => { + const handleOpen = useCallback((nextOpen: boolean) => { if (moreThanOneVersion) - handleOpenSet(value) - }, [moreThanOneVersion, handleOpenToggle]) - const handleToggle = useCallback(() => { - if (moreThanOneVersion) - handleOpenToggle() - }, [moreThanOneVersion, handleOpenToggle]) + handleOpenSet(nextOpen) + }, [moreThanOneVersion, handleOpenSet]) const versions = Array.from({ length: versionLen }, (_, index) => ({ label: `${t('generate.version', { ns: 'appDebug' })} ${index + 1}${index === versionLen - 1 ? ` · ${t('generate.latest', { ns: 'appDebug' })}` : ''}`, @@ -38,67 +39,59 @@ const VersionSelector: React.FC = ({ versionLen, value, on const isLatest = value === versionLen - 1 return ( - - + )} > - -
-
- {t('generate.version', { ns: 'appDebug' })} - {' '} - {value + 1} - {isLatest && ` · ${t('generate.latest', { ns: 'appDebug' })}`} -
- {moreThanOneVersion && } +
+ {t('generate.version', { ns: 'appDebug' })} + {' '} + {value + 1} + {isLatest && ` · ${t('generate.latest', { ns: 'appDebug' })}`}
- - } + + -
+ {t('generate.versions', { ns: 'appDebug' })} +
+ { + onChange(nextValue) + handleOpenFalse() + }} > -
- {t('generate.versions', { ns: 'appDebug' })} -
- { - versions.map(option => ( -
{ - onChange(option.value) - handleOpenFalse() - }} - > -
- {option.label} -
- { - value === option.value && - } + {versions.map(option => ( + +
+ {option.label}
- )) - } -
-
- + { + value === option.value && + } + + ))} + + + ) } diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx index e8b1583171..f8017c3585 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ +import type * as React from 'react' import type { Props } from '../var-picker' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' import ContextVar from '../index' // Mock external dependencies only @@ -76,57 +76,6 @@ vi.mock('@langgenius/dify-ui/popover', async () => { } }) -type PortalToFollowElemProps = { - children: React.ReactNode - open?: boolean - onOpenChange?: (open: boolean) => void -} -type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode, asChild?: boolean } -type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode } - -vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const PortalContext = React.createContext({ open: false }) - - const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => { - return ( - -
{children}
-
- ) - } - - const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => { - const { open } = React.useContext(PortalContext) - if (!open) - return null - return ( -
- {children} -
- ) - } - - const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => { - if (asChild && React.isValidElement(children)) { - return React.cloneElement(children, { - ...props, - 'data-testid': 'portal-trigger', - } as React.HTMLAttributes) - } - return ( -
- {children} -
- ) - } - - return { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, - } -}) - describe('ContextVar', () => { const mockOptions: Props['options'] = [ { name: 'Variable 1', value: 'var1', type: 'string' }, diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx index 7890343720..5b77134468 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx @@ -10,41 +10,41 @@ vi.mock('@/next/navigation', () => ({ usePathname: () => '/test', })) -type PortalToFollowElemProps = { +type PopoverProps = { children: React.ReactNode open?: boolean onOpenChange?: (open: boolean) => void } -type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode, asChild?: boolean } -type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode } +type PopoverTriggerProps = React.HTMLAttributes & { children?: React.ReactNode, asChild?: boolean } +type PopoverContentProps = React.HTMLAttributes & { children?: React.ReactNode } vi.mock('@langgenius/dify-ui/popover', () => { - const PortalContext = React.createContext({ + const PopoverContext = React.createContext({ open: false, onOpenChange: undefined as ((open: boolean) => void) | undefined, }) - const Popover = ({ children, open, onOpenChange }: PortalToFollowElemProps) => { + const Popover = ({ children, open, onOpenChange }: PopoverProps) => { return ( - -
{children}
-
+ +
{children}
+
) } - const PopoverContent = ({ children, ...props }: PortalToFollowElemContentProps) => { - const { open } = React.useContext(PortalContext) + const PopoverContent = ({ children, ...props }: PopoverContentProps) => { + const { open } = React.useContext(PopoverContext) if (!open) return null return ( -
+
{children}
) } - const PopoverTrigger = ({ children, asChild, render, ...props }: PortalToFollowElemTriggerProps & { render?: React.ReactNode }) => { - const { open, onOpenChange } = React.useContext(PortalContext) + const PopoverTrigger = ({ children, asChild, render, ...props }: PopoverTriggerProps & { render?: React.ReactNode }) => { + const { open, onOpenChange } = React.useContext(PopoverContext) const content = render ?? children const handleClick = (e: React.MouseEvent) => { props.onClick?.(e) @@ -56,7 +56,7 @@ vi.mock('@langgenius/dify-ui/popover', () => { return React.cloneElement(content, { ...props, 'onClick': handleClick, - 'data-testid': 'portal-trigger', + 'data-testid': 'popover-trigger', } as React.HTMLAttributes) } @@ -64,11 +64,11 @@ vi.mock('@langgenius/dify-ui/popover', () => { return React.cloneElement(children, { ...props, 'onClick': handleClick, - 'data-testid': 'portal-trigger', + 'data-testid': 'popover-trigger', } as React.HTMLAttributes) } return ( -
+
{content}
) @@ -109,7 +109,7 @@ describe('VarPicker', () => { // Assert // Assert - expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() expect(screen.getByText('var1'))!.toBeInTheDocument() }) @@ -201,7 +201,7 @@ describe('VarPicker', () => { // Assert - Trigger should be present // Assert - Trigger should be present - expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() }) }) @@ -234,7 +234,7 @@ describe('VarPicker', () => { // Assert // Assert - expect(screen.getByTestId('portal-trigger'))!.toHaveClass('custom-trigger-class') + expect(screen.getByTestId('popover-trigger'))!.toHaveClass('custom-trigger-class') }) it('should display selected value with proper formatting', () => { @@ -268,11 +268,11 @@ describe('VarPicker', () => { // Act render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByTestId('popover-trigger')) // Assert // Assert - expect(screen.getByTestId('portal-content'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() }) it('should call onChange and close dropdown when selecting an option', async () => { @@ -285,8 +285,8 @@ describe('VarPicker', () => { render() // Open dropdown - await user.click(screen.getByTestId('portal-trigger')) - expect(screen.getByTestId('portal-content'))!.toBeInTheDocument() + await user.click(screen.getByTestId('popover-trigger')) + expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() // Select a different option const options = screen.getAllByText('var2') @@ -295,7 +295,7 @@ describe('VarPicker', () => { // Assert expect(onChange).toHaveBeenCalledWith('var2') - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) it('should toggle dropdown when clicking trigger button multiple times', async () => { @@ -306,15 +306,15 @@ describe('VarPicker', () => { // Act render() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') // Open dropdown await user.click(trigger) - expect(screen.getByTestId('portal-content'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() // Close dropdown await user.click(trigger) - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) }) @@ -359,7 +359,7 @@ describe('VarPicker', () => { // Assert // Assert // Assert - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) it('should toggle dropdown state on trigger click', async () => { @@ -370,16 +370,16 @@ describe('VarPicker', () => { // Act render() - const trigger = screen.getByTestId('portal-trigger') - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + const trigger = screen.getByTestId('popover-trigger') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() // Open dropdown await user.click(trigger) - expect(screen.getByTestId('portal-content'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() // Close dropdown await user.click(trigger) - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) it('should preserve selected value when dropdown is closed without selection', async () => { @@ -391,7 +391,7 @@ describe('VarPicker', () => { render() // Open and close dropdown without selecting anything - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') await user.click(trigger) await user.click(trigger) @@ -416,7 +416,7 @@ describe('VarPicker', () => { // Assert // Assert expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() - expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() }) it('should handle empty options array', () => { @@ -432,7 +432,7 @@ describe('VarPicker', () => { // Assert // Assert - expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() }) @@ -485,7 +485,7 @@ describe('VarPicker', () => { // Assert // Assert expect(screen.getByText('longVar'))!.toBeInTheDocument() - expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() }) }) }) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx index 4cbe4ce8d1..a54533b194 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx @@ -40,43 +40,24 @@ vi.mock('../../embedded-chatbot/theme/theme-context', () => ({ })), })) -// Mock PortalToFollowElem using React Context -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { - const React = await import('react') - const MockContext = React.createContext(false) +vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu')) +vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip')) - return { - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => { - return ( - -
{children}
-
- ) - }, - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { - const open = React.useContext(MockContext) - if (!open) - return null - return
{children}
- }, - PortalToFollowElemTrigger: ({ children, onClick, ...props }: { children: React.ReactNode, onClick: () => void } & React.HTMLAttributes) => ( -
{children}
- ), - } -}) - -// Mock Modal to avoid Headless UI issues in tests -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, isShow, title }: { children: React.ReactNode, isShow: boolean, title: React.ReactNode }) => { - if (!isShow) +// Mock Dialog to avoid Base UI focus/portal behavior in tests +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => { + if (!open) return null return ( -
- {!!title &&
{title}
} +
{children}
) }, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, })) // Sidebar mock removed to use real component diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx index b1c23a129b..b3f2f6fcb4 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx @@ -16,43 +16,24 @@ vi.mock('@/app/components/base/chat/chat-with-history/inputs-form/content', () = default: () =>
InputsFormContent
, })) -// Mock PortalToFollowElem using React Context -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { - const React = await import('react') - const MockContext = React.createContext(false) +vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu')) +vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip')) - return { - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => { - return ( - -
{children}
-
- ) - }, - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { - const open = React.useContext(MockContext) - if (!open) - return null - return
{children}
- }, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
- ), - } -}) - -// Mock Modal to avoid Headless UI issues in tests -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, isShow, title }: { children: React.ReactNode, isShow: boolean, title: React.ReactNode }) => { - if (!isShow) +// Mock Dialog to avoid Base UI focus/portal behavior in tests +vi.mock('@langgenius/dify-ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => { + if (!open) return null return (
- {!!title &&
{title}
} {children}
) }, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, })) const mockAppData: AppData = { diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx index 170f6d7fb5..33e25dbe01 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx @@ -490,7 +490,7 @@ describe('Sidebar Index', () => { render() await user.click(screen.getByTestId('rename-1')) - expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByText('common.chat.renameConversation')).toBeInTheDocument() }) it('should pass correct props to rename modal', async () => { @@ -499,7 +499,9 @@ describe('Sidebar Index', () => { await user.click(screen.getByTestId('rename-1')) // The modal should have title and save/cancel - expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByText('common.chat.renameConversation')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() }) it('should call handleRenameConversation with new name', async () => { @@ -531,13 +533,13 @@ describe('Sidebar Index', () => { render() await user.click(screen.getByTestId('rename-1')) - expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByText('common.chat.renameConversation')).toBeInTheDocument() const cancelButton = screen.getByText('common.operation.cancel') await user.click(cancelButton) await waitFor(() => { - expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + expect(screen.queryByText('common.chat.renameConversation')).not.toBeInTheDocument() }) }) @@ -882,8 +884,7 @@ describe('RenameModal', () => { />, ) - expect(screen.getByTestId('modal')).toBeInTheDocument() - expect(screen.getByTestId('modal-title')).toHaveTextContent('common.chat.renameConversation') + expect(screen.getByText('common.chat.renameConversation')).toBeInTheDocument() }) it('should handle empty placeholder translation fallback', () => { diff --git a/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx b/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx index 5cf981363c..54822659f2 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx @@ -1,11 +1,15 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogContent, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' type IRenameModalProps = { isShow: boolean @@ -27,24 +31,28 @@ const RenameModal: FC = ({ const conversationNamePlaceholder = t('chat.conversationNamePlaceholder', { ns: 'common' }) || '' return ( - !open && onClose()} > -
{t('chat.conversationName', { ns: 'common' })}
- setTempName(e.target.value)} - placeholder={conversationNamePlaceholder} - /> + + + {t('chat.renameConversation', { ns: 'common' })} + +
{t('chat.conversationName', { ns: 'common' })}
+ setTempName(e.target.value)} + placeholder={conversationNamePlaceholder} + /> -
- - -
-
+
+ + +
+ + ) } export default React.memo(RenameModal) diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index 9eaf5fe374..094c5c987b 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -441,9 +441,8 @@ describe('Operation', () => { renderOperation() const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! await user.click(thumbDown) - // Check if modal title/labels fallback works - // Check if modal title/labels fallback works - expect(screen.getByRole('tooltip'))!.toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Provide Feedback' }))!.toBeInTheDocument() + expect(screen.getByLabelText('Feedback Content'))!.toBeInTheDocument() mockT.mockImplementation(key => key) }) }) diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index e7d1c17a3e..6804271d64 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -1,13 +1,17 @@ -import type { FC } from 'react' +import type { ReactElement, ReactNode } from 'react' import type { ChatItem, Feedback, } from '../../types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import copy from 'copy-to-clipboard' import { memo, + useId, useMemo, useState, } from 'react' @@ -16,10 +20,8 @@ import EditReplyModal from '@/app/components/app/annotation/edit-annotation-moda import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import Log from '@/app/components/base/chat/chat/log' import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button' -import Modal from '@/app/components/base/modal/modal' import NewAudioButton from '@/app/components/base/new-audio-button' import Textarea from '@/app/components/base/textarea' -import Tooltip from '@/app/components/base/tooltip' import { useChatContext } from '../context' type OperationProps = { @@ -33,7 +35,25 @@ type OperationProps = { noChatInput?: boolean } -const Operation: FC = ({ +type FeedbackTooltipProps = { + content: ReactNode + children: ReactElement +} + +const feedbackTooltipClassName = 'max-w-[260px]' + +const FeedbackTooltip = ({ content, children }: FeedbackTooltipProps) => { + return ( + + + + {content} + + + ) +} + +function Operation({ item, question, index, @@ -42,7 +62,7 @@ const Operation: FC = ({ contentWidth, hasWorkflowProcess, noChatInput, -}) => { +}: OperationProps) { const { t } = useTranslation() const { config, @@ -68,8 +88,8 @@ const Operation: FC = ({ const [userLocalFeedback, setUserLocalFeedback] = useState(feedback) const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback) const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user') + const feedbackTextareaId = useId() - // Separate feedback types for display const userFeedback = feedback const content = useMemo(() => { @@ -89,7 +109,11 @@ const Operation: FC = ({ const userFeedbackLabel = t('table.header.userRate', { ns: 'appLog' }) || 'User feedback' const adminFeedbackLabel = t('table.header.adminRate', { ns: 'appLog' }) || 'Admin feedback' - const feedbackTooltipClassName = 'max-w-[260px]' + const likeLabel = t('detail.operation.like', { ns: 'appLog' }) || 'Like' + const dislikeLabel = t('detail.operation.dislike', { ns: 'appLog' }) || 'Dislike' + const removeFeedbackLabel = t('operation.remove', { ns: 'common' }) || 'Remove' + const copyLabel = t('operation.copy', { ns: 'common' }) || 'Copy' + const regenerateLabel = t('operation.regenerate', { ns: 'common' }) || 'Regenerate' const buildFeedbackTooltip = (feedbackData?: Feedback | null, label = userFeedbackLabel) => { if (!feedbackData?.rating) @@ -180,33 +204,35 @@ const Operation: FC = ({ > {hasUserFeedback ? ( - handleFeedback(null, undefined, 'user')} > {displayUserFeedback?.rating === 'like' - ?
- :
} + ?