refactor(web): portal to follow elem migration (#35892)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Coding On Star 2026-05-07 21:02:11 +08:00 committed by GitHub
parent c6a5de3c18
commit 9331024d91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
104 changed files with 2599 additions and 3364 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<HTMLElement> & {
children?: ReactNode
nativeButton?: boolean
render?: React.ReactElement
}
type DropdownMenuContentProps = React.HTMLAttributes<HTMLDivElement> & {
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 (
<DropdownMenuContext.Provider value={{ open: resolvedOpen, onOpenChange: handleOpenChange }}>
<div data-testid="dropdown-menu" data-open={String(resolvedOpen)}>
{children}
</div>
</DropdownMenuContext.Provider>
)
}
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<HTMLElement>) => {
onClick?.(event)
if (!event.defaultPrevented)
onOpenChange(!open)
}
if (React.isValidElement(node)) {
const triggerElement = node as React.ReactElement<Record<string, unknown>>
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
const triggerProps = props as React.HTMLAttributes<HTMLElement> & { '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<HTMLElement>) => {
childProps.onClick?.(event)
handleClick(event)
},
}, render ? (children ?? childProps.children) : childProps.children)
}
return (
<div data-testid="dropdown-menu-trigger" role="button" tabIndex={0} onClick={handleClick} {...props}>
{node}
</div>
)
}
export const DropdownMenuContent = ({
children,
className,
popupClassName,
placement,
sideOffset,
alignOffset,
...props
}: DropdownMenuContentProps) => {
const { open } = React.useContext(DropdownMenuContext)
if (!open)
return null
return (
<div
data-testid="dropdown-menu-content"
data-placement={placement}
data-side-offset={sideOffset}
data-align-offset={alignOffset}
className={className || popupClassName}
{...props}
>
{children}
</div>
)
}
export const DropdownMenuItem = ({
children,
onClick,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode }) => (
<div role="menuitem" onClick={onClick} {...props}>
{children}
</div>
)
export const DropdownMenuRadioGroup = ({
children,
onValueChange,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode, value?: unknown, onValueChange?: (value: unknown) => void }) => (
<div
role="radiogroup"
{...props}
data-on-value-change={onValueChange ? 'true' : undefined}
>
{React.Children.map(children, (child) => {
if (!React.isValidElement(child))
return child
return React.cloneElement(child as React.ReactElement<{ __onValueChange?: (value: unknown) => void }>, { __onValueChange: onValueChange })
})}
</div>
)
export const DropdownMenuRadioItem = ({
children,
value,
onClick,
__onValueChange,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode, value?: unknown, __onValueChange?: (value: unknown) => void }) => (
<div
role="radio"
onClick={(event) => {
onClick?.(event)
__onValueChange?.(value)
}}
{...props}
>
{children}
</div>
)
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<HTMLDivElement>) => <div role="separator" {...props} />
export const DropdownMenuSub = ({ children }: { children?: ReactNode }) => <>{children}</>
export const DropdownMenuSubTrigger = DropdownMenuItem
export const DropdownMenuSubContent = ({ children }: { children?: ReactNode }) => <>{children}</>

View File

@ -23,17 +23,25 @@ type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
placement?: string
sideOffset?: number
alignOffset?: number
popupClassName?: string
positionerProps?: React.HTMLAttributes<HTMLDivElement>
popupProps?: React.HTMLAttributes<HTMLDivElement>
}
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 (
<PopoverContext.Provider value={{
open,
onOpenChange: onOpenChange ?? (() => {}),
open: resolvedOpen,
onOpenChange: handleOpenChange,
}}
>
<div data-testid="popover" data-open={String(open)}>
<div data-testid="popover" data-open={String(resolvedOpen)}>
{children}
</div>
</PopoverContext.Provider>
@ -84,11 +92,12 @@ export const PopoverTrigger = ({
if (React.isValidElement(node)) {
const triggerElement = node as React.ReactElement<Record<string, unknown>>
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
const triggerProps = props as React.HTMLAttributes<HTMLElement> & { '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<HTMLElement>) => {
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}

View File

@ -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) => (
<SelectContext.Provider value={{ value, onValueChange: onValueChange ?? (() => {}) }}>
<div data-testid="select-root">{children}</div>
</SelectContext.Provider>
)
export const SelectTrigger = ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
<button type="button" {...props}>
{children}
</button>
)
export const SelectValue = ({ placeholder }: { placeholder?: ReactNode }) => <>{placeholder}</>
export const SelectContent = ({ children }: { children?: ReactNode }) => (
<div data-testid="select-content">{children}</div>
)
export const SelectItem = ({
children,
value,
onClick,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode, value?: unknown }) => {
const select = React.useContext(SelectContext)
return (
<div
role="option"
onClick={(event) => {
onClick?.(event)
select.onValueChange(value)
}}
{...props}
>
{children}
</div>
)
}
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<HTMLDivElement>) => <div role="separator" {...props} />

View File

@ -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 (
<TooltipContext.Provider value={{ open: resolvedOpen, onOpenChange: handleOpenChange }}>
{children}
</TooltipContext.Provider>
)
}
export const TooltipTrigger = ({
children,
render,
nativeButton: _nativeButton,
...props
}: React.HTMLAttributes<HTMLElement> & { 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<Record<string, unknown>>
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement>
return React.cloneElement(triggerElement, {
...props,
...childProps,
onMouseEnter: (event: React.MouseEvent<HTMLElement>) => {
childProps.onMouseEnter?.(event)
props.onMouseEnter?.(event)
onOpenChange(true)
},
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
childProps.onMouseLeave?.(event)
props.onMouseLeave?.(event)
onOpenChange(false)
},
onClick: (event: React.MouseEvent<HTMLElement>) => {
childProps.onClick?.(event)
props.onClick?.(event)
onOpenChange(!open)
},
})
}
return (
<span
{...props}
onMouseEnter={(event) => {
props.onMouseEnter?.(event)
onOpenChange(true)
}}
onMouseLeave={(event) => {
props.onMouseLeave?.(event)
onOpenChange(false)
}}
onClick={(event) => {
props.onClick?.(event)
onOpenChange(!open)
}}
>
{node}
</span>
)
}
export const TooltipContent = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode }) => {
const { open } = React.useContext(TooltipContext)
if (!open)
return null
return <div {...props}>{children}</div>
}
export const TooltipProvider = ({ children }: { children?: ReactNode }) => <>{children}</>

View File

@ -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<typeof import('react')>('react')
const OpenContext = React.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
<div>{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" data-testid="portal-trigger" onClick={onClick}>
{children}
</button>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = React.useContext(OpenContext)
return open ? <div>{children}</div> : 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: ({

View File

@ -122,33 +122,7 @@ vi.mock('@/app/components/app/app-access-control', () => ({
default: () => <div data-testid="app-access-control" />,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await vi.importActual<typeof import('react')>('react')
const OpenContext = React.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
<div>{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<div onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = React.useContext(OpenContext)
return open ? <div>{children}</div> : null
},
}
})
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',

View File

@ -121,25 +121,7 @@ vi.mock('../../app-access-control', () => ({
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react')
const OpenContext = ReactModule.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
<div>{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = ReactModule.useContext(OpenContext)
return open ? <div>{children}</div> : null
},
}
})
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('../sections', () => ({
PublisherSummarySection: (props: Record<string, any>) => {

View File

@ -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(<SelectVarType onChange={onChange} />)
@ -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()
})
})
})

View File

@ -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 }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{children}</button>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
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 }) => <span>{type}</span>,

View File

@ -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<Props> = ({
popupInnerClassName,
readonly,
}) => {
const [open, setOpen] = useState(false)
const selectedItem = value ? items.find(item => item.value === value) : undefined
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
<Select
value={selectedItem?.value}
readOnly={readonly}
onValueChange={(nextValue) => {
const selected = items.find(item => item.value === nextValue)
if (selected)
onSelect(selected)
}}
>
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className="w-full">
<div
className={cn(`group flex h-9 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2 text-sm hover:bg-state-base-hover-alt ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}`)}
title={selectedItem?.name}
>
<SelectTrigger
className={cn(
'h-9 rounded-lg px-2 text-sm',
readonly ? 'cursor-not-allowed' : 'cursor-pointer',
)}
title={selectedItem?.name}
>
<div className="flex min-w-0 items-center justify-between">
<div className="flex items-center">
<InputVarTypeIcon type={selectedItem?.value as InputVarType} className="size-4 shrink-0 text-text-secondary" />
<span
@ -60,37 +66,35 @@ const TypeSelector: FC<Props> = ({
{selectedItem?.name}
</span>
</div>
<div className="flex items-center space-x-1">
<div className="ml-2 flex shrink-0 items-center space-x-1">
<Badge uppercase={false}>{inputVarTypeToVarType(selectedItem?.value as InputVarType)}</Badge>
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-61">
<div
className={cn('w-[432px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm', popupInnerClassName)}
>
{items.map((item: Item) => (
<div
key={item.value}
className="flex h-9 cursor-pointer items-center justify-between rounded-lg px-2 text-text-secondary hover:bg-state-base-hover"
title={item.name}
onClick={() => {
onSelect(item)
setOpen(false)
}}
</SelectTrigger>
<SelectContent
sideOffset={4}
popupClassName={cn('w-[432px] rounded-md px-1 py-1 text-base sm:text-sm', popupInnerClassName)}
listClassName="max-h-80 p-0"
>
{items.map((item: Item) => (
<SelectItem
key={item.value}
value={item.value}
className="h-9 justify-between px-2 text-text-secondary"
title={item.name}
>
<SelectItemText
className="flex items-center space-x-2 px-0"
>
<div className="flex items-center space-x-2">
<InputVarTypeIcon type={item.value} className="size-4 shrink-0 text-text-secondary" />
<span title={item.name}>{item.name}</span>
</div>
<Badge uppercase={false}>{inputVarTypeToVarType(item.value)}</Badge>
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<InputVarTypeIcon type={item.value} className="size-4 shrink-0 text-text-secondary" />
<span title={item.name}>{item.name}</span>
</SelectItemText>
<Badge uppercase={false}>{inputVarTypeToVarType(item.value)}</Badge>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -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<ItemProps> = ({ text, type, value, Icon, onClick }) => {
return (
<div
className="flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
<DropdownMenuItem
closeOnClick
className="h-8 rounded-lg px-3 text-text-primary"
onClick={() => onClick(value)}
>
{Icon ? <Icon className="h-4 w-4 text-text-secondary" /> : <InputVarTypeIcon type={type!} className="h-4 w-4 text-text-secondary" />}
<div className="ml-2 truncate text-xs text-text-primary">{text}</div>
</div>
</DropdownMenuItem>
)
}
@ -41,40 +43,36 @@ const SelectVarType: FC<Props> = ({
onChange,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleChange = (value: string) => {
onChange(value)
setOpen(false)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 8,
crossAxis: -2,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<DropdownMenu>
<DropdownMenuTrigger
nativeButton={false}
render={<div className="block" />}
>
<OperationBtn type="add" />
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className="min-w-[192px] rounded-lg border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-1">
<SelectItem type={InputVarType.textInput} value="string" text={t('variableConfig.string', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
<SelectItem type={InputVarType.paragraph} value="paragraph" text={t('variableConfig.paragraph', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
<SelectItem type={InputVarType.select} value="select" text={t('variableConfig.select', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
<SelectItem type={InputVarType.number} value="number" text={t('variableConfig.number', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
<SelectItem type={InputVarType.checkbox} value="checkbox" text={t('variableConfig.checkbox', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
</div>
<div className="h-px border-t border-components-panel-border"></div>
<div className="p-1">
<SelectItem Icon={ApiConnection} value="api" text={t('variableConfig.apiBasedVar', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={8}
alignOffset={-2}
popupClassName="min-w-[192px] rounded-lg border bg-components-panel-bg-blur p-0 backdrop-blur-xs"
>
<div className="p-1">
<SelectItem type={InputVarType.textInput} value="string" text={t('variableConfig.string', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
<SelectItem type={InputVarType.paragraph} value="paragraph" text={t('variableConfig.paragraph', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
<SelectItem type={InputVarType.select} value="select" text={t('variableConfig.select', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
<SelectItem type={InputVarType.number} value="number" text={t('variableConfig.number', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
<SelectItem type={InputVarType.checkbox} value="checkbox" text={t('variableConfig.checkbox', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<DropdownMenuSeparator className="my-0" />
<div className="p-1">
<SelectItem Icon={ApiConnection} value="api" text={t('variableConfig.apiBasedVar', { ns: 'appDebug' })} onClick={handleChange}></SelectItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(SelectVarType)

View File

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

View File

@ -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<Props> = ({
)
return (
<>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 8,
crossAxis: -2,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className={cn(open && 'bg-gray-50', 'flex h-8 cursor-pointer items-center space-x-1 rounded-lg border border-black/5 px-3 text-indigo-600 select-none')}>
{isAgent ? <BubbleText className="h-3 w-3" /> : <CuteRobot className="h-3 w-3" />}
<div className="text-xs font-medium">{t(`assistantType.${isAgent ? 'agentAssistant' : 'chatAssistant'}.name`, { ns: 'appDebug' })}</div>
<RiArrowDownSLine className="h-3 w-3" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className="relative left-0.5 w-[480px] rounded-xl border border-black/8 bg-white p-6 shadow-lg">
<div className="mb-2 text-sm leading-5 font-semibold text-gray-900">{t('assistantType.name', { ns: 'appDebug' })}</div>
<SelectItem
Icon={BubbleText}
value="chat"
disabled={disabled}
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
isChecked={!isAgent}
onClick={handleChange}
/>
<SelectItem
Icon={CuteRobot}
value="agent"
disabled={disabled}
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
isChecked={isAgent}
onClick={handleChange}
/>
{!disabled && agentConfigUI}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn(open && 'bg-gray-50', 'flex h-8 cursor-pointer items-center space-x-1 rounded-lg border border-black/5 px-3 text-indigo-600 select-none')} />
)}
>
{isAgent ? <BubbleText className="h-3 w-3" /> : <CuteRobot className="h-3 w-3" />}
<div className="text-xs font-medium">{t(`assistantType.${isAgent ? 'agentAssistant' : 'chatAssistant'}.name`, { ns: 'appDebug' })}</div>
<RiArrowDownSLine className="h-3 w-3" />
</PopoverTrigger>
<PopoverContent
placement="bottom-end"
sideOffset={8}
alignOffset={-2}
popupClassName="relative left-0.5 w-[480px] rounded-xl border border-black/8 bg-white p-6 shadow-lg"
>
<div className="mb-2 text-sm leading-5 font-semibold text-gray-900">{t('assistantType.name', { ns: 'appDebug' })}</div>
<SelectItem
Icon={BubbleText}
value="chat"
disabled={disabled}
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
isChecked={!isAgent}
onClick={handleChange}
/>
<SelectItem
Icon={CuteRobot}
value="agent"
disabled={disabled}
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
isChecked={isAgent}
onClick={handleChange}
/>
{!disabled && agentConfigUI}
</PopoverContent>
</Popover>
{isShowAgentSetting && (
<AgentSetting
isFunctionCall={isFunctionCall}

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import VersionSelector from '../version-selector'
vi.mock('react-i18next', () => ({
@ -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()
})
})
})

View File

@ -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<VersionSelectorProps> = ({ 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<VersionSelectorProps> = ({ versionLen, value, on
const isLatest = value === versionLen - 1
return (
<PortalToFollowElem
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -12,
}}
<DropdownMenu
open={isOpen}
onOpenChange={handleOpen}
>
<PortalToFollowElemTrigger
onClick={handleToggle}
asChild
<DropdownMenuTrigger
nativeButton={false}
render={(
<div className={cn('flex items-center system-xs-medium text-text-tertiary', isOpen && 'text-text-secondary', moreThanOneVersion && 'cursor-pointer')} />
)}
>
<div className={cn('flex items-center system-xs-medium text-text-tertiary', isOpen && 'text-text-secondary', moreThanOneVersion && 'cursor-pointer')}>
<div>
{t('generate.version', { ns: 'appDebug' })}
{' '}
{value + 1}
{isLatest && ` · ${t('generate.latest', { ns: 'appDebug' })}`}
</div>
{moreThanOneVersion && <RiArrowDownSLine className="size-3" />}
<div>
{t('generate.version', { ns: 'appDebug' })}
{' '}
{value + 1}
{isLatest && ` · ${t('generate.latest', { ns: 'appDebug' })}`}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={cn(
'z-99',
)}
{moreThanOneVersion && <RiArrowDownSLine className="size-3" />}
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
alignOffset={-12}
popupClassName="w-[208px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<div
className={cn(
'w-[208px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg',
)}
<div className={cn('flex h-[22px] items-center px-3 pl-3 system-xs-medium-uppercase text-text-tertiary')}>
{t('generate.versions', { ns: 'appDebug' })}
</div>
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
onChange(nextValue)
handleOpenFalse()
}}
>
<div className={cn('flex h-[22px] items-center px-3 pl-3 system-xs-medium-uppercase text-text-tertiary')}>
{t('generate.versions', { ns: 'appDebug' })}
</div>
{
versions.map(option => (
<div
key={option.value}
className={cn(
'flex h-7 cursor-pointer items-center rounded-lg px-2 system-sm-medium text-text-secondary hover:bg-state-base-hover',
)}
title={option.label}
onClick={() => {
onChange(option.value)
handleOpenFalse()
}}
>
<div className="mr-1 grow truncate px-1 pl-1">
{option.label}
</div>
{
value === option.value && <RiCheckLine className="h-4 w-4 shrink-0 text-text-accent" />
}
{versions.map(option => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
className="h-7 rounded-lg px-2 system-sm-medium text-text-secondary"
title={option.label}
>
<div className="mr-1 grow truncate px-1 pl-1">
{option.label}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{
value === option.value && <RiCheckLine className="h-4 w-4 shrink-0 text-text-accent" />
}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -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<HTMLElement> & { children?: React.ReactNode, asChild?: boolean }
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
return (
<PortalContext.Provider value={{ open: !!open }}>
<div data-testid="portal">{children}</div>
</PortalContext.Provider>
)
}
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const { open } = React.useContext(PortalContext)
if (!open)
return null
return (
<div data-testid="portal-content" {...props}>
{children}
</div>
)
}
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
return (
<div data-testid="portal-trigger" {...props}>
{children}
</div>
)
}
return {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
}
})
describe('ContextVar', () => {
const mockOptions: Props['options'] = [
{ name: 'Variable 1', value: 'var1', type: 'string' },

View File

@ -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<HTMLElement> & { children?: React.ReactNode, asChild?: boolean }
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode, asChild?: boolean }
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & { 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 (
<PortalContext.Provider value={{ open: !!open, onOpenChange }}>
<div data-testid="portal">{children}</div>
</PortalContext.Provider>
<PopoverContext.Provider value={{ open: !!open, onOpenChange }}>
<div data-testid="popover">{children}</div>
</PopoverContext.Provider>
)
}
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 (
<div data-testid="portal-content" {...props}>
<div data-testid="popover-content" {...props}>
{children}
</div>
)
}
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<HTMLElement>) => {
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<HTMLElement>)
}
@ -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<HTMLElement>)
}
return (
<div data-testid="portal-trigger" {...props} onClick={handleClick}>
<div data-testid="popover-trigger" {...props} onClick={handleClick}>
{content}
</div>
)
@ -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(<VarPicker {...props} />)
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(<VarPicker {...props} />)
// 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(<VarPicker {...props} />)
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(<VarPicker {...props} />)
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(<VarPicker {...props} />)
// 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()
})
})
})

View File

@ -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 (
<MockContext.Provider value={open}>
<div data-open={open}>{children}</div>
</MockContext.Provider>
)
},
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = React.useContext(MockContext)
if (!open)
return null
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick, ...props }: { children: React.ReactNode, onClick: () => void } & React.HTMLAttributes<HTMLDivElement>) => (
<div onClick={onClick} {...props}>{children}</div>
),
}
})
// 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 (
<div role="dialog" data-testid="modal">
{!!title && <div>{title}</div>}
<div data-testid="modal">
{children}
</div>
)
},
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div role="dialog" data-testid="modal-content">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// Sidebar mock removed to use real component

View File

@ -16,43 +16,24 @@ vi.mock('@/app/components/base/chat/chat-with-history/inputs-form/content', () =
default: () => <div data-testid="inputs-form-content">InputsFormContent</div>,
}))
// 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 (
<MockContext.Provider value={open}>
<div data-open={open}>{children}</div>
</MockContext.Provider>
)
},
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = React.useContext(MockContext)
if (!open)
return null
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div onClick={onClick}>{children}</div>
),
}
})
// 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 (
<div data-testid="modal">
{!!title && <div>{title}</div>}
{children}
</div>
)
},
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div role="dialog" data-testid="modal-content">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockAppData: AppData = {

View File

@ -490,7 +490,7 @@ describe('Sidebar Index', () => {
render(<Sidebar />)
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(<Sidebar />)
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', () => {

View File

@ -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<IRenameModalProps> = ({
const conversationNamePlaceholder = t('chat.conversationNamePlaceholder', { ns: 'common' }) || ''
return (
<Modal
title={t('chat.renameConversation', { ns: 'common' })}
isShow={isShow}
onClose={onClose}
<Dialog
open={isShow}
onOpenChange={open => !open && onClose()}
>
<div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('chat.conversationName', { ns: 'common' })}</div>
<Input
className="mt-2 h-10 w-full"
value={tempName}
onChange={e => setTempName(e.target.value)}
placeholder={conversationNamePlaceholder}
/>
<DialogContent>
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('chat.renameConversation', { ns: 'common' })}
</DialogTitle>
<div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('chat.conversationName', { ns: 'common' })}</div>
<Input
className="mt-2 h-10 w-full"
value={tempName}
onChange={e => setTempName(e.target.value)}
placeholder={conversationNamePlaceholder}
/>
<div className="mt-10 flex justify-end">
<Button className="mr-2 shrink-0" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" className="shrink-0" onClick={() => onSave(tempName)} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</Modal>
<div className="mt-10 flex justify-end">
<Button className="mr-2 shrink-0" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" className="shrink-0" onClick={() => onSave(tempName)} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</DialogContent>
</Dialog>
)
}
export default React.memo(RenameModal)

View File

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

View File

@ -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<OperationProps> = ({
type FeedbackTooltipProps = {
content: ReactNode
children: ReactElement
}
const feedbackTooltipClassName = 'max-w-[260px]'
const FeedbackTooltip = ({ content, children }: FeedbackTooltipProps) => {
return (
<Tooltip>
<TooltipTrigger render={children} />
<TooltipContent className={feedbackTooltipClassName}>
{content}
</TooltipContent>
</Tooltip>
)
}
function Operation({
item,
question,
index,
@ -42,7 +62,7 @@ const Operation: FC<OperationProps> = ({
contentWidth,
hasWorkflowProcess,
noChatInput,
}) => {
}: OperationProps) {
const { t } = useTranslation()
const {
config,
@ -68,8 +88,8 @@ const Operation: FC<OperationProps> = ({
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<OperationProps> = ({
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<OperationProps> = ({
>
{hasUserFeedback
? (
<Tooltip
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
<FeedbackTooltip
content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
>
<ActionButton
aria-label={`${userFeedbackLabel}: ${removeFeedbackLabel}`}
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
onClick={() => handleFeedback(null, undefined, 'user')}
>
{displayUserFeedback?.rating === 'like'
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
? <span aria-hidden="true" className="i-ri-thumb-up-line h-4 w-4" />
: <span aria-hidden="true" className="i-ri-thumb-down-line h-4 w-4" />}
</ActionButton>
</Tooltip>
</FeedbackTooltip>
)
: (
<>
<ActionButton
aria-label={`${userFeedbackLabel}: ${likeLabel}`}
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('user')}
>
<div className="i-ri-thumb-up-line h-4 w-4" />
<span aria-hidden="true" className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
<ActionButton
aria-label={`${userFeedbackLabel}: ${dislikeLabel}`}
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('user')}
>
<div className="i-ri-thumb-down-line h-4 w-4" />
<span aria-hidden="true" className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</>
)}
@ -218,68 +244,65 @@ const Operation: FC<OperationProps> = ({
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
)}
>
{/* User Feedback Display */}
{displayUserFeedback?.rating && (
<Tooltip
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
<FeedbackTooltip
content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
>
{displayUserFeedback.rating === 'like'
? (
<ActionButton state={ActionButtonState.Active}>
<div className="i-ri-thumb-up-line h-4 w-4" />
<ActionButton aria-label={`${userFeedbackLabel}: ${likeLabel}`} state={ActionButtonState.Active}>
<span aria-hidden="true" className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
)
: (
<ActionButton state={ActionButtonState.Destructive}>
<div className="i-ri-thumb-down-line h-4 w-4" />
<ActionButton aria-label={`${userFeedbackLabel}: ${dislikeLabel}`} state={ActionButtonState.Destructive}>
<span aria-hidden="true" className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
)}
</Tooltip>
</FeedbackTooltip>
)}
{/* Admin Feedback Controls */}
{displayUserFeedback?.rating && <div className="mx-1 h-3 w-[0.5px] bg-components-actionbar-border" />}
{hasAdminFeedback
? (
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
<FeedbackTooltip
content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
>
<ActionButton
aria-label={`${adminFeedbackLabel}: ${removeFeedbackLabel}`}
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
onClick={() => handleFeedback(null, undefined, 'admin')}
>
{adminLocalFeedback?.rating === 'like'
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
? <span aria-hidden="true" className="i-ri-thumb-up-line h-4 w-4" />
: <span aria-hidden="true" className="i-ri-thumb-down-line h-4 w-4" />}
</ActionButton>
</Tooltip>
</FeedbackTooltip>
)
: (
<>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
<FeedbackTooltip
content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
>
<ActionButton
aria-label={`${adminFeedbackLabel}: ${likeLabel}`}
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('admin')}
>
<div className="i-ri-thumb-up-line h-4 w-4" />
<span aria-hidden="true" className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
</Tooltip>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
</FeedbackTooltip>
<FeedbackTooltip
content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
>
<ActionButton
aria-label={`${adminFeedbackLabel}: ${dislikeLabel}`}
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('admin')}
>
<div className="i-ri-thumb-down-line h-4 w-4" />
<span aria-hidden="true" className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</Tooltip>
</FeedbackTooltip>
</>
)}
</div>
@ -300,18 +323,19 @@ const Operation: FC<OperationProps> = ({
)}
{!humanInputFormDataList?.length && (
<ActionButton
aria-label={copyLabel}
onClick={() => {
copy(content)
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
data-testid="copy-btn"
>
<div className="i-ri-clipboard-line h-4 w-4" />
<span aria-hidden="true" className="i-ri-clipboard-line h-4 w-4" />
</ActionButton>
)}
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)} data-testid="regenerate-btn">
<div className="i-ri-reset-left-line h-4 w-4" />
<ActionButton aria-label={regenerateLabel} onClick={() => onRegenerate?.(item)} data-testid="regenerate-btn">
<span aria-hidden="true" className="i-ri-reset-left-line h-4 w-4" />
</ActionButton>
)}
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
@ -342,30 +366,56 @@ const Operation: FC<OperationProps> = ({
onRemove={() => onAnnotationRemoved?.(index)}
/>
{isShowFeedbackModal && (
<Modal
title={t('feedback.title', { ns: 'common' }) || 'Provide Feedback'}
subTitle={t('feedback.subtitle', { ns: 'common' }) || 'Please tell us what went wrong with this response'}
onClose={handleFeedbackCancel}
onConfirm={handleFeedbackSubmit}
onCancel={handleFeedbackCancel}
confirmButtonText={t('operation.submit', { ns: 'common' }) || 'Submit'}
cancelButtonText={t('operation.cancel', { ns: 'common' }) || 'Cancel'}
<Dialog
open
onOpenChange={(open) => {
if (!open)
handleFeedbackCancel()
}}
>
<div className="space-y-3">
<div>
<label className="mb-2 block system-sm-semibold text-text-secondary">
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
</label>
<Textarea
value={feedbackContent}
onChange={e => setFeedbackContent(e.target.value)}
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve...'}
rows={4}
className="w-full"
/>
<DialogContent
backdropProps={{ forceRender: true }}
className="p-0"
>
<div className="flex max-h-[80dvh] flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('feedback.title', { ns: 'common' }) || 'Provide Feedback'}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{t('feedback.subtitle', { ns: 'common' }) || 'Please tell us what went wrong with this response'}
</DialogDescription>
<DialogCloseButton className="top-5 right-5 h-8 w-8 rounded-lg" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
<label htmlFor={feedbackTextareaId} className="mb-2 block system-sm-semibold text-text-secondary">
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
</label>
<Textarea
id={feedbackTextareaId}
name="feedback-content"
value={feedbackContent}
onChange={e => setFeedbackContent(e.target.value)}
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve…'}
rows={4}
className="w-full"
/>
</div>
<div className="flex shrink-0 justify-end p-6 pt-5">
<Button onClick={handleFeedbackCancel}>
{t('operation.cancel', { ns: 'common' }) || 'Cancel'}
</Button>
<Button
className="ml-2"
variant="primary"
onClick={handleFeedbackSubmit}
>
{t('operation.submit', { ns: 'common' }) || 'Submit'}
</Button>
</div>
</div>
</div>
</Modal>
</DialogContent>
</Dialog>
)}
</>
)

View File

@ -1,11 +1,11 @@
import type { FC } from 'react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type ProgressTooltipProps = {
data: number
@ -18,35 +18,41 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
<Tooltip
open={open}
onOpenChange={setOpen}
placement="top-start"
>
<PortalToFollowElemTrigger
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
<TooltipTrigger
render={(
<div
data-testid="progress-trigger-content"
className="flex grow items-center"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
/>
)}
>
<div data-testid="progress-trigger-content" className="flex grow items-center">
<div className="mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border">
<div
data-testid="progress-bar-fill"
className="h-full bg-components-progress-gray-progress"
style={{ width: `${data * 100}%` }}
>
</div>
<div className="mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border">
<div
data-testid="progress-bar-fill"
className="h-full bg-components-progress-gray-progress"
style={{ width: `${data * 100}%` }}
>
</div>
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div data-testid="progress-tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 system-xs-medium text-text-quaternary shadow-lg">
{t('chat.citation.hitScore', { ns: 'common' })}
{' '}
{data}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{data}
</TooltipTrigger>
<TooltipContent
data-testid="progress-tooltip-popup"
placement="top-start"
sideOffset={0}
className="rounded-lg bg-components-tooltip-bg p-3 system-xs-medium text-text-quaternary shadow-lg"
>
{t('chat.citation.hitScore', { ns: 'common' })}
{' '}
{data}
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,11 +1,11 @@
import type { FC } from 'react'
import {
Tooltip as DifyTooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type TooltipProps = {
data: number | string
@ -21,28 +21,34 @@ const Tooltip: FC<TooltipProps> = ({
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
<DifyTooltip
open={open}
onOpenChange={setOpen}
placement="top-start"
>
<PortalToFollowElemTrigger
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
<TooltipTrigger
render={(
<div
data-testid="tooltip-trigger-content"
className="mr-6 flex items-center"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
/>
)}
>
<div data-testid="tooltip-trigger-content" className="mr-6 flex items-center">
{icon}
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div data-testid="tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 system-xs-medium text-text-quaternary shadow-lg">
{text}
{' '}
{data}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{icon}
{data}
</TooltipTrigger>
<TooltipContent
data-testid="tooltip-popup"
placement="top-start"
sideOffset={0}
className="rounded-lg bg-components-tooltip-bg p-3 system-xs-medium text-text-quaternary shadow-lg"
>
{text}
{' '}
{data}
</TooltipContent>
</DifyTooltip>
)
}

View File

@ -7,7 +7,7 @@ vi.mock('../content', () => ({
default: () => <div data-testid="mock-inputs-form-content" />,
}))
// Note: PortalToFollowElem is mocked globally in vitest.setup.ts
// Note: Popover is mocked globally in vitest.setup.ts
// to render children in the normal DOM flow when open is true.
describe('ViewFormDropdown', () => {

View File

@ -40,7 +40,7 @@ describe('Chip', () => {
// Helper function to get the trigger element
const getTrigger = (container: HTMLElement) => {
return container.querySelector('[data-state]')
return container.querySelector('[role="button"][aria-haspopup="menu"]') as HTMLElement | null
}
// Helper function to open dropdown panel
@ -102,7 +102,7 @@ describe('Chip', () => {
// When showLeftIcon is false, there should be no filter icon before the text
const textElement = screen.getByText('All Items')
const parent = textElement.closest('div[data-state]')
const parent = textElement.closest('[role="button"]')
const icons = parent?.querySelectorAll('svg')
// Should only have the arrow icon, not the filter icon
@ -142,20 +142,20 @@ describe('Chip', () => {
it('should toggle dropdown panel on trigger click', () => {
const { container } = renderChip()
// Initially closed - check data-state attribute
// Initially closed - check aria-expanded attribute
const trigger = getTrigger(container)
expect(trigger)!.toHaveAttribute('data-state', 'closed')
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
// Open panel
openPanel(container)
expect(trigger)!.toHaveAttribute('data-state', 'open')
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
// Panel items should be visible
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
// Close panel
if (trigger)
fireEvent.click(trigger)
expect(trigger)!.toHaveAttribute('data-state', 'closed')
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
})
it('should close panel after selecting an item', () => {
@ -163,14 +163,14 @@ describe('Chip', () => {
openPanel(container)
const trigger = getTrigger(container)
expect(trigger)!.toHaveAttribute('data-state', 'open')
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
// Click on an item in the dropdown panel
const activeItems = screen.getAllByText('Active')
// The second one should be in the dropdown
fireEvent.click(activeItems[activeItems.length - 1]!)
expect(trigger)!.toHaveAttribute('data-state', 'closed')
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
})
})
@ -208,7 +208,7 @@ describe('Chip', () => {
const { container } = renderChip({ value: 'active' })
const trigger = getTrigger(container)
expect(trigger)!.toHaveAttribute('data-state', 'closed')
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
// Find the close icon (last SVG) and click its parent
const svgs = trigger?.querySelectorAll('svg')
@ -220,7 +220,7 @@ describe('Chip', () => {
// Panel should remain closed
// Panel should remain closed
expect(trigger)!.toHaveAttribute('data-state', 'closed')
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
expect(onClear).toHaveBeenCalledTimes(1)
})
@ -232,17 +232,17 @@ describe('Chip', () => {
// Click 1: open
if (trigger)
fireEvent.click(trigger)
expect(trigger)!.toHaveAttribute('data-state', 'open')
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
// Click 2: close
if (trigger)
fireEvent.click(trigger)
expect(trigger)!.toHaveAttribute('data-state', 'closed')
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
// Click 3: open again
if (trigger)
fireEvent.click(trigger)
expect(trigger)!.toHaveAttribute('data-state', 'open')
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
})
})
@ -285,10 +285,10 @@ describe('Chip', () => {
// Closed by default
// Closed by default
expect(trigger)!.toHaveAttribute('data-state', 'closed')
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
openPanel(container)
expect(trigger)!.toHaveAttribute('data-state', 'open')
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
// Items should be duplicated (once in trigger, once in panel)
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
})
@ -327,7 +327,7 @@ describe('Chip', () => {
const { container } = renderChip({ items: [], value: '' })
// Trigger should still render
const trigger = container.querySelector('[data-state]')
const trigger = getTrigger(container)
expect(trigger)!.toBeInTheDocument()
})

View File

@ -1,12 +1,14 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine, RiCheckLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
import { useMemo, useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
export type Item = {
value: number | string
@ -40,16 +42,14 @@ const Chip: FC<Props> = ({
}, [items, value])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
<DropdownMenuTrigger
nativeButton={false}
render={<div className="block" />}
>
<div className={cn(
'flex min-h-8 cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
@ -84,28 +84,36 @@ const Chip: FC<Props> = ({
</div>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className={cn('relative w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg', panelClassName)}>
<div className="max-h-72 overflow-auto p-1">
{items.map(item => (
<div
key={item.value}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-[6px] pl-3 hover:bg-state-base-hover"
onClick={() => {
onSelect(item)
setOpen(false)
}}
>
<div title={item.name} className="grow truncate system-sm-medium text-text-secondary">{item.name}</div>
{value === item.value && <RiCheckLine className="h-4 w-4 shrink-0 text-util-colors-blue-light-blue-light-600" />}
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName={cn('relative w-[240px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0', panelClassName)}
>
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
const selected = items.find(item => item.value === nextValue)
if (selected)
onSelect(selected)
}}
className="max-h-72 overflow-auto p-1"
>
{items.map(item => (
<DropdownMenuRadioItem
key={item.value}
value={item.value}
closeOnClick
className="gap-2 rounded-lg px-2 py-[6px] pl-3"
>
<div title={item.name} className="grow truncate system-sm-medium text-text-secondary">{item.name}</div>
{value === item.value && <RiCheckLine className="h-4 w-4 shrink-0 text-util-colors-blue-light-blue-light-600" />}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</div>
</PortalToFollowElem>
</DropdownMenu>
)
}

View File

@ -353,7 +353,9 @@ describe('BaseField', () => {
expect(screen.getByText('This is a warning')).toBeInTheDocument()
})
it('should render tooltip when provided', async () => {
it('should render infotip when tooltip content is provided', async () => {
const user = userEvent.setup()
renderBaseField({
formSchema: {
type: FormTypeEnum.textInput,
@ -366,8 +368,7 @@ describe('BaseField', () => {
expect(screen.getByText('Info')).toBeInTheDocument()
const tooltipTrigger = screen.getByTestId('base-field-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
await user.click(screen.getByRole('button', { name: 'Extra info' }))
expect(screen.getByText('Extra info')).toBeInTheDocument()
})

View File

@ -20,10 +20,10 @@ import {
import { useTranslation } from 'react-i18next'
import CheckboxList from '@/app/components/base/checkbox-list'
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import Tooltip from '@/app/components/base/tooltip'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
@ -95,7 +95,7 @@ export type BaseFieldProps = {
formSchema: FormSchema
field: AnyFieldApi
disabled?: boolean
onChange?: (field: string, value: any) => void
onChange?: (field: string, value: unknown) => void
fieldState?: FieldState
}
@ -156,7 +156,7 @@ const BaseField = ({
}, [options])
const watchedValues = useStore(field.form.store, (s) => {
const result: Record<string, any> = {}
const result: Record<string, unknown> = {}
for (const variable of watchedVariables)
result[variable] = s.values[variable]
@ -201,7 +201,7 @@ const BaseField = ({
}))
}, [dynamicOptionsData, renderI18nObject])
const handleChange = useCallback((value: any) => {
const handleChange = useCallback((value: unknown) => {
field.handleChange(value)
onChange?.(field.name, value)
}, [field, onChange])
@ -223,12 +223,10 @@ const BaseField = ({
<span className="ml-1 text-text-destructive-secondary">*</span>
)
}
{tooltip && (
<Tooltip
triggerTestId="base-field-tooltip-trigger"
popupContent={<div className="w-[200px]">{translatedTooltip}</div>}
triggerClassName="ml-0.5 w-4 h-4"
/>
{translatedTooltip && (
<Infotip aria-label={translatedTooltip} className="ml-0.5" popupClassName="w-[200px]">
{translatedTooltip}
</Infotip>
)}
</div>
<div className={cn(inputContainerClassName)}>

View File

@ -1,128 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Modal from '../modal'
describe('Modal Component', () => {
const defaultProps = {
title: 'Test Modal',
onClose: vi.fn(),
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Render', () => {
it('renders correctly with title and children', () => {
render(
<Modal {...defaultProps}>
<div data-testid="modal-child">Child Content</div>
</Modal>,
)
expect(screen.getByText('Test Modal')).toBeInTheDocument()
expect(screen.getByTestId('modal-child')).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
expect(screen.getByText(/save/i)).toBeInTheDocument()
})
it('renders subTitle when provided', () => {
render(<Modal {...defaultProps} subTitle="Test Subtitle" />)
expect(screen.getByText('Test Subtitle')).toBeInTheDocument()
})
it('renders and handles extra button', () => {
const onExtraClick = vi.fn()
render(
<Modal
{...defaultProps}
showExtraButton={true}
extraButtonText="Extra Action"
onExtraButtonClick={onExtraClick}
/>,
)
const extraBtn = screen.getByText('Extra Action')
expect(extraBtn).toBeInTheDocument()
fireEvent.click(extraBtn)
expect(onExtraClick).toHaveBeenCalledTimes(1)
})
it('renders md size class and default extra button label', () => {
const { container } = render(
<Modal
{...defaultProps}
size="md"
showExtraButton={true}
onExtraButtonClick={vi.fn()}
/>,
)
expect(screen.getByText(/remove/i)).toBeInTheDocument()
expect(container.querySelector('.w-\\[640px\\]')).toBeInTheDocument()
})
it('renders footerSlot and bottomSlot', () => {
render(
<Modal
{...defaultProps}
footerSlot={<div data-testid="footer-slot">Footer</div>}
bottomSlot={<div data-testid="bottom-slot">Bottom</div>}
/>,
)
expect(screen.getByTestId('footer-slot')).toBeInTheDocument()
expect(screen.getByTestId('bottom-slot')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls onClose when close icon is clicked', () => {
render(<Modal {...defaultProps} />)
const closeIcon = screen.getByTestId('close-icon').parentElement
fireEvent.click(closeIcon!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('calls onConfirm when confirm button is clicked', () => {
render(<Modal {...defaultProps} confirmButtonText="Confirm Me" />)
fireEvent.click(screen.getByText(/confirm/i))
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onCancel when cancel button is clicked', () => {
render(<Modal {...defaultProps} cancelButtonText="Cancel Me" />)
fireEvent.click(screen.getByText('Cancel Me'))
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('handles clickOutsideNotClose logic', () => {
const onClose = vi.fn()
const { rerender } = render(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={false} />)
fireEvent.click(screen.getByRole('tooltip'))
expect(onClose).toHaveBeenCalledTimes(1)
onClose.mockClear()
rerender(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={true} />)
fireEvent.click(screen.getByRole('tooltip'))
expect(onClose).not.toHaveBeenCalled()
})
it('prevents propagation on internal container click', () => {
const onClose = vi.fn()
render(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={false} />)
fireEvent.click(screen.getByText('Test Modal'))
expect(onClose).not.toHaveBeenCalled()
})
})
describe('Props', () => {
it('disables buttons when disabled prop is true', () => {
render(<Modal {...defaultProps} disabled={true} />)
expect(screen.getByText(/cancel/i).closest('button')).toBeDisabled()
expect(screen.getByText(/save/i).closest('button')).toBeDisabled()
})
})
})

View File

@ -1,216 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useEffect, useState } from 'react'
import Modal from './modal'
const meta = {
title: 'Base/Feedback/RichModal',
component: Modal,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Full-featured modal with header, subtitle, customizable footer buttons, and optional extra action.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'radio',
options: ['sm', 'md'],
description: 'Defines the panel width.',
},
title: {
control: 'text',
description: 'Primary heading text.',
},
subTitle: {
control: 'text',
description: 'Secondary text below the title.',
},
confirmButtonText: {
control: 'text',
description: 'Label for the confirm button.',
},
cancelButtonText: {
control: 'text',
description: 'Label for the cancel button.',
},
showExtraButton: {
control: 'boolean',
description: 'Whether to render the extra button.',
},
extraButtonText: {
control: 'text',
description: 'Label for the extra button.',
},
extraButtonVariant: {
control: 'select',
options: ['primary', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary'],
description: 'Visual style for the extra button.',
},
disabled: {
control: 'boolean',
description: 'Disables footer actions when true.',
},
footerSlot: {
control: false,
},
bottomSlot: {
control: false,
},
onClose: {
control: false,
description: 'Handler fired when the close icon or backdrop is clicked.',
},
onConfirm: {
control: false,
description: 'Handler fired when confirm is pressed.',
},
onCancel: {
control: false,
description: 'Handler fired when cancel is pressed.',
},
onExtraButtonClick: {
control: false,
description: 'Handler fired when the extra button is pressed.',
},
children: {
control: false,
},
},
args: {
size: 'sm',
title: 'Delete integration',
subTitle: 'Disabling this integration will revoke access tokens and webhooks.',
confirmButtonText: 'Delete integration',
cancelButtonText: 'Cancel',
showExtraButton: false,
extraButtonText: 'Disable temporarily',
extraButtonVariant: 'primary',
disabled: false,
onClose: () => console.log('Modal closed'),
onConfirm: () => console.log('Confirm pressed'),
onCancel: () => console.log('Cancel pressed'),
onExtraButtonClick: () => console.log('Extra button pressed'),
},
} satisfies Meta<typeof Modal>
export default meta
type Story = StoryObj<typeof meta>
type ModalProps = React.ComponentProps<typeof Modal>
const ModalDemo = (props: ModalProps) => {
const [open, setOpen] = useState(false)
useEffect(() => {
if (props.disabled && open)
setOpen(false)
}, [props.disabled, open])
const {
onClose,
onConfirm,
onCancel,
onExtraButtonClick,
children,
...rest
} = props
const handleClose = () => {
onClose?.()
setOpen(false)
}
const handleConfirm = () => {
onConfirm?.()
setOpen(false)
}
const handleCancel = () => {
onCancel?.()
setOpen(false)
}
const handleExtra = () => {
onExtraButtonClick?.()
}
return (
<div className="relative flex h-[480px] items-center justify-center bg-gray-100">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Show rich modal
</button>
{open && (
<Modal
{...rest}
onClose={handleClose}
onConfirm={handleConfirm}
onCancel={handleCancel}
onExtraButtonClick={handleExtra}
children={children ?? (
<div className="space-y-4 text-sm text-gray-600">
<p>
Removing integrations immediately stops workflow automations related to this connection.
Make sure no scheduled jobs depend on this integration before proceeding.
</p>
<ul className="list-disc space-y-1 pl-4 text-xs text-gray-500">
<li>All API credentials issued by this integration will be revoked.</li>
<li>Historical logs remain accessible for auditing.</li>
<li>You can re-enable the integration later with fresh credentials.</li>
</ul>
</div>
)}
/>
)}
</div>
)
}
export const Default: Story = {
render: args => <ModalDemo {...args} />,
}
export const WithExtraAction: Story = {
render: args => <ModalDemo {...args} />,
args: {
showExtraButton: true,
extraButtonVariant: 'secondary',
extraButtonText: 'Disable only',
footerSlot: (
<span className="text-xs text-gray-400">Last synced 5 minutes ago</span>
),
},
parameters: {
docs: {
description: {
story: 'Illustrates the optional extra button and footer slot for advanced workflows.',
},
},
},
}
export const MediumSized: Story = {
render: args => <ModalDemo {...args} />,
args: {
size: 'md',
subTitle: 'Use the larger width to surface forms with more fields or supporting descriptions.',
bottomSlot: (
<div className="border-t border-divider-subtle bg-components-panel-bg px-6 py-4 text-xs text-gray-500">
Need finer control? Configure automation rules in the integration settings page.
</div>
),
},
parameters: {
docs: {
description: {
story: 'Shows the medium sized panel and a populated `bottomSlot` for supplemental messaging.',
},
},
},
}

View File

@ -1,143 +0,0 @@
/**
* @deprecated Use `@langgenius/dify-ui/dialog` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
type ModalProps = {
onClose?: () => void
size?: 'sm' | 'md'
title: string
subTitle?: string
children?: React.ReactNode
confirmButtonText?: string
onConfirm?: () => void
cancelButtonText?: string
onCancel?: () => void
showExtraButton?: boolean
extraButtonText?: string
extraButtonVariant?: ButtonProps['variant']
onExtraButtonClick?: () => void
footerSlot?: React.ReactNode
bottomSlot?: React.ReactNode
disabled?: boolean
containerClassName?: string
wrapperClassName?: string
clickOutsideNotClose?: boolean
}
const Modal = ({
onClose,
size = 'sm',
title,
subTitle,
children,
confirmButtonText,
onConfirm,
cancelButtonText,
onCancel,
showExtraButton,
extraButtonVariant = 'primary',
extraButtonText,
onExtraButtonClick,
footerSlot,
bottomSlot,
disabled,
containerClassName,
wrapperClassName,
clickOutsideNotClose = false,
}: ModalProps) => {
const { t } = useTranslation()
return (
<PortalToFollowElem open>
<PortalToFollowElemContent
className={cn('z-9998 flex h-full w-full items-center justify-center bg-background-overlay', wrapperClassName)}
onClick={clickOutsideNotClose ? noop : onClose}
>
<div
className={cn(
'flex max-h-[80%] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs',
size === 'sm' && 'w-[480px]',
size === 'md' && 'w-[640px]',
containerClassName,
)}
onClick={e => e.stopPropagation()}
>
<div className="relative shrink-0 p-6 pr-14 pb-3 title-2xl-semi-bold text-text-primary">
{title}
{
subTitle && (
<div className="mt-1 system-xs-regular text-text-tertiary">
{subTitle}
</div>
)
}
<div
className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={onClose}
>
<span className="i-ri-close-line h-5 w-5 text-text-tertiary" data-testid="close-icon" />
</div>
</div>
{
!!children && (
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">{children}</div>
)
}
<div className="flex shrink-0 justify-between p-6 pt-5">
<div>
{footerSlot}
</div>
<div className="flex items-center">
{
showExtraButton && (
<>
<Button
variant={extraButtonVariant}
onClick={onExtraButtonClick}
disabled={disabled}
>
{extraButtonText || t('operation.remove', { ns: 'common' })}
</Button>
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
</>
)
}
<Button
onClick={onCancel}
disabled={disabled}
>
{cancelButtonText || t('operation.cancel', { ns: 'common' })}
</Button>
<Button
className="ml-2"
variant="primary"
onClick={onConfirm}
disabled={disabled}
>
{confirmButtonText || t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
{!!bottomSlot && (
<div className="shrink-0">
{bottomSlot}
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(Modal)

View File

@ -1,230 +0,0 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import * as React from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '..'
type MockFloatingData = {
middlewareData?: {
hide?: {
referenceHidden?: boolean
}
}
}
let mockFloatingData: MockFloatingData = {}
const useFloatingMock = vi.fn()
vi.mock('@floating-ui/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@floating-ui/react')>()
return {
...actual,
useFloating: (options: unknown) => {
useFloatingMock(options)
const data = actual.useFloating(options as Parameters<typeof actual.useFloating>[0])
return {
...data,
...mockFloatingData,
middlewareData: {
...data.middlewareData,
...mockFloatingData.middlewareData,
},
}
},
}
})
afterEach(cleanup)
describe('PortalToFollowElem', () => {
describe('Context and Provider', () => {
it('should throw error when using context outside provider', () => {
// Suppress console.error for this test
const originalError = console.error
console.error = vi.fn()
expect(() => {
render(
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>,
)
}).toThrow('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
console.error = originalError
})
it('should not throw when used within provider', () => {
expect(() => {
render(
<PortalToFollowElem>
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
</PortalToFollowElem>,
)
}).not.toThrow()
})
})
describe('PortalToFollowElemTrigger', () => {
it('should render children correctly', () => {
const { getByText } = render(
<PortalToFollowElem>
<PortalToFollowElemTrigger>Trigger Text</PortalToFollowElemTrigger>
</PortalToFollowElem>,
)
expect(getByText('Trigger Text'))!.toBeInTheDocument()
})
it('should handle asChild prop correctly', () => {
const { getByRole } = render(
<PortalToFollowElem>
<PortalToFollowElemTrigger asChild>
<button>Button Trigger</button>
</PortalToFollowElemTrigger>
</PortalToFollowElem>,
)
expect(getByRole('button'))!.toHaveTextContent('Button Trigger')
})
})
describe('PortalToFollowElemContent', () => {
it('should not render content when closed', () => {
const { queryByText } = render(
<PortalToFollowElem open={false}>
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
<PortalToFollowElemContent>Popup Content</PortalToFollowElemContent>
</PortalToFollowElem>,
)
expect(queryByText('Popup Content')).not.toBeInTheDocument()
})
it('should render content when open', () => {
const { getByText } = render(
<PortalToFollowElem open={true}>
<PortalToFollowElemTrigger>Trigger </PortalToFollowElemTrigger>
<PortalToFollowElemContent>Popup Content</PortalToFollowElemContent>
</PortalToFollowElem>,
)
expect(getByText('Popup Content'))!.toBeInTheDocument()
})
})
describe('Controlled behavior', () => {
it('should call onOpenChange when interaction happens', () => {
const handleOpenChange = vi.fn()
const { getByText } = render(
<PortalToFollowElem onOpenChange={handleOpenChange}>
<PortalToFollowElemTrigger>Hover Me</PortalToFollowElemTrigger>
<PortalToFollowElemContent>Content</PortalToFollowElemContent>
</PortalToFollowElem>,
)
fireEvent.mouseEnter(getByText('Hover Me'))
expect(handleOpenChange).toHaveBeenCalled()
fireEvent.mouseLeave(getByText('Hover Me'))
expect(handleOpenChange).toHaveBeenCalled()
})
})
describe('Configuration options', () => {
it('should accept placement prop', () => {
render(
<PortalToFollowElem placement="top-start">
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
</PortalToFollowElem>,
)
expect(useFloatingMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: 'top-start',
}),
)
})
it('should handle triggerPopupSameWidth prop', () => {
render(
<PortalToFollowElem triggerPopupSameWidth>
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
<PortalToFollowElemContent>Content</PortalToFollowElemContent>
</PortalToFollowElem>,
)
type SizeMiddleware = {
name: 'size'
options: [{
apply: (args: {
elements: { floating: { style: Record<string, string> } }
rects: { reference: { width: number } }
availableHeight: number
}) => void
}]
}
const sizeMiddleware = useFloatingMock.mock.calls[0]![0].middleware.find(
(m: { name: string }) => m.name === 'size',
) as SizeMiddleware
expect(sizeMiddleware).toBeDefined()
// Manually trigger the apply function to cover line 81-82
const mockElements = {
floating: { style: {} as Record<string, string> },
}
const mockRects = {
reference: { width: 100 },
}
sizeMiddleware.options[0].apply({
elements: mockElements,
rects: mockRects,
availableHeight: 500,
})
expect(mockElements.floating.style.width).toBe('100px')
expect(mockElements.floating.style.maxHeight).toBe('500px')
})
})
describe('PortalToFollowElemTrigger asChild', () => {
it('should render correct data-state when open', () => {
const { getByRole } = render(
<PortalToFollowElem open={true}>
<PortalToFollowElemTrigger asChild>
<button>Trigger</button>
</PortalToFollowElemTrigger>
</PortalToFollowElem>,
)
expect(getByRole('button'))!.toHaveAttribute('data-state', 'open')
})
it('should handle missing ref on child', () => {
const { getByRole } = render(
<PortalToFollowElem>
<PortalToFollowElemTrigger asChild>
<button>Trigger</button>
</PortalToFollowElemTrigger>
</PortalToFollowElem>,
)
expect(getByRole('button'))!.toBeInTheDocument()
})
})
describe('Visibility', () => {
it('should hide content when reference is hidden', () => {
mockFloatingData = {
middlewareData: {
hide: { referenceHidden: true },
},
}
const { getByTestId } = render(
<PortalToFollowElem open={true}>
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
<PortalToFollowElemContent data-testid="content">Hidden Content</PortalToFollowElemContent>
</PortalToFollowElem>,
)
expect(getByTestId('content'))!.toHaveStyle('visibility: hidden')
mockFloatingData = {}
})
})
})

View File

@ -1,103 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '.'
const TooltipCard = ({ title, description }: { title: string, description: string }) => (
<div className="w-[220px] rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-sm text-text-secondary shadow-lg">
<div className="mb-1 text-xs font-semibold tracking-[0.14em] text-text-tertiary uppercase">
{title}
</div>
<p className="leading-5">{description}</p>
</div>
)
const PortalDemo = ({
placement = 'bottom',
triggerPopupSameWidth = false,
}: {
placement?: Parameters<typeof PortalToFollowElem>[0]['placement']
triggerPopupSameWidth?: boolean
}) => {
const [controlledOpen, setControlledOpen] = useState(false)
return (
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex flex-wrap items-center gap-4">
<PortalToFollowElem placement={placement} triggerPopupSameWidth={triggerPopupSameWidth}>
<PortalToFollowElemTrigger className="rounded-md border border-divider-subtle bg-background-default px-3 py-2 text-sm text-text-secondary">
Hover me
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-40">
<TooltipCard
title="Auto follow"
description="The floating element repositions itself when the trigger moves, using Floating UI under the hood."
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
<PortalToFollowElem
placement="bottom-start"
triggerPopupSameWidth
open={controlledOpen}
onOpenChange={setControlledOpen}
>
<PortalToFollowElemTrigger asChild>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default-subtle px-3 py-2 text-sm font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setControlledOpen(prev => !prev)}
>
Controlled toggle
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-40">
<TooltipCard
title="Controlled"
description="This panel uses the controlled API via onOpenChange/open props, and matches the trigger width."
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/PortalToFollowElem',
component: PortalDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Floating UI based portal that tracks trigger positioning. Demonstrates both hover-driven and controlled usage.',
},
},
},
argTypes: {
placement: {
control: 'select',
options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end'],
},
triggerPopupSameWidth: { control: 'boolean' },
},
args: {
placement: 'bottom',
triggerPopupSameWidth: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof PortalDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const SameWidthPanel: Story = {
args: {
triggerPopupSameWidth: true,
},
}

View File

@ -1,218 +0,0 @@
'use client'
/**
* @deprecated Use semantic overlay primitives from `@langgenius/dify-ui/*` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*
* Migration guide:
* - Tooltip `@langgenius/dify-ui/tooltip`
* - Menu/Dropdown `@langgenius/dify-ui/dropdown-menu`
* - Popover `@langgenius/dify-ui/popover`
* - Dialog/Modal `@langgenius/dify-ui/dialog`
* - Select `@langgenius/dify-ui/select`
*/
import type { OffsetOptions, Placement } from '@floating-ui/react'
import {
autoUpdate,
flip,
FloatingPortal,
offset,
shift,
size,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useMergeRefs,
useRole,
} from '@floating-ui/react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useCallback, useState } from 'react'
type PortalToFollowElemOptions = {
/*
* top, bottom, left, right
* start, end. Default is middle
* combine: top-start, top-end
*/
placement?: Placement
open?: boolean
offset?: number | OffsetOptions
onOpenChange?: (open: boolean) => void
triggerPopupSameWidth?: boolean
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
function usePortalToFollowElem({
placement = 'bottom',
open: controlledOpen,
offset: offsetValue = 0,
onOpenChange: setControlledOpen,
triggerPopupSameWidth,
}: PortalToFollowElemOptions = {}) {
const [localOpen, setLocalOpen] = useState(false)
const open = controlledOpen ?? localOpen
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
setControlledOpen?.(newOpen)
}, [setControlledOpen, setLocalOpen])
const data = useFloating({
placement,
open,
onOpenChange: handleOpenChange,
whileElementsMounted: autoUpdate,
middleware: [
offset(offsetValue),
flip({
crossAxis: placement.includes('-'),
fallbackAxisSideDirection: 'start',
padding: 5,
}),
shift({ padding: 5 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
maxHeight: `${Math.max(0, availableHeight)}px`,
overflowY: 'auto',
...(triggerPopupSameWidth && { width: `${rects.reference.width}px` }),
})
},
}),
],
})
const context = data.context
const hover = useHover(context, {
move: false,
enabled: controlledOpen === undefined,
})
const focus = useFocus(context, {
enabled: controlledOpen === undefined,
})
const dismiss = useDismiss(context)
const role = useRole(context, { role: 'tooltip' })
const interactions = useInteractions([hover, focus, dismiss, role])
return React.useMemo(
() => ({
open,
setOpen: handleOpenChange,
...interactions,
...data,
}),
[open, handleOpenChange, interactions, data],
)
}
type ContextType = ReturnType<typeof usePortalToFollowElem> | null
const PortalToFollowElemContext = React.createContext<ContextType>(null)
function usePortalToFollowElemContext() {
const context = React.useContext(PortalToFollowElemContext)
if (context == null)
throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
return context
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export function PortalToFollowElem({
children,
...options
}: { children: React.ReactNode } & PortalToFollowElemOptions) {
// This can accept any props as options, e.g. `placement`,
// or other positioning options.
const tooltip = usePortalToFollowElem(options)
return (
<PortalToFollowElemContext.Provider value={tooltip}>
{children}
</PortalToFollowElemContext.Provider>
)
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemTrigger = (
{
ref: propRef,
children,
asChild = false,
...props
}: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement | null>, asChild?: boolean },
) => {
const context = usePortalToFollowElemContext()
const childElement = React.isValidElement<{ ref?: React.Ref<HTMLElement | null> }>(children)
? children
: null
const childrenRef = childElement?.props.ref
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
// `asChild` allows the user to pass any element as the anchor
if (asChild && childElement) {
const childProps = (childElement.props ?? {}) as Record<string, unknown>
return React.cloneElement(
childElement,
context.getReferenceProps({
ref,
...props,
...childProps,
'data-state': context.open ? 'open' : 'closed',
} as React.HTMLProps<HTMLElement>),
)
}
return (
<div
ref={ref}
className={cn('inline-block', props.className)}
// The user can style the trigger based on the state
data-state={context.open ? 'open' : 'closed'}
{...context.getReferenceProps(props)}
>
{children}
</div>
)
}
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
/** @deprecated Use semantic overlay primitives instead. See #32767. */
export const PortalToFollowElemContent = (
{
ref: propRef,
style,
...props
}: React.HTMLProps<HTMLDivElement> & {
ref?: React.RefObject<HTMLDivElement | null>
},
) => {
const context = usePortalToFollowElemContext()
const ref = useMergeRefs([context.refs.setFloating, propRef])
if (!context.open)
return null
const body = document.body
return (
<FloatingPortal root={body}>
<div
ref={ref}
style={{
...context.floatingStyles,
...style,
visibility: context.middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
}}
{...context.getFloatingProps(props)}
/>
</FloatingPortal>
)
}
PortalToFollowElemContent.displayName = 'PortalToFollowElemContent'

View File

@ -20,9 +20,7 @@ describe('Sort component — real portal integration', () => {
// helper: returns a non-null HTMLElement or throws with a clear message
const getTriggerWrapper = (): HTMLElement => {
const labelNode = screen.getByText('appLog.filter.sortBy')
// try to find a reasonable wrapper element; prefer '.block' but fallback to any ancestor div
const wrapper = labelNode.closest('.block') ?? labelNode.closest('div')
const wrapper = screen.getByRole('button', { name: /appLog\.filter\.sortBy/i })
if (!wrapper)
throw new Error('Trigger wrapper element not found for "Sort by" label')
return wrapper as HTMLElement
@ -49,32 +47,30 @@ describe('Sort component — real portal integration', () => {
expect(sortButton.querySelector('svg')).toBeInTheDocument()
})
it('opens and closes the tooltip (portal mounts to document.body)', async () => {
it('opens and closes the menu', async () => {
const { user, getTriggerWrapper } = setup()
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
expect(tooltip).toBeInTheDocument()
expect(document.body.contains(tooltip)).toBe(true)
expect(await screen.findByText('Name')).toBeInTheDocument()
// clicking the trigger again should close it
await user.click(getTriggerWrapper())
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('Name')).not.toBeInTheDocument())
})
it('renders options and calls onSelect with descending prefix when order is "-"', async () => {
const { user, onSelect, getTriggerWrapper } = setup({ order: '-' })
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
await screen.findByText('Name')
mockItems.forEach((item) => {
expect(within(tooltip).getByText(item.name)).toBeInTheDocument()
expect(within(document.body).getAllByText(item.name).length).toBeGreaterThan(0)
})
await user.click(within(tooltip).getByText('Name'))
await user.click(screen.getByText('Name'))
expect(onSelect).toHaveBeenCalledWith('-name')
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('Name')).not.toBeInTheDocument())
})
it('toggles sorting order: ascending -> descending via right-side button', async () => {
@ -93,10 +89,10 @@ describe('Sort component — real portal integration', () => {
const { user, getTriggerWrapper } = setup({ value: 'status' })
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
await screen.findByText('Name')
const statusRow = within(tooltip).getByText('Status').closest('.flex')
const nameRow = within(tooltip).getByText('Name').closest('.flex')
const statusRow = screen.getAllByText('Status').at(-1)?.closest('.flex')
const nameRow = screen.getByText('Name').closest('.flex')
if (!statusRow)
throw new Error('Status option row not found in menu')
@ -120,9 +116,9 @@ describe('Sort component — real portal integration', () => {
const { user, onSelect, getTriggerWrapper } = setup({ order: undefined })
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
await screen.findByText('Name')
await user.click(within(tooltip).getByText('Name'))
await user.click(screen.getByText('Name'))
expect(onSelect).toHaveBeenCalled()
expect(onSelect).toHaveBeenCalledWith(expect.stringMatching(/name$/))
@ -131,11 +127,10 @@ describe('Sort component — real portal integration', () => {
it('clicking outside the open menu closes the portal', async () => {
const { user, getTriggerWrapper } = setup()
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
expect(tooltip).toBeInTheDocument()
expect(await screen.findByText('Name')).toBeInTheDocument()
// click outside: body click should close the tooltip
await user.click(document.body)
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('Name')).not.toBeInTheDocument())
})
})

View File

@ -1,13 +1,15 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine, RiCheckLine, RiSortAsc, RiSortDesc } from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type Item = {
value: number | string
@ -35,16 +37,14 @@ const Sort: FC<Props> = ({
return (
<div className="inline-flex items-center">
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
<DropdownMenuTrigger
nativeButton={false}
render={<div className="block" />}
>
<div className={cn(
'flex min-h-8 cursor-pointer items-center rounded-l-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
@ -59,28 +59,32 @@ const Sort: FC<Props> = ({
</div>
<RiArrowDownSLine className="h-4 w-4 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className="relative w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="max-h-72 overflow-auto p-1">
{items.map(item => (
<div
key={item.value}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-[6px] pl-3 hover:bg-state-base-hover"
onClick={() => {
onSelect(`${order}${item.value}`)
setOpen(false)
}}
>
<div title={item.name} className="grow truncate system-sm-medium text-text-secondary">{item.name}</div>
{value === item.value && <RiCheckLine className="h-4 w-4 shrink-0 text-util-colors-blue-light-blue-light-600" />}
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="relative w-[240px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0"
>
<DropdownMenuRadioGroup
value={value}
onValueChange={nextValue => onSelect(`${order}${nextValue}`)}
className="max-h-72 overflow-auto p-1"
>
{items.map(item => (
<DropdownMenuRadioItem
key={item.value}
value={item.value}
closeOnClick
className="gap-2 rounded-lg px-2 py-[6px] pl-3"
>
<div title={item.name} className="grow truncate system-sm-medium text-text-secondary">{item.name}</div>
{value === item.value && <RiCheckLine className="h-4 w-4 shrink-0 text-util-colors-blue-light-blue-light-600" />}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</div>
</PortalToFollowElem>
</DropdownMenu>
<div className="ml-px cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover" onClick={() => onSelect(`${order ? '' : '-'}${value}`)}>
{!order && <RiSortAsc className="h-4 w-4 text-components-button-tertiary-text" />}
{order && <RiSortDesc className="h-4 w-4 text-components-button-tertiary-text" />}

View File

@ -1,19 +1,28 @@
'use client'
import type { Placement } from '@langgenius/dify-ui/popover'
/**
* @deprecated Use `@langgenius/dify-ui/tooltip` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import type { OffsetOptions, Placement } from '@floating-ui/react'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiQuestionLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { tooltipManager } from './TooltipManager'
type TooltipOffset = number | {
mainAxis?: number
crossAxis?: number
}
type TooltipProps = {
position?: Placement
triggerMethod?: 'hover' | 'click'
@ -25,7 +34,7 @@ type TooltipProps = {
popupClassName?: string
portalContentClassName?: string
noDecoration?: boolean
offset?: OffsetOptions
offset?: TooltipOffset
needsDelay?: boolean
asChild?: boolean
}
@ -46,6 +55,9 @@ const Tooltip: FC<TooltipProps> = ({
needsDelay = true,
}) => {
const [open, setOpen] = useState(false)
const resolvedOffset = offset ?? 8
const sideOffset = typeof resolvedOffset === 'number' ? resolvedOffset : (resolvedOffset.mainAxis ?? 0)
const alignOffset = typeof resolvedOffset === 'number' ? 0 : (resolvedOffset.crossAxis ?? 0)
const [isHoverPopup, {
setTrue: setHoverPopup,
setFalse: setNotHoverPopup,
@ -81,6 +93,16 @@ const Tooltip: FC<TooltipProps> = ({
}, [clearCloseTimeout])
const close = () => setOpen(false)
const handleOpenChange = (nextOpen: boolean) => {
if (disabled) {
setOpen(false)
return
}
if (triggerMethod === 'click')
setOpen(nextOpen)
else if (!nextOpen)
setOpen(false)
}
const handleLeave = (isTrigger: boolean) => {
if (isTrigger)
@ -105,52 +127,104 @@ const Tooltip: FC<TooltipProps> = ({
tooltipManager.clear(close)
}
}
const handleTriggerMouseEnter = () => {
if (triggerMethod === 'hover') {
clearCloseTimeout()
setHoverTrigger()
tooltipManager.register(close)
setOpen(true)
}
}
const handleTriggerMouseLeave = () => {
if (triggerMethod === 'hover')
handleLeave(true)
}
const handlePopupMouseEnter = () => {
if (triggerMethod === 'hover') {
clearCloseTimeout()
setHoverPopup()
}
}
const handlePopupMouseLeave = () => {
if (triggerMethod === 'hover')
handleLeave(false)
}
const fallbackTrigger = (
<div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-px'}>
<RiQuestionLine className="h-full w-full text-text-quaternary hover:text-text-tertiary" />
</div>
)
const triggerContent = children || fallbackTrigger
const childElement = React.isValidElement<React.HTMLAttributes<HTMLElement>>(triggerContent)
? triggerContent
: fallbackTrigger
const nativeButton = typeof childElement.type !== 'string' || childElement.type === 'button'
const renderAsChildTrigger = () => {
const childProps = childElement.props
return React.cloneElement(childElement, {
onMouseEnter: (event: React.MouseEvent<HTMLElement>) => {
childProps.onMouseEnter?.(event)
handleTriggerMouseEnter()
},
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
childProps.onMouseLeave?.(event)
handleTriggerMouseLeave()
},
})
}
const effectiveOpen = !disabled && open
return (
<PortalToFollowElem
open={disabled ? false : open}
onOpenChange={setOpen}
placement={position}
offset={offset ?? 8}
<Popover
open={effectiveOpen}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
onMouseEnter={() => {
if (triggerMethod === 'hover') {
clearCloseTimeout()
setHoverTrigger()
tooltipManager.register(close)
setOpen(true)
}
}}
onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)}
asChild={asChild}
className={!asChild ? triggerClassName : ''}
>
{children || <div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-px'}><RiQuestionLine className="h-full w-full text-text-quaternary hover:text-text-tertiary" /></div>}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
className={cn('z-9999', portalContentClassName || '')}
>
{!!popupContent && (
<div
className={cn(
!noDecoration && 'relative max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
popupClassName,
)}
onMouseEnter={() => {
if (triggerMethod === 'hover') {
clearCloseTimeout()
setHoverPopup()
}
}}
onMouseLeave={() => triggerMethod === 'hover' && handleLeave(false)}
>
{popupContent}
</div>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
{asChild
? (
<PopoverTrigger
nativeButton={nativeButton}
disabled={disabled}
render={renderAsChildTrigger()}
/>
)
: (
<PopoverTrigger
nativeButton={false}
disabled={disabled}
render={(
<div
className={triggerClassName}
onMouseEnter={handleTriggerMouseEnter}
onMouseLeave={handleTriggerMouseLeave}
/>
)}
>
{triggerContent}
</PopoverTrigger>
)}
{effectiveOpen && !!popupContent && (
<PopoverContent
placement={position}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={portalContentClassName}
popupClassName={cn(
noDecoration
? 'border-0 bg-transparent p-0 shadow-none'
: 'relative max-w-[300px] rounded-md border-0 bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
popupClassName,
)}
popupProps={{
onMouseEnter: handlePopupMouseEnter,
onMouseLeave: handlePopupMouseLeave,
}}
>
{popupContent}
</PopoverContent>
)}
</Popover>
)
}

View File

@ -18,43 +18,7 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
},
}))
// Mock portal-to-follow-elem - use React state to properly handle open/close
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const MockPortalToFollowElem = ({ children, open }: { children: React.ReactNode, open: boolean }) => {
return (
<div data-testid="portal-root" data-open={open}>
{React.Children.map(children, (child) => {
if (!React.isValidElement(child))
return null
return React.cloneElement(child as React.ReactElement<{ __portalOpen?: boolean }>, { __portalOpen: open })
})}
</div>
)
}
const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string, __portalOpen?: boolean }) => (
<div data-testid="portal-trigger" onClick={onClick} className={className} data-open={__portalOpen}>
{children}
</div>
)
const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: { children: React.ReactNode, className?: string, __portalOpen?: boolean }) => {
// Match actual behavior: returns null when not open
if (!__portalOpen)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
}
return {
PortalToFollowElem: MockPortalToFollowElem,
PortalToFollowElemTrigger: MockPortalToFollowElemTrigger,
PortalToFollowElemContent: MockPortalToFollowElemContent,
}
})
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// CredentialIcon - imported directly (not mocked)
// This is a simple UI component with no external dependencies
@ -97,8 +61,8 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
expect(screen.getByTestId('portal-root'))!.toBeInTheDocument()
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
})
it('should render current credential name in trigger', () => {
@ -134,7 +98,7 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
it('should render all credentials in dropdown when opened', () => {
@ -142,12 +106,12 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Act - Click trigger to open dropdown
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - All credentials should be visible (current credential appears in both trigger and list)
// Assert - All credentials should be visible (current credential appears in both trigger and list)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
// 3 in dropdown list + 1 in trigger (current) = 4 total
expect(screen.getAllByText(/Credential \d/)).toHaveLength(4)
})
@ -212,7 +176,7 @@ describe('CredentialSelector', () => {
})
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - 5 in dropdown + 1 in trigger (current credential appears twice)
@ -238,7 +202,7 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Act - Open dropdown
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential2 = screen.getByText('Credential 2')
@ -256,11 +220,11 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Act - Open dropdown and select credential
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Get the dropdown item using within() to scope query to portal content
const portalContent = screen.getByTestId('portal-content')
const portalContent = screen.getByTestId('popover-content')
const credentialOption = within(portalContent).getByText(credentialName)
fireEvent.click(credentialOption)
@ -277,7 +241,7 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Act - Open dropdown and select Credential 1
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential1 = screen.getByText('Credential 1')
@ -326,15 +290,15 @@ describe('CredentialSelector', () => {
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
// Act - Click trigger
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - Now open
// Assert - Now open
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should call onCredentialChange when clicking a credential item', () => {
@ -342,7 +306,7 @@ describe('CredentialSelector', () => {
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
@ -357,10 +321,10 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Act - Open and select
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
@ -374,7 +338,7 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Act - Rapid clicks
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
fireEvent.click(trigger)
fireEvent.click(trigger)
@ -395,7 +359,7 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Act & Assert - Select Credential 1 (different from current)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential1 = screen.getByText('Credential 1')
@ -499,7 +463,7 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Act - Open dropdown and select
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential = screen.getByText('Credential 2')
fireEvent.click(credential)
@ -519,7 +483,7 @@ describe('CredentialSelector', () => {
rerender(<CredentialSelector {...props} onCredentialChange={mockOnChange2} />)
// Open and select
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential = screen.getByText('Credential 2')
fireEvent.click(credential)
@ -651,7 +615,7 @@ describe('CredentialSelector', () => {
rerender(<CredentialSelector {...props} onCredentialChange={mockOnChange2} />)
// Open and select
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential = screen.getByText('Credential 2')
fireEvent.click(credential)
@ -672,7 +636,7 @@ describe('CredentialSelector', () => {
// Assert - Should render without crashing
// Assert - Should render without crashing
expect(screen.getByTestId('portal-root'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle undefined avatar_url in credential', () => {
@ -713,7 +677,7 @@ describe('CredentialSelector', () => {
// Assert - Should render without crashing
// Assert - Should render without crashing
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
})
it('should handle very long credential name', () => {
@ -788,7 +752,7 @@ describe('CredentialSelector', () => {
})
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Get all "Same Name" elements
@ -807,7 +771,7 @@ describe('CredentialSelector', () => {
const props = createDefaultProps({ onCredentialChange: mockOnChange })
const { unmount } = render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
unmount()
@ -832,7 +796,7 @@ describe('CredentialSelector', () => {
// Assert - Should render without crashing
// Assert - Should render without crashing
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
})
})
@ -843,7 +807,7 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
expect(trigger)!.toHaveClass('overflow-hidden')
})
@ -852,19 +816,21 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
expect(trigger)!.toHaveClass('grow')
})
it('should apply z-10 class to dropdown content', () => {
it('should configure dropdown placement through popover props', () => {
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const content = screen.getByTestId('portal-content')
expect(content)!.toHaveClass('z-10')
const content = screen.getByTestId('popover-content')
expect(content)!.toHaveAttribute('data-placement', 'bottom-start')
expect(content)!.toHaveAttribute('data-side-offset', '4')
expect(content)!.not.toHaveClass('z-10')
})
})
@ -885,11 +851,11 @@ describe('CredentialSelector', () => {
render(<CredentialSelector {...props} />)
// Assert - Initially closed
const portalRoot = screen.getByTestId('portal-root')
const portalRoot = screen.getByTestId('popover')
expect(portalRoot)!.toHaveAttribute('data-open', 'false')
// Act - Open
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - Now open
@ -901,7 +867,7 @@ describe('CredentialSelector', () => {
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - All credentials should be rendered in list
@ -914,7 +880,7 @@ describe('CredentialSelector', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - Current credential (Credential 2) appears twice:
@ -928,7 +894,7 @@ describe('CredentialSelector', () => {
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential3 = screen.getByText('Credential 3')
fireEvent.click(credential3)
@ -938,23 +904,23 @@ describe('CredentialSelector', () => {
})
})
// Portal Configuration
describe('Portal Configuration', () => {
it('should configure PortalToFollowElem with placement bottom-start', () => {
// Popover Configuration
describe('Popover Configuration', () => {
it('should configure Popover with placement bottom-start', () => {
// This test verifies the portal is configured correctly
// The actual placement is handled by the mock, but we verify the component renders
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
expect(screen.getByTestId('portal-root'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should configure PortalToFollowElem with offset mainAxis 4', () => {
it('should configure Popover with offset mainAxis 4', () => {
// This test verifies the offset configuration doesn't break rendering
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
expect(screen.getByTestId('portal-root'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
})

View File

@ -1,12 +1,12 @@
import type { DataSourceCredential } from '@/types/pipeline'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import List from './list'
import Trigger from './trigger'
@ -21,7 +21,7 @@ const CredentialSelector = ({
onCredentialChange,
credentials,
}: CredentialSelectorProps) => {
const [open, { toggle }] = useBoolean(false)
const [open, { set, setFalse }] = useBoolean(false)
const currentCredential = useMemo(() => {
return credentials.find(cred => cred.id === currentCredentialId)
@ -34,32 +34,35 @@ const CredentialSelector = ({
const handleCredentialChange = useCallback((credentialId: string) => {
onCredentialChange(credentialId)
toggle()
}, [onCredentialChange, toggle])
setFalse()
}, [onCredentialChange, setFalse])
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={toggle}
placement="bottom-start"
offset={{
mainAxis: 4,
}}
onOpenChange={set}
>
<PortalToFollowElemTrigger onClick={toggle} className="grow overflow-hidden">
<PopoverTrigger
nativeButton={false}
render={<div className="grow overflow-hidden" />}
>
<Trigger
currentCredential={currentCredential}
isOpen={open}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<List
currentCredentialId={currentCredentialId}
credentials={credentials}
onCredentialChange={handleCredentialChange}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -134,6 +134,16 @@ describe('IndexMethod', () => {
expect(input)!.toHaveValue('25')
})
it('should keep keyword number input visible next to steppers', () => {
render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} keywordNumber={25} />)
const input = screen.getByRole('textbox')
expect(input)!.toHaveClass('w-12')
expect(input)!.toHaveClass('flex-none')
expect(input)!.toHaveClass('text-center')
})
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
const handleKeywordChange = vi.fn()
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
@ -147,7 +157,7 @@ describe('IndexMethod', () => {
describe('Tooltip', () => {
it('should show tooltip when hovering over disabled Economy option', () => {
// The tooltip is shown via PortalToFollowElem when hovering
// The tooltip is shown via Popover when hovering
// This is controlled by useHover hook
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} />)
// The tooltip content should exist in DOM but may not be visible

View File

@ -1,14 +1,12 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useHover } from 'ahooks'
import { useRef } from 'react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useTranslation } from 'react-i18next'
import { Economic, HighQuality } from '@/app/components/base/icons/src/vender/knowledge'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { IndexingType } from '../../create/step-two'
import { EffectColor } from '../chunk-structure/types'
import OptionCard from '../option-card'
@ -32,8 +30,6 @@ const IndexMethod = ({
onKeywordNumberChange,
}: IndexMethodProps) => {
const { t } = useTranslation()
const economyDomRef = useRef<HTMLDivElement>(null)
const isHoveringEconomy = useHover(economyDomRef)
const isEconomyDisabled = currentValue === IndexingType.QUALIFIED
return (
@ -54,14 +50,13 @@ const IndexMethod = ({
className="gap-x-2"
/>
{/* Economy */}
<PortalToFollowElem
open={isHoveringEconomy}
offset={4}
placement="right"
>
<PortalToFollowElemTrigger>
<Popover>
<PopoverTrigger
nativeButton={false}
openOnHover={isEconomyDisabled}
render={<div />}
>
<OptionCard
ref={economyDomRef}
id={IndexingType.ECONOMICAL}
isActive={value === IndexingType.ECONOMICAL}
onClick={onChange}
@ -80,13 +75,17 @@ const IndexMethod = ({
onKeywordNumberChange={onKeywordNumberChange}
/>
</OptionCard>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 60 }}>
<div className="rounded-lg border-components-panel-border bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg">
</PopoverTrigger>
{isEconomyDisabled && (
<PopoverContent
placement="right"
sideOffset={4}
popupClassName="rounded-lg border-0 bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg"
>
{t('form.indexMethodChangeToEconomyDisabledTip', { ns: 'datasetSettings' })}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
)}
</Popover>
</div>
)
}

View File

@ -56,14 +56,14 @@ const KeyWordNumber = ({
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
/>
<NumberField
className="w-12 shrink-0"
className="w-[74px] shrink-0"
min={MIN_KEYWORD_NUMBER}
max={MAX_KEYWORD_NUMBER}
value={keywordNumber}
onValueChange={handleInputChange}
>
<NumberFieldGroup>
<NumberFieldInput />
<NumberFieldInput className="w-12 flex-none px-2 text-center" />
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />

View File

@ -20,6 +20,7 @@ vi.mock('@/app/components/header/indicator', () => ({
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
RiQuestionLine: () => <div data-testid="question-icon" />,
}))
describe('SwitchCredentialInLoadBalancing', () => {

View File

@ -1,4 +1,9 @@
import type { Credential } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
RiAddLine,
RiArrowDownSLine,
@ -10,11 +15,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import CredentialItem from './authorized/credential-item'
@ -47,68 +47,71 @@ const CredentialSelector = ({
}, [handleSelect, t])
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
triggerPopupSameWidth
>
<PortalToFollowElemTrigger asChild onClick={() => !disabled && setOpen(v => !v)}>
<div className="flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2 system-sm-regular">
<PopoverTrigger
nativeButton={false}
disabled={disabled}
render={<div className="flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2 system-sm-regular" />}
>
{
selectedCredential && (
<div className="flex items-center">
{
!selectedCredential.addNewCredential && <Indicator className="mr-2 ml-1 shrink-0" />
}
<div className="truncate system-sm-regular text-components-input-text-filled" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
{
selectedCredential.from_enterprise && (
<Badge className="shrink-0">Enterprise</Badge>
)
}
</div>
)
}
{
!selectedCredential && (
<div className="grow truncate system-sm-regular text-components-input-text-placeholder">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
)
}
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
</PopoverTrigger>
<PopoverContent
sideOffset={0}
popupClassName="border-ccomponents-panel-border rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0"
popupProps={{ style: { width: 'var(--anchor-width, auto)' } }}
>
<div className="max-h-[320px] overflow-y-auto p-1">
{
selectedCredential && (
<div className="flex items-center">
{
!selectedCredential.addNewCredential && <Indicator className="mr-2 ml-1 shrink-0" />
}
<div className="truncate system-sm-regular text-components-input-text-filled" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
{
selectedCredential.from_enterprise && (
<Badge className="shrink-0">Enterprise</Badge>
)
}
</div>
)
}
{
!selectedCredential && (
<div className="grow truncate system-sm-regular text-components-input-text-placeholder">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
)
}
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className="border-ccomponents-panel-border rounded-xl border-[0.5px] bg-components-panel-bg-blur shadow-lg">
<div className="max-h-[320px] overflow-y-auto p-1">
{
credentials.map(credential => (
<CredentialItem
key={credential.credential_id}
credential={credential}
disableDelete
disableEdit
disableRename
onItemClick={handleSelect}
showSelectedIcon
selectedCredentialId={selectedCredential?.credential_id}
/>
))
}
</div>
{
!notAllowAddNewCredential && (
<div
className="flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 system-xs-medium text-text-accent-light-mode-only"
onClick={handleAddNewCredential}
>
<RiAddLine className="mr-1 h-4 w-4" />
{t('modelProvider.auth.addNewModelCredential', { ns: 'common' })}
</div>
)
credentials.map(credential => (
<CredentialItem
key={credential.credential_id}
credential={credential}
disableDelete
disableEdit
disableRename
onItemClick={handleSelect}
showSelectedIcon
selectedCredentialId={selectedCredential?.credential_id}
/>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{
!notAllowAddNewCredential && (
<div
className="flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 system-xs-medium text-text-accent-light-mode-only"
onClick={handleAddNewCredential}
>
<RiAddLine className="mr-1 h-4 w-4" />
{t('modelProvider.auth.addNewModelCredential', { ns: 'common' })}
</div>
)
}
</PopoverContent>
</Popover>
)
}

View File

@ -21,6 +21,12 @@ describe('StatusIndicators', () => {
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
})
const getTooltipTrigger = (container: HTMLElement) => {
const trigger = container.querySelector('[role="button"][aria-haspopup="dialog"]')
expect(trigger).toBeInTheDocument()
return trigger as HTMLElement
}
it('should render nothing when model is available and enabled', () => {
const { container } = render(
<StatusIndicators
@ -48,9 +54,7 @@ describe('StatusIndicators', () => {
/>,
)
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
await user.hover(getTooltipTrigger(container))
expect(await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
})
@ -68,9 +72,7 @@ describe('StatusIndicators', () => {
/>,
)
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
await user.hover(getTooltipTrigger(container))
expect(await screen.findByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
})
@ -134,9 +136,7 @@ describe('StatusIndicators', () => {
/>,
)
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
await user.hover(getTooltipTrigger(container))
expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
})

View File

@ -225,7 +225,7 @@ describe('OAuthClientSettings', () => {
expect(mockOnClose).toHaveBeenCalled()
})
it('should close when backdrop is clicked', async () => {
it('should stay open when backdrop is clicked', () => {
const mockOnClose = vi.fn()
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
@ -234,10 +234,8 @@ describe('OAuthClientSettings', () => {
fireEvent.click(backdrop!)
await waitFor(() => {
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
})
expect(mockOnClose).toHaveBeenCalled()
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('true')
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should save settings on save only button click', async () => {

View File

@ -138,6 +138,7 @@ const OAuthClientSettings = ({
return (
<Dialog
open={open}
disablePointerDismissal
onOpenChange={handleOpenChange}
>
<DialogContent

View File

@ -1427,7 +1427,7 @@ describe('Authorized Component', () => {
expect(document.querySelector('.custom-popup-class'))!.toBeInTheDocument()
})
it('should pass placement to PortalToFollowElem', () => {
it('should pass placement to Popover', () => {
const pluginPayload = createPluginPayload()
const credentials = [createCredential()]

View File

@ -77,7 +77,7 @@ afterAll(() => {
})
// Mock portal components for controlled positioning in tests
// Use React context to properly scope open state per portal instance (for nested portals)
// Use React context to properly scope open state per popover instance (for nested popovers)
vi.mock('@langgenius/dify-ui/popover', () => {
// Context reference shared across mock components
let sharedContext: React.Context<boolean> | null = null
@ -85,8 +85,8 @@ vi.mock('@langgenius/dify-ui/popover', () => {
// Lazily get or create the context
const getContext = (): React.Context<boolean> => {
if (!sharedContext) {
const PortalOpenContext = React.createContext(false)
sharedContext = PortalOpenContext
const PopoverOpenContext = React.createContext(false)
sharedContext = PopoverOpenContext
}
return sharedContext
}
@ -103,7 +103,7 @@ vi.mock('@langgenius/dify-ui/popover', () => {
return React.createElement(
Context.Provider,
{ value: open || false },
React.createElement('div', { 'data-testid': 'portal-to-follow-elem', 'data-open': open }, children),
React.createElement('div', { 'data-testid': 'popover', 'data-open': open }, children),
)
},
PopoverTrigger: ({
@ -117,7 +117,7 @@ vi.mock('@langgenius/dify-ui/popover', () => {
onClick?: () => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
<div data-testid="popover-trigger" onClick={onClick} className={className}>
{render ?? children}
</div>
),
@ -127,7 +127,7 @@ vi.mock('@langgenius/dify-ui/popover', () => {
if (!isOpen)
return null
return (
<div data-testid="portal-content" className={className}>{children}</div>
<div data-testid="popover-content" className={className}>{children}</div>
)
},
}
@ -562,7 +562,7 @@ describe('AppPicker', () => {
const onShowChange = vi.fn()
render(<AppPicker {...defaultProps} disabled={true} onShowChange={onShowChange} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(onShowChange).not.toHaveBeenCalled()
})
@ -570,7 +570,7 @@ describe('AppPicker', () => {
const onShowChange = vi.fn()
render(<AppPicker {...defaultProps} disabled={false} onShowChange={onShowChange} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(onShowChange).toHaveBeenCalledWith(true)
})
})
@ -683,7 +683,7 @@ describe('AppPicker', () => {
// The component should render without errors
// The component should render without errors
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle isShow toggle correctly', () => {
@ -697,7 +697,7 @@ describe('AppPicker', () => {
// Should not crash
// Should not crash
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should setup intersection observer when isShow is true', () => {
@ -718,7 +718,7 @@ describe('AppPicker', () => {
// Component should render without errors
// Component should render without errors
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should cleanup observer on component unmount', () => {
@ -736,7 +736,7 @@ describe('AppPicker', () => {
// Component should still work correctly
// Component should still work correctly
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should not setup IntersectionObserver when observerTarget is null', () => {
@ -745,7 +745,7 @@ describe('AppPicker', () => {
// The guard at line 84 should prevent setup
// The guard at line 84 should prevent setup
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should debounce onLoadMore calls using loadingRef', () => {
@ -1555,7 +1555,7 @@ describe('AppSelector', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should render trigger component', () => {
@ -1588,33 +1588,33 @@ describe('AppSelector', () => {
)
// Should show the app trigger with app info
// Should show the app trigger with app info
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle different placement values', () => {
renderWithQueryClient(<AppSelector {...defaultProps} placement="top" />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle different offset values', () => {
renderWithQueryClient(<AppSelector {...defaultProps} offset={10} />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle disabled state', () => {
renderWithQueryClient(<AppSelector {...defaultProps} disabled={true} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Portal should remain closed when disabled
// Portal should remain closed when disabled
expect(screen.getByTestId('portal-to-follow-elem'))!.toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false')
})
it('should handle scope prop', () => {
renderWithQueryClient(<AppSelector {...defaultProps} scope="workflow" />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle value with inputs', () => {
@ -1624,7 +1624,7 @@ describe('AppSelector', () => {
value={{ app_id: 'app-1', inputs: { name: 'test' }, files: [] }}
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle value with files', () => {
@ -1634,7 +1634,7 @@ describe('AppSelector', () => {
value={{ app_id: 'app-1', inputs: {}, files: [{ id: 'file-1' }] }}
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
@ -1642,32 +1642,32 @@ describe('AppSelector', () => {
it('should toggle isShow state when trigger is clicked', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
const trigger = screen.getAllByTestId('portal-trigger')[0]
const trigger = screen.getAllByTestId('popover-trigger')[0]
fireEvent.click(trigger!)
// The portal state should update synchronously - get the first one (outer portal)
// The portal state should update synchronously - get the first one (outer portal)
expect(screen.getAllByTestId('portal-to-follow-elem')[0])!.toHaveAttribute('data-open', 'true')
expect(screen.getAllByTestId('popover')[0])!.toHaveAttribute('data-open', 'true')
})
it('should not toggle isShow when disabled', () => {
renderWithQueryClient(<AppSelector {...defaultProps} disabled={true} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
expect(screen.getByTestId('portal-to-follow-elem'))!.toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false')
})
it('should manage search text state', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Portal content should be visible after click
// Portal content should be visible after click
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should render correctly during load more setup', () => {
@ -1678,7 +1678,7 @@ describe('AppSelector', () => {
// Trigger should be rendered
// Trigger should be rendered
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
})
})
@ -1689,9 +1689,9 @@ describe('AppSelector', () => {
renderWithQueryClient(<AppSelector {...defaultProps} onSelect={onSelect} />)
// Open the portal
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should call onSelect with correct value structure', () => {
@ -1706,7 +1706,7 @@ describe('AppSelector', () => {
// The component should maintain the correct value structure
// The component should maintain the correct value structure
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should clear inputs when selecting different app', () => {
@ -1721,7 +1721,7 @@ describe('AppSelector', () => {
// Component renders with existing value
// Component renders with existing value
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should preserve inputs when selecting same app', () => {
@ -1734,7 +1734,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
@ -1748,7 +1748,7 @@ describe('AppSelector', () => {
}
renderWithQueryClient(<AppSelector {...defaultProps} />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should memoize currentAppInfo correctly', () => {
@ -1763,7 +1763,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should memoize formattedValue correctly', () => {
@ -1774,7 +1774,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should be wrapped with React.memo', () => {
@ -1791,7 +1791,7 @@ describe('AppSelector', () => {
</QueryClientProvider>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
@ -1799,7 +1799,7 @@ describe('AppSelector', () => {
it('should handle load more when hasMore is true', async () => {
mockHasNextPage = true
renderWithQueryClient(<AppSelector {...defaultProps} />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should not trigger load more when already loading', async () => {
@ -1825,7 +1825,7 @@ describe('AppSelector', () => {
vi.advanceTimersByTime(500)
})
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should render load more area when hasMore is true', () => {
@ -1836,11 +1836,11 @@ describe('AppSelector', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
// Open the portal
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Should render without errors
// Should render without errors
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should handle fetchNextPage rejection gracefully in handleLoadMore', async () => {
@ -1851,7 +1851,7 @@ describe('AppSelector', () => {
// Should not crash even if fetchNextPage rejects
// Should not crash even if fetchNextPage rejects
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should call fetchNextPage when intersection observer triggers handleLoadMore', async () => {
@ -1862,10 +1862,10 @@ describe('AppSelector', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
// Open the main portal
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Open the inner app picker portal
const triggers = screen.getAllByTestId('portal-trigger')
const triggers = screen.getAllByTestId('popover-trigger')
fireEvent.click(triggers[1]!)
// Simulate intersection to trigger handleLoadMore
@ -1883,8 +1883,8 @@ describe('AppSelector', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
// Open portals
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
const triggers = screen.getAllByTestId('popover-trigger')
fireEvent.click(triggers[1]!)
// Trigger first intersection
@ -1898,7 +1898,7 @@ describe('AppSelector', () => {
// Still only one call due to the picker-level debounce
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0)
})
it('should skip handleLoadMore when isFetchingNextPage is true', async () => {
@ -1909,8 +1909,8 @@ describe('AppSelector', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
// Open portals
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
const triggers = screen.getAllByTestId('popover-trigger')
fireEvent.click(triggers[1]!)
// Trigger intersection
@ -1928,8 +1928,8 @@ describe('AppSelector', () => {
renderWithQueryClient(<AppSelector {...defaultProps} />)
// Open portals
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
const triggers = screen.getAllByTestId('popover-trigger')
fireEvent.click(triggers[1]!)
// Trigger intersection
@ -1950,7 +1950,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle form change without image file', () => {
@ -1963,7 +1963,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should extract #image# from inputs and add to files array', () => {
@ -1977,7 +1977,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should preserve existing files when no #image# in inputs', () => {
@ -1990,7 +1990,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
@ -2010,9 +2010,9 @@ describe('AppSelector', () => {
)
// Open the main portal
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should preserve inputs when selecting the same app', () => {
@ -2029,7 +2029,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle app selection with empty value', () => {
@ -2047,9 +2047,9 @@ describe('AppSelector', () => {
)
// Open the main portal
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
})
@ -2062,19 +2062,19 @@ describe('AppSelector', () => {
it('should handle empty pages array', () => {
mockAppListData = { pages: [] }
renderWithQueryClient(<AppSelector {...defaultProps} />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle undefined data', () => {
mockAppListData = undefined
renderWithQueryClient(<AppSelector {...defaultProps} />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle loading state', () => {
mockIsLoading = true
renderWithQueryClient(<AppSelector {...defaultProps} />)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle app not found in displayedApps', () => {
@ -2089,7 +2089,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle value with empty inputs and files', () => {
@ -2100,7 +2100,7 @@ describe('AppSelector', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
@ -2113,7 +2113,7 @@ describe('AppSelector', () => {
// Should not crash
// Should not crash
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
})
@ -2147,11 +2147,11 @@ describe('AppSelector Integration', () => {
renderWithQueryClient(<AppSelector onSelect={onSelect} />)
// 1. Click trigger to open picker - get first trigger (outer portal)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Get the first portal element (outer portal)
// Get the first portal element (outer portal)
expect(screen.getAllByTestId('portal-to-follow-elem')[0])!.toHaveAttribute('data-open', 'true')
expect(screen.getAllByTestId('popover')[0])!.toHaveAttribute('data-open', 'true')
})
it('should handle app change with input preservation logic', () => {
@ -2163,7 +2163,7 @@ describe('AppSelector Integration', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
})
@ -2179,9 +2179,9 @@ describe('AppSelector Integration', () => {
it('should pass correct props to AppPicker', () => {
renderWithQueryClient(<AppSelector onSelect={vi.fn()} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
})
@ -2194,15 +2194,15 @@ describe('AppSelector Integration', () => {
/>,
)
expect(screen.getByTestId('portal-to-follow-elem'))!.toBeInTheDocument()
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should handle search filtering through app list', () => {
renderWithQueryClient(<AppSelector onSelect={vi.fn()} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
})
@ -2221,11 +2221,11 @@ describe('AppSelector Integration', () => {
)
// Open the main portal
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// The inner AppPicker portal is closed by default (isShowChooseApp = false)
// We need to click on the inner trigger to open it
const innerTriggers = screen.getAllByTestId('portal-trigger')
const innerTriggers = screen.getAllByTestId('popover-trigger')
// The second trigger is the inner AppPicker trigger
fireEvent.click(innerTriggers[1]!)
@ -2256,10 +2256,10 @@ describe('AppSelector Integration', () => {
)
// Open the main portal
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Click on the inner trigger to open app picker
const innerTriggers = screen.getAllByTestId('portal-trigger')
const innerTriggers = screen.getAllByTestId('popover-trigger')
fireEvent.click(innerTriggers[1]!)
// Click on the same app - need to get the one in the app list, not the trigger
@ -2289,10 +2289,10 @@ describe('AppSelector Integration', () => {
)
// Open the main portal
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Click on inner trigger to open app picker
const innerTriggers = screen.getAllByTestId('portal-trigger')
const innerTriggers = screen.getAllByTestId('popover-trigger')
fireEvent.click(innerTriggers[1]!)
// Click on an app from the dropdown
@ -2317,9 +2317,9 @@ describe('AppSelector Integration', () => {
renderWithQueryClient(<AppSelector onSelect={vi.fn()} />)
// Open the portal to render the app picker
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should stay stable after fetchNextPage completes', async () => {
@ -2329,9 +2329,9 @@ describe('AppSelector Integration', () => {
renderWithQueryClient(<AppSelector onSelect={vi.fn()} />)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should not call fetchNextPage when conditions prevent it', () => {
@ -2340,7 +2340,7 @@ describe('AppSelector Integration', () => {
renderWithQueryClient(<AppSelector onSelect={vi.fn()} />)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// fetchNextPage should not be called
expect(mockFetchNextPage).not.toHaveBeenCalled()
@ -2362,10 +2362,10 @@ describe('AppSelector Integration', () => {
)
// Open portal
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// formattedValue should include #image# from files
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0)
})
it('should handle value with no files', () => {
@ -2381,9 +2381,9 @@ describe('AppSelector Integration', () => {
/>,
)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0)
})
it('should handle undefined value.files', () => {
@ -2399,9 +2399,9 @@ describe('AppSelector Integration', () => {
/>,
)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0)
})
it('should call onSelect with transformed inputs when form input changes', () => {
@ -2430,7 +2430,7 @@ describe('AppSelector Integration', () => {
)
// Open portal to render AppInputsPanel
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Find and interact with the form input (may not exist if schema is empty)
const formInputs = screen.queryAllByPlaceholderText('FormInputField')
@ -2446,7 +2446,7 @@ describe('AppSelector Integration', () => {
}
else {
// If form inputs aren't rendered, at least verify component rendered
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0)
}
})
@ -2482,7 +2482,7 @@ describe('AppSelector Integration', () => {
/>,
)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Find file uploader and trigger upload - the #image# field will be extracted
const uploadBtns = screen.queryAllByTestId('upload-file-btn')
@ -2493,7 +2493,7 @@ describe('AppSelector Integration', () => {
}
else {
// Verify component rendered
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0)
}
})
@ -2520,7 +2520,7 @@ describe('AppSelector Integration', () => {
/>,
)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Find form input (may not exist if schema is empty)
const inputs = screen.queryAllByPlaceholderText('PreserveField')
@ -2536,7 +2536,7 @@ describe('AppSelector Integration', () => {
}
else {
// If form inputs aren't rendered, at least verify component rendered
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0)
}
})
@ -2571,7 +2571,7 @@ describe('AppSelector Integration', () => {
/>,
)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
// Try to find and click the upload button which triggers #image# form change
const uploadBtn = screen.queryByTestId('upload-file-btn')
@ -2605,7 +2605,7 @@ describe('AppSelector Integration', () => {
/>,
)
fireEvent.click(screen.getAllByTestId('portal-trigger')[0]!)
fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!)
const inputs = screen.queryAllByPlaceholderText('SimpleInput')
if (inputs.length > 0) {

View File

@ -5,34 +5,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from '../index'
let mockPortalOpenState = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
mockPortalOpenState = open || false
return (
<div data-testid="portal-elem" data-open={open}>
{children}
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
if (!mockPortalOpenState)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
},
}))
import { CreateSubscriptionButton } from '../index'
import { CreateButtonType, DEFAULT_METHOD } from '../types'
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: Object.assign(vi.fn(), {
@ -107,40 +81,47 @@ vi.mock('../common-modal', () => ({
}))
vi.mock('../oauth-client', () => ({
OAuthClientSettingsModal: ({ oauthConfig, onClose, showOAuthCreateModal }: {
OAuthClientSettingsModal: ({ open, oauthConfig, onOpenChange, showOAuthCreateModal }: {
open: boolean
oauthConfig?: TriggerOAuthConfig
onClose: () => void
onOpenChange: (open: boolean) => void
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
}) => (
<div
data-testid="oauth-client-modal"
data-has-config={!!oauthConfig}
>
<button data-testid="close-oauth-modal" onClick={onClose}>Close</button>
<button
data-testid="show-create-modal"
onClick={() => showOAuthCreateModal({
id: 'test-builder',
name: 'test',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
properties: {},
workflows_in_use: 0,
})}
}) => {
if (!open)
return null
return (
<div
data-testid="oauth-client-modal"
data-has-config={!!oauthConfig}
>
Show Create Modal
</button>
</div>
),
<button data-testid="close-oauth-modal" onClick={() => onOpenChange(false)}>Close</button>
<button
data-testid="show-create-modal"
onClick={() => showOAuthCreateModal({
id: 'test-builder',
name: 'test',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
properties: {},
workflows_in_use: 0,
})}
>
Show Create Modal
</button>
</div>
)
},
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onOpenChange?: (open: boolean) => void
onValueChange?: (value: string) => void
}>({})
@ -160,11 +141,13 @@ vi.mock('@langgenius/dify-ui/select', async () => {
children,
value,
open,
onOpenChange,
onValueChange,
}: {
children: React.ReactNode
value: string | null
open?: boolean
onOpenChange?: (open: boolean) => void
onValueChange?: (value: string) => void
}) => {
const currentValue = value ?? DEFAULT_METHOD
@ -175,10 +158,11 @@ vi.mock('@langgenius/dify-ui/select', async () => {
: String(open ?? false)
return (
<SelectContext.Provider value={{ onValueChange }}>
<SelectContext.Provider value={{ onOpenChange, onValueChange }}>
<div
data-testid="custom-select"
data-value={currentValue}
data-open={String(open ?? false)}
data-options-count={optionsCount}
data-container-open={containerOpen}
>
@ -188,7 +172,16 @@ vi.mock('@langgenius/dify-ui/select', async () => {
)
},
SelectTrigger: ({ children, className }: { children: React.ReactNode, render?: React.ReactNode, className?: string }) => {
return <div data-testid="custom-trigger" className={className}>{children}</div>
const context = React.useContext(SelectContext)
return (
<div
data-testid="custom-trigger"
className={className}
onClick={() => context.onOpenChange?.(true)}
>
{children}
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="options-container">{children}</div>
@ -281,7 +274,6 @@ const setupMocks = (config: {
describe('CreateSubscriptionButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
setupMocks()
})
@ -494,6 +486,38 @@ describe('CreateSubscriptionButton', () => {
})
})
it('should close dropdown when oauth settings is clicked from option extra action', async () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [
SupportedCreationMethods.OAUTH,
SupportedCreationMethods.APIKEY,
SupportedCreationMethods.MANUAL,
],
}),
oauthConfig: createOAuthConfig({ configured: false }),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(screen.getByTestId('custom-select'))!.toHaveAttribute('data-open', 'true')
fireEvent.click(screen.getByRole('button', {
name: 'pluginTrigger.subscription.addType.options.oauth.clientSettings',
}))
// Assert
await waitFor(() => {
expect(screen.getByTestId('oauth-client-modal'))!.toBeInTheDocument()
expect(screen.getByTestId('custom-select'))!.toHaveAttribute('data-open', 'false')
})
})
it('should close OAuthClientSettingsModal and refetch config when closed', async () => {
// Arrange
const mockRefetchOAuth = vi.fn()

View File

@ -110,48 +110,6 @@ Object.defineProperty(navigator, 'clipboard', {
writable: true,
})
vi.mock('@/app/components/base/modal/modal', () => ({
default: ({
children,
onClose,
onConfirm,
onCancel,
title,
confirmButtonText,
cancelButtonText,
footerSlot,
onExtraButtonClick,
extraButtonText,
}: {
children: React.ReactNode
onClose: () => void
onConfirm: () => void
onCancel: () => void
title: string
confirmButtonText: string
cancelButtonText?: string
footerSlot?: React.ReactNode
onExtraButtonClick?: () => void
extraButtonText?: string
}) => (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<div data-testid="modal-content">{children}</div>
<div data-testid="modal-footer">
{footerSlot}
{extraButtonText && (
<button data-testid="modal-extra" onClick={onExtraButtonClick}>{extraButtonText}</button>
)}
{cancelButtonText && (
<button data-testid="modal-cancel" onClick={onCancel}>{cancelButtonText}</button>
)}
<button data-testid="modal-confirm" onClick={onConfirm}>{confirmButtonText}</button>
<button data-testid="modal-close" onClick={onClose}>Close</button>
</div>
</div>
),
}))
let mockFormValues: { values: Record<string, string>, isCheckValidated: boolean } = {
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
isCheckValidated: true,
@ -161,10 +119,13 @@ const setMockFormValues = (values: typeof mockFormValues) => {
}
vi.mock('@/app/components/base/form/components/base', () => ({
BaseForm: React.forwardRef((
{ formSchemas }: { formSchemas: Array<{ name: string, default?: string }> },
ref: React.ForwardedRef<{ getFormValues: () => { values: Record<string, string>, isCheckValidated: boolean } }>,
) => {
BaseForm: ({
formSchemas,
ref,
}: {
formSchemas: Array<{ name: string, default?: string }>
ref?: React.Ref<{ getFormValues: () => { values: Record<string, string>, isCheckValidated: boolean } }>
}) => {
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormValues,
}))
@ -180,15 +141,24 @@ vi.mock('@/app/components/base/form/components/base', () => ({
))}
</div>
)
}),
},
}))
describe('OAuthClientSettingsModal', () => {
const defaultProps = {
open: true,
oauthConfig: createMockOAuthConfig(),
onClose: vi.fn(),
onOpenChange: vi.fn(),
showOAuthCreateModal: vi.fn(),
}
const title = 'pluginTrigger.modal.oauth.title'
const getDialog = () => screen.getByRole('dialog', { name: title })
const getCloseButton = () => screen.getByRole('button', { name: 'Close' })
const getCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' })
const getSaveOnlyButton = () => screen.getByRole('button', { name: 'plugin.auth.saveOnly' })
const getConfirmButton = () => screen.getByRole('button', {
name: /plugin\.auth\.saveAndAuth|pluginTrigger\.modal\.common\.authorizing|pluginTrigger\.modal\.oauth\.authorization\.waitingJump/,
})
beforeEach(() => {
vi.clearAllMocks()
@ -215,7 +185,7 @@ describe('OAuthClientSettingsModal', () => {
it('should render modal with correct title', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.oauth.title')
expect(screen.getByRole('heading', { name: title })).toBeInTheDocument()
})
it('should render client type selector when system_configured is true', () => {
@ -332,7 +302,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
expect(mockConfigureOAuth).toHaveBeenCalled()
})
@ -350,7 +320,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
'https://oauth.example.com/authorize',
@ -359,7 +329,7 @@ describe('OAuthClientSettingsModal', () => {
})
it('should show success toast and close modal when OAuth callback succeeds', () => {
const mockOnClose = vi.fn()
const mockOnOpenChange = vi.fn()
const mockShowOAuthCreateModal = vi.fn()
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
@ -379,18 +349,18 @@ describe('OAuthClientSettingsModal', () => {
render(
<OAuthClientSettingsModal
{...defaultProps}
onClose={mockOnClose}
onOpenChange={mockOnOpenChange}
showOAuthCreateModal={mockShowOAuthCreateModal}
/>,
)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'pluginTrigger.modal.oauth.authorization.authSuccess',
})
expect(mockOnClose).toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it('should show error toast when OAuth initiation fails', () => {
@ -403,7 +373,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
@ -420,7 +390,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
@ -432,20 +402,20 @@ describe('OAuthClientSettingsModal', () => {
})
it('should show success toast when save only succeeds', () => {
const mockOnClose = vi.fn()
const mockOnOpenChange = vi.fn()
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
onSuccess()
})
render(<OAuthClientSettingsModal {...defaultProps} onClose={mockOnClose} />)
render(<OAuthClientSettingsModal {...defaultProps} onOpenChange={mockOnOpenChange} />)
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'pluginTrigger.modal.oauth.save.success',
})
expect(mockOnClose).toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
@ -469,7 +439,7 @@ describe('OAuthClientSettingsModal', () => {
})
it('should show success toast when remove succeeds', () => {
const mockOnClose = vi.fn()
const mockOnOpenChange = vi.fn()
const configWithCustomEnabled = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
@ -484,7 +454,7 @@ describe('OAuthClientSettingsModal', () => {
<OAuthClientSettingsModal
{...defaultProps}
oauthConfig={configWithCustomEnabled}
onClose={mockOnClose}
onOpenChange={mockOnOpenChange}
/>,
)
@ -495,7 +465,7 @@ describe('OAuthClientSettingsModal', () => {
type: 'success',
message: 'pluginTrigger.modal.oauth.remove.success',
})
expect(mockOnClose).toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it('should show error toast when remove fails', () => {
@ -522,22 +492,22 @@ describe('OAuthClientSettingsModal', () => {
})
describe('Modal Actions', () => {
it('should call onClose when close button is clicked', () => {
const mockOnClose = vi.fn()
render(<OAuthClientSettingsModal {...defaultProps} onClose={mockOnClose} />)
it('should call onOpenChange when close button is clicked', () => {
const mockOnOpenChange = vi.fn()
render(<OAuthClientSettingsModal {...defaultProps} onOpenChange={mockOnOpenChange} />)
fireEvent.click(screen.getByTestId('modal-close'))
fireEvent.click(getCloseButton())
expect(mockOnClose).toHaveBeenCalled()
expect(mockOnOpenChange.mock.calls[0]?.[0]).toBe(false)
})
it('should call onClose when extra button (cancel) is clicked', () => {
const mockOnClose = vi.fn()
render(<OAuthClientSettingsModal {...defaultProps} onClose={mockOnClose} />)
it('should call onOpenChange when cancel button is clicked', () => {
const mockOnOpenChange = vi.fn()
render(<OAuthClientSettingsModal {...defaultProps} onOpenChange={mockOnOpenChange} />)
fireEvent.click(screen.getByTestId('modal-extra'))
fireEvent.click(getCancelButton())
expect(mockOnClose).toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
@ -545,13 +515,13 @@ describe('OAuthClientSettingsModal', () => {
it('should show default button text initially', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
expect(screen.getByTestId('modal-confirm')).toHaveTextContent('plugin.auth.saveAndAuth')
expect(getConfirmButton()).toHaveTextContent('plugin.auth.saveAndAuth')
})
it('should show save only button text', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
expect(screen.getByTestId('modal-cancel')).toHaveTextContent('plugin.auth.saveOnly')
expect(getSaveOnlyButton()).toHaveTextContent('plugin.auth.saveOnly')
})
})
@ -591,7 +561,7 @@ describe('OAuthClientSettingsModal', () => {
it('should handle undefined oauthConfig', () => {
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={undefined} />)
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(getDialog()).toBeInTheDocument()
})
it('should handle missing provider', () => {
@ -600,7 +570,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(getDialog()).toBeInTheDocument()
})
})
@ -618,7 +588,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
// Verify OAuth flow was initiated
expect(mockInitiateOAuth).toHaveBeenCalledWith(
@ -644,13 +614,13 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
vi.advanceTimersByTime(3000)
expect(mockVerifyBuilder).toHaveBeenCalled()
// Should still be in pending state (polling continues)
expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.authorizing')
expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.authorizing')
vi.useRealTimers()
})
@ -765,7 +735,7 @@ describe('OAuthClientSettingsModal', () => {
describe('OAuth callback edge cases', () => {
it('should not show success toast when OAuth callback returns falsy data', () => {
const mockOnClose = vi.fn()
const mockOnOpenChange = vi.fn()
const mockShowOAuthCreateModal = vi.fn()
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
@ -784,12 +754,12 @@ describe('OAuthClientSettingsModal', () => {
render(
<OAuthClientSettingsModal
{...defaultProps}
onClose={mockOnClose}
onOpenChange={mockOnOpenChange}
showOAuthCreateModal={mockShowOAuthCreateModal}
/>,
)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
// Should not show success toast or call callbacks
expect(mockToastNotify).not.toHaveBeenCalledWith(
@ -811,7 +781,7 @@ describe('OAuthClientSettingsModal', () => {
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
fireEvent.click(customCard!)
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
@ -829,7 +799,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
// Default is already selected
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
@ -901,7 +871,7 @@ describe('OAuthClientSettingsModal', () => {
it('should show saveAndAuth text by default', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
expect(screen.getByTestId('modal-confirm')).toHaveTextContent('plugin.auth.saveAndAuth')
expect(getConfirmButton()).toHaveTextContent('plugin.auth.saveAndAuth')
})
it('should show authorizing text when authorization is pending', () => {
@ -914,9 +884,9 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.authorizing')
expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.authorizing')
})
})
@ -931,10 +901,10 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
// After failure, button text should return to default
expect(screen.getByTestId('modal-confirm')).toHaveTextContent('plugin.auth.saveAndAuth')
expect(getConfirmButton()).toHaveTextContent('plugin.auth.saveAndAuth')
})
})
@ -1013,7 +983,7 @@ describe('OAuthClientSettingsModal', () => {
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!
fireEvent.click(customCard)
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
// Should not call configureOAuth because form validation failed
expect(mockConfigureOAuth).not.toHaveBeenCalled()
@ -1035,7 +1005,7 @@ describe('OAuthClientSettingsModal', () => {
// Switch to custom type
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
@ -1062,7 +1032,7 @@ describe('OAuthClientSettingsModal', () => {
// Switch to custom type
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
@ -1089,7 +1059,7 @@ describe('OAuthClientSettingsModal', () => {
// Switch to custom type
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
@ -1116,7 +1086,7 @@ describe('OAuthClientSettingsModal', () => {
// Switch to custom type
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
fireEvent.click(screen.getByTestId('modal-cancel'))
fireEvent.click(getSaveOnlyButton())
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
@ -1148,7 +1118,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
// Advance timer to trigger polling
await vi.advanceTimersByTimeAsync(3000)
@ -1157,7 +1127,7 @@ describe('OAuthClientSettingsModal', () => {
// Button text should show waitingJump after verified
await waitFor(() => {
expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.oauth.authorization.waitingJump')
expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.oauth.authorization.waitingJump')
})
vi.useRealTimers()
@ -1180,7 +1150,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
fireEvent.click(getConfirmButton())
// First poll
await vi.advanceTimersByTimeAsync(3000)
@ -1191,7 +1161,7 @@ describe('OAuthClientSettingsModal', () => {
expect(mockVerifyBuilder).toHaveBeenCalledTimes(2)
// Should still be in authorizing state
expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.authorizing')
expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.authorizing')
vi.useRealTimers()
})

View File

@ -137,7 +137,7 @@ describe('useOAuthClientState', () => {
const defaultParams = {
oauthConfig: createMockOAuthConfig(),
providerName: 'test-provider',
onClose: vi.fn(),
onOpenChange: vi.fn(),
showOAuthCreateModal: vi.fn(),
}
@ -310,20 +310,20 @@ describe('useOAuthClientState', () => {
)
})
it('should call onClose and show success toast on success', () => {
it('should call onOpenChange and show success toast on success', () => {
mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => onSuccess())
const onClose = vi.fn()
const onOpenChange = vi.fn()
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
onClose,
onOpenChange,
}))
act(() => {
result.current.handleRemove()
})
expect(onClose).toHaveBeenCalled()
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'pluginTrigger.modal.oauth.remove.success',
@ -398,20 +398,20 @@ describe('useOAuthClientState', () => {
)
})
it('should show success toast and call onClose when needAuth is false', () => {
it('should show success toast and call onOpenChange when needAuth is false', () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
const onClose = vi.fn()
const onOpenChange = vi.fn()
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
onClose,
onOpenChange,
}))
act(() => {
result.current.handleSave(false)
})
expect(onClose).toHaveBeenCalled()
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'pluginTrigger.modal.oauth.save.success',
@ -495,8 +495,8 @@ describe('useOAuthClientState', () => {
})
})
it('should call onClose and showOAuthCreateModal on callback success', () => {
const onClose = vi.fn()
it('should call onOpenChange and showOAuthCreateModal on callback success', () => {
const onOpenChange = vi.fn()
const showOAuthCreateModal = vi.fn()
const builder = createMockSubscriptionBuilder()
@ -513,7 +513,7 @@ describe('useOAuthClientState', () => {
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
onClose,
onOpenChange,
showOAuthCreateModal,
}))
@ -521,7 +521,7 @@ describe('useOAuthClientState', () => {
result.current.handleSave(true)
})
expect(onClose).toHaveBeenCalled()
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(showOAuthCreateModal).toHaveBeenCalledWith(builder)
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
@ -530,7 +530,7 @@ describe('useOAuthClientState', () => {
})
it('should not call callbacks when OAuth callback returns falsy', () => {
const onClose = vi.fn()
const onOpenChange = vi.fn()
const showOAuthCreateModal = vi.fn()
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
@ -546,7 +546,7 @@ describe('useOAuthClientState', () => {
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
onClose,
onOpenChange,
showOAuthCreateModal,
}))
@ -554,7 +554,7 @@ describe('useOAuthClientState', () => {
result.current.handleSave(true)
})
expect(onClose).not.toHaveBeenCalled()
expect(onOpenChange).not.toHaveBeenCalled()
expect(showOAuthCreateModal).not.toHaveBeenCalled()
})
})

View File

@ -13,16 +13,20 @@ import {
useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
export enum AuthorizationStatusEnum {
Pending = 'pending',
Success = 'success',
Failed = 'failed',
}
export const AuthorizationStatusEnum = {
Pending: 'pending',
Success: 'success',
Failed: 'failed',
} as const
export enum ClientTypeEnum {
Default = 'default',
Custom = 'custom',
}
export type AuthorizationStatusEnum = typeof AuthorizationStatusEnum[keyof typeof AuthorizationStatusEnum]
export const ClientTypeEnum = {
Default: 'default',
Custom: 'custom',
} as const
export type ClientTypeEnum = typeof ClientTypeEnum[keyof typeof ClientTypeEnum]
const POLL_INTERVAL_MS = 3000
@ -41,7 +45,7 @@ export const getErrorMessage = (error: unknown, fallback: string): string => {
type UseOAuthClientStateParams = {
oauthConfig?: TriggerOAuthConfig
providerName: string
onClose: () => void
onOpenChange: (open: boolean) => void
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
}
@ -67,7 +71,7 @@ type UseOAuthClientStateReturn = {
export const useOAuthClientState = ({
oauthConfig,
providerName,
onClose,
onOpenChange,
showOAuthCreateModal,
}: UseOAuthClientStateParams): UseOAuthClientStateReturn => {
const { t } = useTranslation()
@ -119,7 +123,7 @@ export const useOAuthClientState = ({
if (!callbackData)
return
toast.success(t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }))
onClose()
onOpenChange(false)
showOAuthCreateModal(response.subscription_builder)
})
},
@ -128,20 +132,20 @@ export const useOAuthClientState = ({
toast.error(t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }))
},
})
}, [providerName, initiateOAuth, onClose, showOAuthCreateModal, t])
}, [providerName, initiateOAuth, onOpenChange, showOAuthCreateModal, t])
// Remove handler
const handleRemove = useCallback(() => {
deleteOAuth(providerName, {
onSuccess: () => {
onClose()
onOpenChange(false)
toast.success(t('modal.oauth.remove.success', { ns: 'pluginTrigger' }))
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })))
},
})
}, [providerName, deleteOAuth, onClose, t])
}, [providerName, deleteOAuth, onOpenChange, t])
// Save handler
const handleSave = useCallback((needAuth: boolean) => {
@ -174,11 +178,11 @@ export const useOAuthClientState = ({
handleAuthorization()
return
}
onClose()
onOpenChange(false)
toast.success(t('modal.oauth.save.success', { ns: 'pluginTrigger' }))
},
})
}, [clientType, providerName, oauthClientSchema, oauthConfig?.params, configureOAuth, handleAuthorization, onClose, t])
}, [clientType, providerName, oauthClientSchema, oauthConfig?.params, configureOAuth, handleAuthorization, onOpenChange, t])
// Polling effect for authorization verification
useEffect(() => {

View File

@ -68,9 +68,20 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
const onClickClientSettings = useCallback((e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
e.stopPropagation()
e.preventDefault()
setIsMenuOpen(false)
showClientSettingsModal()
}, [showClientSettingsModal])
const handleClientSettingsOpenChange = useCallback((open: boolean) => {
if (open) {
showClientSettingsModal()
return
}
hideClientSettingsModal()
refetchOAuthConfig()
}, [hideClientSettingsModal, refetchOAuthConfig, showClientSettingsModal])
const allOptions = useMemo<CreateTypeOption[]>(() => {
const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured
@ -299,11 +310,9 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
{isShowClientSettingsModal
? (
<OAuthClientSettingsModal
open={isShowClientSettingsModal}
oauthConfig={oauthConfig}
onClose={() => {
hideClientSettingsModal()
refetchOAuthConfig()
}}
onOpenChange={handleClientSettingsOpenChange}
showOAuthCreateModal={(builder) => {
showCreateModal({
type: SupportedCreationMethods.OAUTH,
@ -316,5 +325,3 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
</>
)
}
export { CreateButtonType, DEFAULT_METHOD } from './types'

View File

@ -1,31 +1,36 @@
'use client'
import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiClipboardLine,
RiInformation2Fill,
} from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseForm } from '@/app/components/base/form/components/base'
import Modal from '@/app/components/base/modal/modal'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { usePluginStore } from '../../store'
import { ClientTypeEnum, useOAuthClientState } from './hooks/use-oauth-client-state'
import { ClientTypeEnum, useOAuthClientState as useOAuthClientSettings } from './hooks/use-oauth-client-state'
type Props = {
open: boolean
oauthConfig?: TriggerOAuthConfig
onClose: () => void
onOpenChange: (open: boolean) => void
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
}
const CLIENT_TYPE_OPTIONS = [ClientTypeEnum.Default, ClientTypeEnum.Custom] as const
export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => {
export const OAuthClientSettingsModal = ({ open, oauthConfig, onOpenChange, showOAuthCreateModal }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const providerName = detail?.provider || ''
const closeModal = useCallback(() => onOpenChange(false), [onOpenChange])
const oauthClientSettings = useOAuthClientSettings({
oauthConfig,
providerName,
onOpenChange,
showOAuthCreateModal,
})
const {
clientType,
setClientType,
@ -34,12 +39,7 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
confirmButtonText,
handleRemove,
handleSave,
} = useOAuthClientState({
oauthConfig,
providerName,
onClose,
showOAuthCreateModal,
})
} = oauthClientSettings
const isCustomClient = clientType === ClientTypeEnum.Custom
const showRemoveButton = oauthConfig?.custom_enabled && oauthConfig?.params && isCustomClient
@ -51,81 +51,116 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}
const title = t('modal.oauth.title', { ns: 'pluginTrigger' })
return (
<Modal
title={t('modal.oauth.title', { ns: 'pluginTrigger' })}
confirmButtonText={confirmButtonText}
cancelButtonText={t('auth.saveOnly', { ns: 'plugin' })}
extraButtonText={t('operation.cancel', { ns: 'common' })}
showExtraButton
clickOutsideNotClose
extraButtonVariant="secondary"
onExtraButtonClick={onClose}
onClose={onClose}
onCancel={() => handleSave(false)}
onConfirm={() => handleSave(true)}
footerSlot={showRemoveButton && (
<div className="grow">
<Button
variant="secondary"
className="text-components-button-destructive-secondary-text"
onClick={handleRemove}
>
{t('operation.remove', { ns: 'common' })}
</Button>
</div>
)}
<Dialog
open={open}
disablePointerDismissal
onOpenChange={onOpenChange}
>
<div className="mb-2 system-sm-medium text-text-secondary">
{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}
</div>
{oauthConfig?.system_configured && (
<div className="mb-4 flex w-full items-start justify-between gap-2">
{CLIENT_TYPE_OPTIONS.map(option => (
<OptionCard
key={option}
title={t(`subscription.addType.options.oauth.${option}`, { ns: 'pluginTrigger' })}
onSelect={() => setClientType(option)}
selected={clientType === option}
className="flex-1"
/>
))}
</div>
)}
{showRedirectInfo && (
<div className="mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4">
<div className="rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3">
<RiInformation2Fill className="h-5 w-5 shrink-0 text-text-accent" />
<DialogContent
backdropProps={{ forceRender: true }}
className="p-0"
>
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
{title}
</DialogTitle>
<DialogCloseButton data-testid="modal-close" className="top-5 right-5 h-8 w-8 rounded-lg" />
</div>
<div className="flex-1 text-text-secondary">
<div className="system-sm-regular leading-4 whitespace-pre-wrap">
{t('modal.oauthRedirectInfo', { ns: 'pluginTrigger' })}
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
<div className="mb-2 system-sm-medium text-text-secondary">
{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}
</div>
<div className="my-1.5 system-sm-medium leading-4 break-all">
{oauthConfig?.redirect_uri}
{oauthConfig?.system_configured && (
<div className="mb-4 flex w-full items-start justify-between gap-2">
{CLIENT_TYPE_OPTIONS.map(option => (
<OptionCard
key={option}
title={t(`subscription.addType.options.oauth.${option}`, { ns: 'pluginTrigger' })}
onSelect={() => setClientType(option)}
selected={clientType === option}
className="flex-1"
/>
))}
</div>
)}
{showRedirectInfo && (
<div className="mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4">
<div className="rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3">
<span aria-hidden="true" className="i-ri-information-2-fill h-5 w-5 shrink-0 text-text-accent" />
</div>
<div className="min-w-0 flex-1 text-text-secondary">
<div className="system-sm-regular leading-4 whitespace-pre-wrap">
{t('modal.oauthRedirectInfo', { ns: 'pluginTrigger' })}
</div>
<div className="my-1.5 system-sm-medium leading-4 break-all">
{oauthConfig?.redirect_uri}
</div>
<Button
variant="secondary"
size="small"
onClick={handleCopyRedirectUri}
>
<span aria-hidden="true" className="mr-1 i-ri-clipboard-line h-[14px] w-[14px]" />
{t('operation.copy', { ns: 'common' })}
</Button>
</div>
</div>
)}
{showClientForm && (
<BaseForm
formSchemas={oauthClientSchema}
ref={clientFormRef}
labelClassName="system-sm-medium mb-2 block text-text-secondary"
formClassName="space-y-4"
/>
)}
</div>
<div data-testid="modal-footer" className="flex shrink-0 justify-between p-6 pt-5">
<div>
{showRemoveButton && (
<Button
variant="secondary"
className="text-components-button-destructive-secondary-text"
onClick={handleRemove}
>
{t('operation.remove', { ns: 'common' })}
</Button>
)}
</div>
<div className="flex items-center">
<Button
data-testid="modal-extra"
variant="secondary"
onClick={closeModal}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
<Button
data-testid="modal-cancel"
onClick={() => handleSave(false)}
>
{t('auth.saveOnly', { ns: 'plugin' })}
</Button>
<Button
data-testid="modal-confirm"
className="ml-2"
variant="primary"
onClick={() => handleSave(true)}
>
{confirmButtonText}
</Button>
</div>
<Button
variant="secondary"
size="small"
onClick={handleCopyRedirectUri}
>
<RiClipboardLine className="mr-1 h-[14px] w-[14px]" />
{t('operation.copy', { ns: 'common' })}
</Button>
</div>
</div>
)}
{showClientForm && (
<BaseForm
formSchemas={oauthClientSchema}
ref={clientFormRef}
labelClassName="system-sm-medium mb-2 block text-text-secondary"
formClassName="space-y-4"
/>
)}
</Modal>
</DialogContent>
</Dialog>
)
}

View File

@ -166,49 +166,6 @@ vi.mock('@/app/components/base/form/components/base', () => ({
}),
}))
vi.mock('@/app/components/base/modal/modal', () => ({
default: ({
title,
confirmButtonText,
onClose,
onCancel,
onConfirm,
disabled,
children,
showExtraButton,
extraButtonText,
onExtraButtonClick,
bottomSlot,
}: {
title: string
confirmButtonText: string
onClose: () => void
onCancel: () => void
onConfirm: () => void
disabled?: boolean
children: React.ReactNode
showExtraButton?: boolean
extraButtonText?: string
onExtraButtonClick?: () => void
bottomSlot?: React.ReactNode
}) => (
<div data-testid="modal" data-title={title} data-disabled={disabled}>
<div data-testid="modal-content">{children}</div>
<button data-testid="modal-confirm-button" onClick={onConfirm} disabled={disabled}>
{confirmButtonText}
</button>
<button data-testid="modal-cancel-button" onClick={onCancel}>Cancel</button>
<button data-testid="modal-close-button" onClick={onClose}>Close</button>
{showExtraButton && (
<button data-testid="modal-extra-button" onClick={onExtraButtonClick}>
{extraButtonText}
</button>
)}
{!!bottomSlot && <div data-testid="modal-bottom-slot">{bottomSlot}</div>}
</div>
),
}))
// ==================== Test Utilities ====================
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({

View File

@ -2,6 +2,8 @@
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { isEqual } from 'es-toolkit/predicate'
import { useMemo, useRef, useState } from 'react'
@ -9,7 +11,6 @@ import { useTranslation } from 'react-i18next'
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
@ -23,10 +24,12 @@ type Props = {
pluginDetail?: PluginDetail
}
enum EditStep {
EditCredentials = 'edit_credentials',
EditConfiguration = 'edit_configuration',
}
const EditStep = {
EditCredentials: 'edit_credentials',
EditConfiguration: 'edit_configuration',
} as const
type EditStep = typeof EditStep[keyof typeof EditStep]
const normalizeFormType = (type: string): FormTypeEnum => {
switch (type) {
@ -52,7 +55,6 @@ const normalizeFormType = (type: string): FormTypeEnum => {
const HIDDEN_SECRET_VALUE = '[__HIDDEN__]'
// Check if all credential values are hidden (meaning nothing was changed)
const areAllCredentialsHidden = (credentials: Record<string, unknown>): boolean => {
return Object.values(credentials).every(value => value === HIDDEN_SECRET_VALUE)
}
@ -63,17 +65,36 @@ const StatusStep = ({ isActive, text, onClick, clickable }: {
onClick?: () => void
clickable?: boolean
}) => {
return (
<div
className={`flex items-center gap-1 system-2xs-semibold-uppercase ${isActive
? 'text-state-accent-solid'
: 'text-text-tertiary'} ${clickable ? 'cursor-pointer hover:text-text-secondary' : ''}`}
onClick={clickable ? onClick : undefined}
>
{isActive && (
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
)}
const className = `flex items-center gap-1 system-2xs-semibold-uppercase ${isActive
? 'text-state-accent-solid'
: 'text-text-tertiary'} ${clickable ? 'cursor-pointer rounded bg-transparent p-0 text-left hover:text-text-secondary focus-visible:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden' : ''}`
const content = (
<>
{isActive
? (
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
)
: null}
{text}
</>
)
if (clickable) {
return (
<button
type="button"
className={className}
onClick={onClick}
>
{content}
</button>
)
}
return (
<div className={className}>
{content}
</div>
)
}
@ -260,78 +281,123 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props)
})
}, [parametersSchema, subscription.parameters, subscription.id, detail?.plugin_id, detail?.provider, verifiedCredentials])
const getConfirmButtonText = () => {
const confirmButtonText = (() => {
if (currentStep === EditStep.EditCredentials)
return isVerifying ? t('modal.common.verifying', { ns: 'pluginTrigger' }) : t('modal.common.verify', { ns: 'pluginTrigger' })
return isUpdating ? t('operation.saving', { ns: 'common' }) : t('operation.save', { ns: 'common' })
}
})()
const handleBack = () => {
setCurrentStep(EditStep.EditCredentials)
setVerifiedCredentials(null)
}
const isDisabled = isUpdating || isVerifying
const title = t('subscription.list.item.actions.edit.title', { ns: 'pluginTrigger' })
return (
<Modal
title={t('subscription.list.item.actions.edit.title', { ns: 'pluginTrigger' })}
confirmButtonText={getConfirmButtonText()}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isUpdating || isVerifying}
showExtraButton={currentStep === EditStep.EditConfiguration}
extraButtonText={t('modal.common.back', { ns: 'pluginTrigger' })}
extraButtonVariant="secondary"
onExtraButtonClick={handleBack}
clickOutsideNotClose
wrapperClassName="z-101!"
bottomSlot={currentStep === EditStep.EditCredentials ? <EncryptedBottom /> : null}
<Dialog
open
disablePointerDismissal
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
)}
<DialogContent
backdropProps={{ forceRender: true }}
className="p-0"
>
<div data-testid="modal" data-title={title} data-disabled={isDisabled} className="flex max-h-[80dvh] flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
{title}
</DialogTitle>
<DialogCloseButton data-testid="modal-close-button" className="top-5 right-5 h-8 w-8 rounded-lg" />
</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} />
)}
{/* Multi-step indicator */}
<MultiSteps currentStep={currentStep} onStepClick={handleBack} />
<MultiSteps currentStep={currentStep} onStepClick={handleBack} />
{/* Step 1: Edit Credentials */}
{currentStep === EditStep.EditCredentials && (
<div className="mb-4">
{credentialsFormSchemas.length > 0 && (
<BaseForm
formSchemas={credentialsFormSchemas}
ref={credentialsFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
preventDefaultSubmit={true}
/>
{currentStep === EditStep.EditCredentials
? (
<div className="mb-4">
{credentialsFormSchemas.length > 0 && (
<BaseForm
formSchemas={credentialsFormSchemas}
ref={credentialsFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
preventDefaultSubmit={true}
/>
)}
</div>
)
: (
<div className="max-h-[70vh]">
<BaseForm
formSchemas={basicFormSchemas}
ref={basicFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4 mb-4"
/>
{parametersFormSchemas.length > 0 && (
<BaseForm
formSchemas={parametersFormSchemas}
ref={parametersFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
)}
</div>
)}
</div>
<div className="flex shrink-0 justify-between p-6 pt-5">
<div />
<div className="flex items-center">
{currentStep === EditStep.EditConfiguration && (
<>
<Button
data-testid="modal-extra-button"
variant="secondary"
onClick={handleBack}
disabled={isDisabled}
>
{t('modal.common.back', { ns: 'pluginTrigger' })}
</Button>
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
</>
)}
<Button
data-testid="modal-cancel-button"
onClick={onClose}
disabled={isDisabled}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
data-testid="modal-confirm-button"
className="ml-2"
variant="primary"
onClick={handleConfirm}
disabled={isDisabled}
>
{confirmButtonText}
</Button>
</div>
</div>
{currentStep === EditStep.EditCredentials && (
<div data-testid="modal-bottom-slot" className="shrink-0">
<EncryptedBottom />
</div>
)}
</div>
)}
{/* Step 2: Edit Configuration */}
{currentStep === EditStep.EditConfiguration && (
<div className="max-h-[70vh]">
{/* Basic form: subscription name and callback URL */}
<BaseForm
formSchemas={basicFormSchemas}
ref={basicFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4 mb-4"
/>
{/* Parameters */}
{parametersFormSchemas.length > 0 && (
<BaseForm
formSchemas={parametersFormSchemas}
ref={parametersFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
)}
</div>
)}
</Modal>
</DialogContent>
</Dialog>
)
}

View File

@ -2,13 +2,14 @@
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { isEqual } from 'es-toolkit/predicate'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
@ -133,26 +134,63 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props)
})),
], [t, subscription.name, subscription.endpoint, subscription.properties, propertiesSchema])
const title = t('subscription.list.item.actions.edit.title', { ns: 'pluginTrigger' })
const confirmButtonText = isUpdating ? t('operation.saving', { ns: 'common' }) : t('operation.save', { ns: 'common' })
return (
<Modal
title={t('subscription.list.item.actions.edit.title', { ns: 'pluginTrigger' })}
confirmButtonText={isUpdating ? t('operation.saving', { ns: 'common' }) : t('operation.save', { ns: 'common' })}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isUpdating}
clickOutsideNotClose
wrapperClassName="z-101!"
<Dialog
open
disablePointerDismissal
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
)}
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
</Modal>
<DialogContent
backdropProps={{ forceRender: true }}
className="p-0"
>
<div data-testid="modal" data-title={title} data-disabled={isUpdating} className="flex max-h-[80dvh] flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
{title}
</DialogTitle>
<DialogCloseButton data-testid="modal-close-button" className="top-5 right-5 h-8 w-8 rounded-lg" />
</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} />
)}
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
</div>
<div className="flex shrink-0 justify-between p-6 pt-5">
<div />
<div className="flex items-center">
<Button
data-testid="modal-cancel-button"
onClick={onClose}
disabled={isUpdating}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
data-testid="modal-confirm-button"
className="ml-2"
variant="primary"
onClick={handleConfirm}
disabled={isUpdating}
>
{confirmButtonText}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -2,13 +2,14 @@
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { isEqual } from 'es-toolkit/predicate'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
@ -147,26 +148,63 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) =
}),
], [t, subscription.name, subscription.endpoint, subscription.parameters, subscription.id, parametersSchema, detail?.plugin_id, detail?.provider])
const title = t('subscription.list.item.actions.edit.title', { ns: 'pluginTrigger' })
const confirmButtonText = isUpdating ? t('operation.saving', { ns: 'common' }) : t('operation.save', { ns: 'common' })
return (
<Modal
title={t('subscription.list.item.actions.edit.title', { ns: 'pluginTrigger' })}
confirmButtonText={isUpdating ? t('operation.saving', { ns: 'common' }) : t('operation.save', { ns: 'common' })}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isUpdating}
clickOutsideNotClose
wrapperClassName="z-101!"
<Dialog
open
disablePointerDismissal
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
)}
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
</Modal>
<DialogContent
backdropProps={{ forceRender: true }}
className="p-0"
>
<div data-testid="modal" data-title={title} data-disabled={isUpdating} className="flex max-h-[80dvh] flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
{title}
</DialogTitle>
<DialogCloseButton data-testid="modal-close-button" className="top-5 right-5 h-8 w-8 rounded-lg" />
</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} />
)}
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
</div>
<div className="flex shrink-0 justify-between p-6 pt-5">
<div />
<div className="flex items-center">
<Button
data-testid="modal-cancel-button"
onClick={onClose}
disabled={isUpdating}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
data-testid="modal-confirm-button"
className="ml-2"
variant="primary"
onClick={handleConfirm}
disabled={isUpdating}
>
{confirmButtonText}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -4,7 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { CreateButtonType, CreateSubscriptionButton } from './create'
import { CreateSubscriptionButton } from './create'
import { CreateButtonType } from './create/types'
import SubscriptionCard from './subscription-card'
import { useSubscriptionList } from './use-subscription-list'

View File

@ -7,7 +7,8 @@ import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { CreateButtonType, CreateSubscriptionButton } from './create'
import { CreateSubscriptionButton } from './create'
import { CreateButtonType } from './create/types'
import { DeleteConfirm } from './delete-confirm'
import { useSubscriptionList } from './use-subscription-list'

View File

@ -152,20 +152,20 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
),
}))
// Portal components need mocking for controlled positioning in tests
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
// Popover positioning is mocked for deterministic panel tests.
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({
children,
open,
}: {
children: ReactNode
open?: boolean
}) => (
<div data-testid="portal-to-follow-elem" data-open={open}>
<div data-testid="popover" data-open={open}>
{children}
</div>
),
PortalToFollowElemTrigger: ({
PopoverTrigger: ({
children,
render,
onClick,
@ -174,12 +174,19 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
render?: ReactNode
onClick?: () => void
}) => (
<div data-testid="portal-trigger" onClick={onClick}>
{render ?? children}
<div data-testid="popover-trigger" onClick={onClick}>
{render
? (
<>
{render}
{children}
</>
)
: children}
</div>
),
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
<div data-testid="portal-content">{children}</div>
PopoverContent: ({ children }: { children: ReactNode }) => (
<div data-testid="popover-content">{children}</div>
),
}))
@ -254,11 +261,15 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
// Mock Modal - headlessui Dialog has complex behavior
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow }: { children: ReactNode, isShow: boolean }) => (
isShow ? <div data-testid="modal">{children}</div> : null
// Mock Dialog to avoid Base UI focus/portal behavior in tests
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: ReactNode, open?: boolean }) => (
open ? <div>{children}</div> : null
),
DialogContent: ({ children }: { children: ReactNode }) => (
<div data-testid="modal">{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
// Mock VisualEditor - complex component with many dependencies
@ -1372,7 +1383,7 @@ describe('ToolSelector Component', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ToolSelector {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('portal-to-follow-elem')).toBeInTheDocument()
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should render ToolTrigger when no value and no trigger', () => {
@ -1394,7 +1405,7 @@ describe('ToolSelector Component', () => {
it('should render panel content', () => {
render(<ToolSelector {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
})
it('should render tool base form in panel', () => {
@ -1426,7 +1437,7 @@ describe('ToolSelector Component', () => {
{ wrapper: createWrapper() },
)
// The component should receive and use the nodeId
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
})
})
@ -1442,7 +1453,7 @@ describe('ToolSelector Component', () => {
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('portal-to-follow-elem')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
})
it('should use internal state when no trigger', () => {
@ -1450,7 +1461,7 @@ describe('ToolSelector Component', () => {
<ToolSelector {...defaultProps} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('portal-to-follow-elem')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
})
@ -1503,9 +1514,9 @@ describe('ToolSelector Component', () => {
)
// Click on portal trigger
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('popover-trigger'))
// State should not change when disabled
expect(screen.getByTestId('portal-to-follow-elem')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
})
@ -1523,7 +1534,7 @@ describe('ToolSelector Component', () => {
rerender(<ToolSelector {...defaultProps} onSelect={onSelect} />)
// Component should not trigger unnecessary re-renders
expect(screen.getByTestId('portal-to-follow-elem')).toBeInTheDocument()
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
})
@ -1541,7 +1552,7 @@ describe('Edge Cases', () => {
<ToolSelector {...defaultProps} value={undefined} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('portal-to-follow-elem')).toBeInTheDocument()
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle undefined selectedTools', () => {
@ -1549,7 +1560,7 @@ describe('Edge Cases', () => {
<ToolSelector {...defaultProps} selectedTools={undefined} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('portal-to-follow-elem')).toBeInTheDocument()
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle empty nodeOutputVars', () => {
@ -1557,7 +1568,7 @@ describe('Edge Cases', () => {
<ToolSelector {...defaultProps} nodeOutputVars={[]} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('portal-to-follow-elem')).toBeInTheDocument()
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle empty availableNodes', () => {
@ -1565,7 +1576,7 @@ describe('Edge Cases', () => {
<ToolSelector {...defaultProps} availableNodes={[]} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('portal-to-follow-elem')).toBeInTheDocument()
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@ -2515,11 +2526,11 @@ describe('Additional Coverage Tests', () => {
)
// Click on the trigger
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Should still be closed because disabled
expect(screen.getByTestId('portal-to-follow-elem')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
it('should handle trigger click when provider and tool exist', () => {
@ -2530,10 +2541,10 @@ describe('Additional Coverage Tests', () => {
)
// Without provider/tool, clicking should not open
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
expect(screen.getByTestId('portal-to-follow-elem')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
it('should early return from handleTriggerClick when disabled', () => {
@ -2546,11 +2557,11 @@ describe('Additional Coverage Tests', () => {
// Rerender with disabled=true
rerender(<ToolSelector {...defaultProps} disabled={true} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Verify it stays closed
expect(screen.getByTestId('portal-to-follow-elem')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
it('should set isShow when clicked with valid provider and tool', () => {
@ -2584,12 +2595,12 @@ describe('Additional Coverage Tests', () => {
)
// Click on the trigger - this should call handleTriggerClick
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Now that we have provider and tool, the click should work
// This tests lines 106-108 and 148
expect(screen.getByTestId('portal-to-follow-elem')).toBeInTheDocument()
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should not open when disabled is true even with valid provider', () => {
@ -2622,11 +2633,11 @@ describe('Additional Coverage Tests', () => {
)
// Click should not open because disabled=true
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Verify it stays closed due to disabled
expect(screen.getByTestId('portal-to-follow-elem')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
})

View File

@ -53,8 +53,7 @@ describe('SchemaModal', () => {
expect(screen.getByText('workflow.nodes.agent.parameterSchema')).toBeInTheDocument()
expect(screen.getByTestId('visual-editor')).toHaveTextContent('response')
const closeButton = document.body.querySelector('div.absolute.right-5.top-5')
fireEvent.click(closeButton!)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onClose).toHaveBeenCalled()
})

View File

@ -1,10 +1,13 @@
'use client'
import type { FC } from 'react'
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import { RiCloseLine } from '@remixicon/react'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import VisualEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor'
import { MittProvider, VisualEditorContextProvider } from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context'
@ -23,38 +26,43 @@ const SchemaModal: FC<Props> = ({
}) => {
const { t } = useTranslation()
return (
<Modal
isShow={isShow}
onClose={onClose}
className="max-w-[960px] p-0"
wrapperClassName="z-9999"
<Dialog
open={isShow}
onOpenChange={open => !open && onClose()}
>
<div className="pb-6">
{/* Header */}
<div className="relative flex p-6 pr-14 pb-3">
<div className="grow truncate title-2xl-semi-bold text-text-primary">
{t('nodes.agent.parameterSchema', { ns: 'workflow' })}
<DialogContent className="w-full max-w-[960px] p-0">
<div className="pb-6">
{/* Header */}
<div className="relative flex p-6 pr-14 pb-3">
<DialogTitle className="grow truncate title-2xl-semi-bold text-text-primary">
{t('nodes.agent.parameterSchema', { ns: 'workflow' })}
</DialogTitle>
<button
type="button"
aria-label={t('operation.close', { ns: 'common' })}
className="absolute top-5 right-5 flex h-8 w-8 items-center justify-center p-1.5"
onClick={onClose}
>
<span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" />
</button>
</div>
<div className="absolute top-5 right-5 flex h-8 w-8 items-center justify-center p-1.5" onClick={onClose}>
<RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" />
{/* Content */}
<div className="flex max-h-[700px] overflow-y-auto px-6 py-2">
<MittProvider>
<VisualEditorContextProvider>
<VisualEditor
className="w-full"
schema={schema}
rootName={rootName}
readOnly
>
</VisualEditor>
</VisualEditorContextProvider>
</MittProvider>
</div>
</div>
{/* Content */}
<div className="flex max-h-[700px] overflow-y-auto px-6 py-2">
<MittProvider>
<VisualEditorContextProvider>
<VisualEditor
className="w-full"
schema={schema}
rootName={rootName}
readOnly
>
</VisualEditor>
</VisualEditorContextProvider>
</MittProvider>
</div>
</div>
</Modal>
</DialogContent>
</Dialog>
)
}
export default React.memo(SchemaModal)

View File

@ -1,21 +1,18 @@
'use client'
import type {
OffsetOptions,
Placement,
} from '@floating-ui/react'
import type { OffsetOptions } from '@floating-ui/react'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { FC } from 'react'
import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
// eslint-disable-next-line no-restricted-imports -- legacy overlay migration is handled separately from this change
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { CollectionType } from '@/app/components/tools/types'
import Link from '@/next/link'
import {
@ -72,6 +69,8 @@ const ToolSelector: FC<Props> = ({
nodeId = '',
}) => {
const { t } = useTranslation()
const sideOffset = typeof offset === 'number' ? offset : (typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0))
const alignOffset = typeof offset === 'number' ? 0 : (typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0))
// Use custom hook for state management
const state = useToolSelectorState({ value, onSelect, onSelectMultiple })
@ -103,15 +102,14 @@ const ToolSelector: FC<Props> = ({
getSettingsValue,
} = state
const handleTriggerClick = () => {
if (disabled)
return
setIsShow(true)
}
// Determine portal open state based on controlled vs uncontrolled mode
const portalOpen = trigger ? controlledState : isShow
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
const handlePortalOpenChange = (nextOpen: boolean) => {
if (nextOpen && (disabled || !currentProvider || !currentTool))
return
onPortalOpenChange?.(nextOpen)
}
// Build error tooltip content
const renderErrorTip = () => (
@ -135,19 +133,13 @@ const ToolSelector: FC<Props> = ({
)
return (
<PortalToFollowElem
placement={placement}
offset={offset}
<Popover
open={portalOpen}
onOpenChange={onPortalOpenChange}
onOpenChange={handlePortalOpenChange}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => {
if (!currentProvider || !currentTool)
return
handleTriggerClick()
}}
<PopoverTrigger
nativeButton={false}
render={<div className="w-full" />}
>
{trigger}
@ -183,9 +175,14 @@ const ToolSelector: FC<Props> = ({
errorTip={renderErrorTip()}
/>
)}
</PortalToFollowElemTrigger>
</PopoverTrigger>
<PortalToFollowElemContent className="z-10">
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-none bg-transparent shadow-none"
>
<div className={cn(
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
@ -240,8 +237,8 @@ const ToolSelector: FC<Props> = ({
onParamsFormChange={handleParamsFormChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -82,15 +82,7 @@ vi.mock('../../plugin-page/plugin-info', () => ({
),
}))
// Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup
// Simplified mock that just renders children with tooltip content accessible
vi.mock('../../../base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" data-popup-content={popupContent}>
{children}
</div>
),
}))
vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip'))
// ==================== Test Utilities ====================
@ -236,8 +228,17 @@ describe('Action Component', () => {
render(<Action {...props} />)
// Assert
const tooltips = screen.getAllByTestId('tooltip')
expect(tooltips).toHaveLength(3)
const buttons = getActionButtons()
fireEvent.mouseEnter(buttons[0]!)
expect(screen.getByText('plugin.action.checkForUpdates'))!.toBeInTheDocument()
fireEvent.mouseLeave(buttons[0]!)
fireEvent.mouseEnter(buttons[1]!)
expect(screen.getByText('plugin.action.pluginInfo'))!.toBeInTheDocument()
fireEvent.mouseLeave(buttons[1]!)
fireEvent.mouseEnter(buttons[2]!)
expect(screen.getByText('plugin.action.delete'))!.toBeInTheDocument()
})
})
@ -256,8 +257,7 @@ describe('Action Component', () => {
fireEvent.click(getActionButtons()[0]!)
// Assert
// Assert
expect(screen.getByText('plugin.action.delete'))!.toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'plugin.action.delete' }))!.toBeInTheDocument()
})
it('should display plugin name in delete confirm content', () => {
@ -289,13 +289,13 @@ describe('Action Component', () => {
// Act
render(<Action {...props} />)
fireEvent.click(getActionButtons()[0]!)
expect(screen.getByText('plugin.action.delete'))!.toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'plugin.action.delete' }))!.toBeInTheDocument()
fireEvent.click(getDeleteCancelButton())
// Assert
return waitFor(() => {
expect(screen.queryByText('plugin.action.delete')).not.toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
@ -414,7 +414,7 @@ describe('Action Component', () => {
// Resolve and check modal closes
resolveUninstall!({ success: true })
await waitFor(() => {
expect(screen.queryByText('plugin.action.delete')).not.toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
})
@ -871,7 +871,7 @@ describe('Action Component', () => {
resolveFirst!({ success: true })
await waitFor(() => {
expect(screen.queryByText('plugin.action.delete')).not.toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})

View File

@ -11,7 +11,7 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiDeleteBinLine, RiInformation2Line, RiLoopLeftLine } from '@remixicon/react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback } from 'react'
@ -20,7 +20,6 @@ import { useModalContext } from '@/context/modal-context'
import { uninstallPlugin } from '@/service/plugins'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import ActionButton from '../../base/action-button'
import Tooltip from '../../base/tooltip'
import { checkForUpdates, fetchReleases } from '../install-plugin/hooks'
import PluginInfo from '../plugin-page/plugin-info'
import { PluginSource } from '../types'
@ -114,38 +113,59 @@ const Action: FC<Props> = ({
finally {
hideDeleting()
}
}, [installationId, onDelete])
}, [hideDeleteConfirm, hideDeleting, installationId, onDelete, showDeleting])
return (
<div className="flex space-x-1">
{/* Only plugin installed from GitHub need to check if it's the new version */}
{isShowFetchNewVersion
&& (
<Tooltip popupContent={t(`${i18nPrefix}.checkForUpdates`, { ns: 'plugin' })}>
<ActionButton onClick={handleFetchNewVersion}>
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton onClick={handleFetchNewVersion}>
<span className="i-ri-loop-left-line h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<TooltipContent>
{t(`${i18nPrefix}.checkForUpdates`, { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)}
{
isShowInfo
&& (
<Tooltip popupContent={t(`${i18nPrefix}.pluginInfo`, { ns: 'plugin' })}>
<ActionButton onClick={showPluginInfo}>
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton onClick={showPluginInfo}>
<span className="i-ri-information-2-line h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<TooltipContent>
{t(`${i18nPrefix}.pluginInfo`, { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)
}
{
isShowDelete
&& (
<Tooltip popupContent={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}>
<ActionButton
className="text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
onClick={showDeleteConfirm}
>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
className="text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
onClick={showDeleteConfirm}
>
<span className="i-ri-delete-bin-line h-4 w-4" />
</ActionButton>
)}
/>
<TooltipContent>
{t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)
}

View File

@ -63,19 +63,19 @@ vi.mock('@/service/use-plugins', () => ({
}))
// Mock popover component for ToolPicker and StrategyPicker
let mockPortalOpen = false
let forcePortalContentVisible = false // Allow tests to force content visibility
let mockPortalOnOpenChange: ((open: boolean) => void) | undefined
let mockPopoverOpen = false
let forcePopoverContentVisible = false // Allow tests to force content visibility
let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({ children, open = false, onOpenChange }: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpen = open
mockPortalOnOpenChange = onOpenChange
mockPopoverOpen = open
mockPopoverOnOpenChange = onOpenChange
return (
<div data-testid="portal-elem" data-open={open}>{children}</div>
<div data-testid="popover" data-open={open}>{children}</div>
)
},
PopoverTrigger: ({ children, render, onClick, className }: {
@ -85,11 +85,11 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
className?: string
}) => (
<div
data-testid="portal-trigger"
data-testid="popover-trigger"
onClick={(e) => {
onClick?.(e)
if (!onClick)
mockPortalOnOpenChange?.(!mockPortalOpen)
mockPopoverOnOpenChange?.(!mockPopoverOpen)
}}
className={className}
>
@ -101,39 +101,9 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
className?: string
popupClassName?: string
}) => {
if (!mockPortalOpen && !forcePortalContentVisible)
if (!mockPopoverOpen && !forcePopoverContentVisible)
return null
return <div data-testid="portal-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div>
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open = false, onOpenChange }: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpen = open
mockPortalOnOpenChange = onOpenChange
return <div data-testid="portal-elem" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick, className }: {
children?: React.ReactNode
onClick?: (e: React.MouseEvent) => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className, popupClassName }: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
if (!mockPortalOpen && !forcePortalContentVisible)
return null
return <div data-testid="portal-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div>
return <div data-testid="popover-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div>
},
}))
@ -362,9 +332,9 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
describe('auto-update-setting', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockPortalOnOpenChange = undefined
forcePortalContentVisible = false
mockPopoverOpen = false
mockPopoverOnOpenChange = undefined
forcePopoverContentVisible = false
mockPluginsData.plugins = []
})
@ -928,12 +898,12 @@ describe('auto-update-setting', () => {
render(<ToolPicker {...defaultProps} isShow={false} />)
// Assert
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
it('should render search box and tabs when isShow is true', () => {
// Arrange
mockPortalOpen = true
mockPopoverOpen = true
// Act
render(<ToolPicker {...defaultProps} isShow={true} />)
@ -944,7 +914,7 @@ describe('auto-update-setting', () => {
it('should show NoDataPlaceholder when no plugins and no search query', () => {
// Arrange
mockPortalOpen = true
mockPopoverOpen = true
mockPluginsData.plugins = []
// Act
@ -986,7 +956,7 @@ describe('auto-update-setting', () => {
it('should filter out non-marketplace plugins', () => {
// Arrange
mockPortalOpen = true
mockPopoverOpen = true
// Act
renderWithQueryClient(<ToolPicker {...defaultProps} isShow={true} />)
@ -997,7 +967,7 @@ describe('auto-update-setting', () => {
it('should filter by search query', () => {
// Arrange
mockPortalOpen = true
mockPopoverOpen = true
// Act
renderWithQueryClient(<ToolPicker {...defaultProps} isShow={true} />)
@ -1018,7 +988,7 @@ describe('auto-update-setting', () => {
// Act
render(<ToolPicker {...defaultProps} onShowChange={onShowChange} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('popover-trigger'))
// Assert
expect(onShowChange).toHaveBeenCalledWith(true)
@ -1026,7 +996,7 @@ describe('auto-update-setting', () => {
it('should call onChange when plugin is selected', () => {
// Arrange
mockPortalOpen = true
mockPopoverOpen = true
mockPluginsData.plugins = [
createMockPluginDetail({
plugin_id: 'test-plugin',
@ -1046,7 +1016,7 @@ describe('auto-update-setting', () => {
it('should unselect plugin when already selected', () => {
// Arrange
mockPortalOpen = true
mockPopoverOpen = true
mockPluginsData.plugins = [
createMockPluginDetail({
plugin_id: 'test-plugin',
@ -1070,7 +1040,7 @@ describe('auto-update-setting', () => {
it('handleCheckChange should be memoized with correct dependencies', () => {
// Arrange
const onChange = vi.fn()
mockPortalOpen = true
mockPopoverOpen = true
mockPluginsData.plugins = [
createMockPluginDetail({
plugin_id: 'plugin-1',

View File

@ -45,7 +45,7 @@ const PluginVersionPicker: FC<Props> = ({
trigger,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = -16,
alignOffset = 0,
onSelect,
}) => {
const { t } = useTranslation()

View File

@ -219,7 +219,7 @@ describe('InputFieldPanel', () => {
it('should render close button', () => {
render(<InputFieldPanel />)
const closeButton = screen.getByRole('button', { name: '' })
const closeButton = screen.getByRole('button', { name: 'common.operation.close' })
expect(closeButton)!.toBeInTheDocument()
})

View File

@ -121,6 +121,7 @@ const InputFieldPanel = () => {
<Divider type="vertical" className="mx-1 h-3" />
<button
type="button"
aria-label={t('operation.close', { ns: 'common' })}
className="flex size-6 shrink-0 items-center justify-center p-0.5"
onClick={closePanel}
>

View File

@ -113,29 +113,6 @@ vi.mock('@/app/components/tools/labels/selector', () => ({
),
}))
// Mock PortalToFollowElem for dropdown tests
let mockPortalOpenState = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => {
mockPortalOpenState = open
return (
<div data-testid="portal-elem" data-open={open} onClick={() => onOpenChange(!open)}>
{children}
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick: () => void, className?: string }) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
if (!mockPortalOpenState)
return null
return <div data-testid="portal-content" className={className}>{children}</div>
},
}))
// Test data factories
const createMockEmoji = (overrides = {}) => ({
content: '🔧',
@ -246,7 +223,6 @@ const createDefaultModalPayload = (overrides: Partial<WorkflowToolModalPayload>
describe('WorkflowToolConfigureButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
mockIsCurrentWorkspaceManager.mockReturnValue(true)
mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({
data: enabled ? createMockWorkflowToolDetail() : undefined,
@ -624,7 +600,6 @@ describe('WorkflowToolConfigureButton', () => {
describe('WorkflowToolAsModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
})
// Rendering Tests (REQUIRED)
@ -1486,7 +1461,6 @@ describe('WorkflowToolAsModal', () => {
describe('MethodSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
})
// Rendering Tests (REQUIRED)
@ -1709,7 +1683,6 @@ describe('MethodSelector', () => {
describe('Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
mockIsCurrentWorkspaceManager.mockReturnValue(true)
mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({
data: enabled ? createMockWorkflowToolDetail() : undefined,

View File

@ -91,18 +91,12 @@ describe('WorkflowOnboardingModal', () => {
expect(getTriggerHeading()).toBeInTheDocument()
})
it('should render ESC tip when shown', () => {
it('should not render ESC tip', () => {
renderComponent({ isShow: true })
expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument()
})
it('should not render ESC tip when hidden', () => {
renderComponent({ isShow: false })
expect(screen.queryByText('workflow.onboarding.escTip.press')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.onboarding.escTip.key')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.onboarding.escTip.toDismiss')).not.toBeInTheDocument()
})
it('should have correct styling for title', () => {
@ -386,20 +380,6 @@ describe('WorkflowOnboardingModal', () => {
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should have visible ESC key hint', () => {
renderComponent({ isShow: true })
const escKey = screen.getByText('workflow.onboarding.escTip.key')
expect(escKey.closest('.system-kbd')).toBeInTheDocument()
})
it('should have descriptive text for ESC functionality', () => {
renderComponent({ isShow: true })
expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument()
})
it('should have proper text color classes', () => {
renderComponent()
@ -443,8 +423,6 @@ describe('WorkflowOnboardingModal', () => {
expect(dialog).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument()
expect(getUserInputHeading()).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument()
expect(dialog).not.toContainElement(screen.getByText('workflow.onboarding.escTip.key'))
})
it('should coordinate between keyboard and click interactions', async () => {

View File

@ -1,9 +1,8 @@
'use client'
import type { FC } from 'react'
import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { BlockEnum } from '@/app/components/workflow/types'
import StartNodeSelectionPanel from './start-node-selection-panel'
@ -44,15 +43,6 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
/>
</div>
</DialogContent>
{/* TODO: reduce z-1002 to match @langgenius/dify-ui primitives after legacy overlay migration completes */}
<DialogPortal>
<div className="pointer-events-none fixed top-1/2 left-1/2 z-1002 flex -translate-x-1/2 translate-y-[165px] items-center gap-1 body-xs-regular text-text-quaternary">
<span>{t('onboarding.escTip.press', { ns: 'workflow' })}</span>
<ShortcutsName keys={[t('onboarding.escTip.key', { ns: 'workflow' })]} textColor="secondary" />
<span>{t('onboarding.escTip.toDismiss', { ns: 'workflow' })}</span>
</div>
</DialogPortal>
</Dialog>
)
}

View File

@ -376,15 +376,15 @@ describe('ToolPicker', () => {
renderToolPicker({ onShowChange })
await user.click(screen.getByRole('button', { name: 'open-picker' }))
expect(onShowChange).toHaveBeenCalledWith(true)
await user.click(screen.getByText('open-picker').closest('[role="button"]')!)
expect(onShowChange.mock.calls[0]?.[0]).toBe(true)
renderToolPicker({
disabled: true,
onShowChange: disabledOnShowChange,
})
await user.click(screen.getAllByRole('button', { name: 'open-picker' })[1]!)
await user.click(screen.getAllByText('open-picker')[1]!.closest('[role="button"]')!)
expect(disabledOnShowChange).not.toHaveBeenCalled()
})

View File

@ -1,25 +1,22 @@
'use client'
import type {
OffsetOptions,
Placement,
} from '@floating-ui/react'
import type { OffsetOptions } from '@floating-ui/react'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { FC } from 'react'
import type { ToolDefaultValue, ToolValue } from './types'
import type { CustomCollectionBackend } from '@/app/components/tools/types'
import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
// eslint-disable-next-line no-restricted-imports -- legacy overlay migration is handled separately from this change
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import AllTools from '@/app/components/workflow/block-selector/all-tools'
@ -71,6 +68,8 @@ const ToolPicker: FC<Props> = ({
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const [tags, setTags] = useState<string[]>([])
const sideOffset = typeof offset === 'number' ? offset : (typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0))
const alignOffset = typeof offset === 'number' ? 0 : (typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0))
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@ -121,10 +120,10 @@ const ToolPicker: FC<Props> = ({
const handleAddedCustomTool = invalidateCustomTools
const handleTriggerClick = () => {
if (disabled)
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen && disabled)
return
onShowChange(true)
onShowChange(nextOpen)
}
const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => {
@ -159,19 +158,23 @@ const ToolPicker: FC<Props> = ({
}
return (
<PortalToFollowElem
placement={placement}
offset={offset}
<Popover
open={isShow}
onOpenChange={onShowChange}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
onClick={handleTriggerClick}
<PopoverTrigger
nativeButton={false}
render={<div className="inline-block" />}
>
{trigger}
</PortalToFollowElemTrigger>
</PopoverTrigger>
<PortalToFollowElemContent className="z-1002">
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-none bg-transparent shadow-none"
>
<div className={cn('relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs', panelClassName)}>
<div className="p-2 pb-1">
<SearchBox
@ -210,8 +213,8 @@ const ToolPicker: FC<Props> = ({
}}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -149,7 +149,7 @@ describe('error-handle path', () => {
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.title'))
expect(onSelected).toHaveBeenCalledWith(ErrorHandleTypeEnum.defaultValue)
expect(onSelected.mock.calls[0]?.[0]).toBe(ErrorHandleTypeEnum.defaultValue)
})
it('should render the error tip only when a strategy exists', () => {

View File

@ -1,15 +1,16 @@
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { ErrorHandleTypeEnum } from './types'
type ErrorHandleTypeSelectorProps = {
@ -21,7 +22,6 @@ const ErrorHandleTypeSelector = ({
onSelected,
}: ErrorHandleTypeSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = [
{
value: ErrorHandleTypeEnum.none,
@ -42,37 +42,38 @@ const ErrorHandleTypeSelector = ({
const selectedOption = options.find(option => option.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={4}
>
<PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v)
}}
<DropdownMenu>
<DropdownMenuTrigger
render={(
<Button
size="small"
onClick={(e) => {
e.stopPropagation()
}}
/>
)}
>
<Button
size="small"
{selectedOption?.label}
<RiArrowDownSLine className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[280px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuRadioGroup
value={value}
onValueChange={onSelected}
>
{selectedOption?.label}
<RiArrowDownSLine className="h-3.5 w-3.5" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
options.map(option => (
<div
<DropdownMenuRadioItem
key={option.value}
className="flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover"
value={option.value}
closeOnClick
className="h-auto items-start rounded-lg p-2 pr-3"
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onSelected(option.value)
setOpen(false)
}}
>
<div className="mr-1 w-4 shrink-0">
@ -86,12 +87,12 @@ const ErrorHandleTypeSelector = ({
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
</div>
</div>
</DropdownMenuRadioItem>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,15 +1,15 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
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 { VarType } from '@/app/components/workflow/types'
type Props = {
@ -26,47 +26,39 @@ const VarReferencePicker: FC<Props> = ({
value,
onChange,
}) => {
const [open, setOpen] = useState(false)
const handleChange = useCallback((type: string) => {
return () => {
setOpen(false)
onChange(type)
}
}, [onChange])
return (
<div className={cn(className, !readonly && 'cursor-pointer select-none')}>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)} className="w-[120px] cursor-pointer">
<div className="flex h-8 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2.5 text-[13px] text-text-primary">
<div className="w-0 grow truncate capitalize" title={value}>{value}</div>
<RiArrowDownSLine className="h-3.5 w-3.5 shrink-0 text-text-secondary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{
zIndex: 100,
<Select
value={value}
readOnly={readonly}
onValueChange={(type) => {
if (type)
onChange(type)
}}
>
<SelectTrigger
className="h-8 w-[120px] cursor-pointer rounded-lg px-2.5 text-[13px] text-text-primary"
title={value}
>
<div className="w-[120px] rounded-lg bg-components-panel-bg p-1 shadow-sm">
{TYPES.map(type => (
<div
key={type}
className="flex h-[30px] cursor-pointer items-center justify-between rounded-lg pr-2 pl-3 text-[13px] text-text-primary hover:bg-state-base-hover"
onClick={handleChange(type)}
>
<div className="w-0 grow truncate capitalize">{type}</div>
{type === value && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<span className="capitalize">{value}</span>
</SelectTrigger>
<SelectContent
sideOffset={4}
popupClassName="w-[120px] rounded-lg border-0 p-1 shadow-sm"
listClassName="p-0"
>
{TYPES.map(type => (
<SelectItem
key={type}
value={type}
className="h-[30px] rounded-lg pr-2 pl-3 text-[13px] text-text-primary"
>
<SelectItemText className="px-0 capitalize">{type}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -1,7 +1,8 @@
import type { FC } from 'react'
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import { cn } from '@langgenius/dify-ui/cn'
import { CreateButtonType, CreateSubscriptionButton } from '@/app/components/plugins/plugin-detail-panel/subscription-list/create'
import { CreateSubscriptionButton } from '@/app/components/plugins/plugin-detail-panel/subscription-list/create'
import { CreateButtonType } from '@/app/components/plugins/plugin-detail-panel/subscription-list/create/types'
import { SubscriptionSelectorEntry } from '@/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry'
import { useSubscriptionList } from '@/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list'

View File

@ -264,8 +264,8 @@ describe('if-else path', () => {
await user.click(screen.getByText('Variable'))
fireEvent.change(screen.getByDisplayValue('12'), { target: { value: '42' } })
expect(onSelect).toHaveBeenCalledWith(ComparisonOperator.is)
expect(onNumberVarTypeChange).toHaveBeenCalledWith(NumberVarType.variable)
expect(onSelect.mock.calls[0]?.[0]).toBe(ComparisonOperator.is)
expect(onNumberVarTypeChange.mock.calls[0]?.[0]).toBe(NumberVarType.variable)
expect(onValueChange).toHaveBeenCalledWith('42')
})

View File

@ -2,17 +2,18 @@ import type { ComparisonOperator } from '../../types'
import type { VarType } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine } from '@remixicon/react'
import {
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils'
const i18nPrefix = 'nodes.ifElse'
@ -34,7 +35,6 @@ const ConditionOperator = ({
onSelect,
}: ConditionOperatorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = useMemo(() => {
return getOperators(varType, file).map((o) => {
@ -46,49 +46,48 @@ const ConditionOperator = ({
}, [t, varType, file])
const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
className={cn('shrink-0', !selectedOption && 'opacity-50', className)}
size="small"
variant="ghost"
disabled={disabled}
<DropdownMenu>
<DropdownMenuTrigger
render={(
<Button
className={cn('shrink-0', !selectedOption && 'opacity-50', className)}
size="small"
variant="ghost"
disabled={disabled}
/>
)}
>
{
selectedOption
? selectedOption.label
: t(`${i18nPrefix}.select`, { ns: 'workflow' })
}
<RiArrowDownSLine className="ml-1 h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuRadioGroup
value={selectedOption?.value}
onValueChange={onSelect}
>
{
selectedOption
? selectedOption.label
: t(`${i18nPrefix}.select`, { ns: 'workflow' })
}
<RiArrowDownSLine className="ml-1 h-3.5 w-3.5" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
options.map(option => (
<div
<DropdownMenuRadioItem
key={option.value}
className="flex h-7 cursor-pointer items-center rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => {
onSelect(option.value)
setOpen(false)
}}
value={option.value}
closeOnClick
className="h-7 rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary"
>
{option.label}
</div>
</DropdownMenuRadioItem>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -4,6 +4,18 @@ import type {
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { capitalize } from 'es-toolkit/string'
@ -14,11 +26,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { VarType } from '@/app/components/workflow/types'
import { variableTransformer } from '@/app/components/workflow/utils'
@ -63,58 +70,61 @@ const ConditionNumberInput = ({
return (
<div className="flex cursor-pointer items-center">
<PortalToFollowElem
<DropdownMenu
open={numberVarTypeVisible}
onOpenChange={setNumberVarTypeVisible}
placement="bottom-start"
offset={{ mainAxis: 2, crossAxis: 0 }}
>
<PortalToFollowElemTrigger onClick={() => setNumberVarTypeVisible(v => !v)}>
<Button
className="shrink-0"
variant="ghost"
size="small"
<DropdownMenuTrigger
render={(
<Button
className="shrink-0"
variant="ghost"
size="small"
/>
)}
>
{capitalize(numberVarType)}
<RiArrowDownSLine className="ml-px h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={2}
popupClassName="w-[112px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuRadioGroup
value={numberVarType}
onValueChange={onNumberVarTypeChange}
>
{capitalize(numberVarType)}
<RiArrowDownSLine className="ml-px h-3.5 w-3.5" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<div className="w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
options.map(option => (
<div
<DropdownMenuRadioItem
key={option}
value={option}
closeOnClick
className={cn(
'flex h-7 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover',
'h-7 rounded-md px-3',
'text-[13px] font-medium text-text-secondary',
numberVarType === option && 'bg-state-base-hover',
)}
onClick={() => {
onNumberVarTypeChange(option)
setNumberVarTypeVisible(false)
}}
>
{capitalize(option)}
</div>
</DropdownMenuRadioItem>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="mx-1 h-4 w-px bg-divider-regular"></div>
<div className="ml-0.5 w-0 grow">
{
numberVarType === NumberVarType.variable && (
<PortalToFollowElem
<Popover
open={variableSelectorVisible}
onOpenChange={setVariableSelectorVisible}
placement="bottom-start"
offset={{ mainAxis: 2, crossAxis: 0 }}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => setVariableSelectorVisible(v => !v)}
<PopoverTrigger
nativeButton={false}
render={<div className="w-full" />}
>
{
value && (
@ -133,16 +143,20 @@ const ConditionNumberInput = ({
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={2}
popupClassName="border-none bg-transparent shadow-none"
>
<div className={cn('w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-1 shadow-lg', isShort && 'w-[200px]')}>
<VarReferenceVars
vars={variables}
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
{

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ChunkStructureEnum } from '../../../types'
import Selector from '../selector'
@ -20,7 +20,7 @@ const options = [
]
describe('ChunkStructureSelector', () => {
it('should open the selector panel and close it after selecting an option', () => {
it('should open the selector panel and close it after selecting an option', async () => {
const onChange = vi.fn()
render(
@ -38,7 +38,9 @@ describe('ChunkStructureSelector', () => {
fireEvent.click(screen.getByText('Parent child'))
expect(onChange).toHaveBeenCalledWith(ChunkStructureEnum.parent_child)
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
})
})
it('should not open the selector when readonly is enabled', () => {
@ -51,7 +53,8 @@ describe('ChunkStructureSelector', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'custom-trigger' }))
const trigger = screen.getByText('custom-trigger').closest('[role="button"]')
fireEvent.click(trigger!)
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
})

View File

@ -2,13 +2,13 @@ import type { ReactNode } from 'react'
import type { ChunkStructureEnum } from '../../types'
import type { Option } from './type'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import OptionCard from '../option-card'
type SelectorProps = {
@ -32,37 +32,46 @@ const Selector = ({
onChange(optionId)
setOpen(false)
}, [onChange])
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (readonly && nextOpen)
return
setOpen(nextOpen)
}, [readonly])
return (
<PortalToFollowElem
placement="bottom-end"
offset={{
mainAxis: 0,
crossAxis: -8,
}}
<Popover
open={open}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
asChild
onClick={() => {
if (readonly)
return
setOpen(!open)
}}
{
trigger
? (
<PopoverTrigger
nativeButton={false}
render={<div />}
>
{trigger}
</PopoverTrigger>
)
: (
<PopoverTrigger
render={(
<Button
size="small"
variant="ghost-accent"
/>
)}
>
{t('panel.change', { ns: 'workflow' })}
</PopoverTrigger>
)
}
<PopoverContent
placement="bottom-end"
sideOffset={0}
alignOffset={-8}
popupClassName="border-none bg-transparent shadow-none"
>
{
trigger || (
<Button
size="small"
variant="ghost-accent"
>
{t('panel.change', { ns: 'workflow' })}
</Button>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className="w-[404px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]">
<div className="px-3 pt-3.5 system-sm-semibold text-text-primary">
{t('nodes.knowledgeBase.changeChunkStructure', { ns: 'workflow' })}
@ -86,8 +95,8 @@ const Selector = ({
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -427,7 +427,7 @@ describe('knowledge-retrieval path', () => {
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title/i }))
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.options.manual.title'))
expect(onSelect).toHaveBeenCalledWith(MetadataFilteringModeEnum.manual)
expect(onSelect.mock.calls[0]?.[0]).toBe(MetadataFilteringModeEnum.manual)
})
it('should remove stale metadata conditions and open the manual metadata panel', async () => {

View File

@ -1,15 +1,16 @@
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
type MetadataFilterSelectorProps = {
@ -21,7 +22,6 @@ const MetadataFilterSelector = ({
onSelect,
}: MetadataFilterSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = [
{
key: MetadataFilteringModeEnum.disabled,
@ -43,41 +43,35 @@ const MetadataFilterSelector = ({
const selectedOption = options.find(option => option.key === value)!
return (
<PortalToFollowElem
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
onClick={(e) => {
e.stopPropagation()
setOpen(!open)
}}
asChild
<DropdownMenu>
<DropdownMenuTrigger
render={(
<Button
variant="secondary"
size="small"
onClick={e => e.stopPropagation()}
/>
)}
>
<Button
variant="secondary"
size="small"
{selectedOption.value}
<RiArrowDownSLine className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[280px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuRadioGroup
value={value}
onValueChange={onSelect}
>
{selectedOption.value}
<RiArrowDownSLine className="h-3.5 w-3.5" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
options.map(option => (
<div
<DropdownMenuRadioItem
key={option.key}
className="flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover"
onClick={() => {
onSelect(option.key)
setOpen(false)
}}
value={option.key}
closeOnClick
className="h-auto items-start rounded-lg p-2 pr-3"
>
<div className="w-4 shrink-0">
{
@ -94,12 +88,12 @@ const MetadataFilterSelector = ({
{option.desc}
</div>
</div>
</div>
</DropdownMenuRadioItem>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -232,7 +232,6 @@ const EditCard: FC<EditCardProps> = ({
currentValue={currentFields.type}
items={maximumDepthReached ? MAXIMUM_DEPTH_TYPE_OPTIONS : TYPE_OPTIONS}
onSelect={handleTypeChange}
popupClassName="z-1000"
/>
{
currentFields.required && (

View File

@ -1,9 +1,16 @@
import type { FC } from 'react'
import type { ArrayType, Type } from '../../../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { RiCheckLine } from '@remixicon/react'
import { useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
export type TypeItem = {
value: Type | ArrayType
@ -26,45 +33,46 @@ const TypeSelector: FC<TypeSelectorProps> = ({
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
<Select<Type | ArrayType>
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
value={currentValue}
onValueChange={(nextValue) => {
const selected = items.find(item => item.value === nextValue)
if (selected)
onSelect(selected)
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className={cn(
'flex items-center rounded-[5px] p-0.5 pl-1 hover:bg-state-base-hover',
<SelectTrigger
className={cn(
'h-auto w-auto rounded-[5px] bg-transparent p-0.5 pl-1 hover:bg-state-base-hover',
open && 'bg-state-base-hover',
)}
>
<span className="system-xs-medium text-text-tertiary">{currentValue}</span>
<RiArrowDownSLine className="h-4 w-4 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={popupClassName}>
<div className="w-40 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5">
{items.map((item) => {
const isSelected = item.value === currentValue
return (
<div
key={item.value}
className="flex items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover"
onClick={() => {
onSelect(item)
setOpen(false)
}}
>
<span className="px-1 system-sm-medium text-text-secondary">{item.text}</span>
{isSelected && <RiCheckLine className="h-4 w-4 text-text-accent" />}
</div>
)
})}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
>
<span className="system-xs-medium text-text-tertiary">{currentValue}</span>
</SelectTrigger>
<SelectContent
sideOffset={4}
className={popupClassName}
popupClassName="w-40 rounded-xl border-[0.5px] p-1 shadow-lg shadow-shadow-shadow-5"
listClassName="p-0"
>
{items.map((item) => {
const isSelected = item.value === currentValue
return (
<SelectItem
key={item.value}
value={item.value}
className="gap-x-1 rounded-lg px-2 py-1"
>
<SelectItemText className="px-1 system-sm-medium text-text-secondary">{item.text}</SelectItemText>
{isSelected && <RiCheckLine className="h-4 w-4 text-text-accent" />}
<SelectItemIndicator className="hidden" />
</SelectItem>
)
})}
</SelectContent>
</Select>
)
}

View File

@ -353,8 +353,8 @@ describe('loop path', () => {
await user.click(screen.getByText('Variable'))
fireEvent.change(screen.getByDisplayValue('12'), { target: { value: '42' } })
expect(onSelect).toHaveBeenCalledWith(ComparisonOperator.is)
expect(onNumberVarTypeChange).toHaveBeenCalledWith(NumberVarType.variable)
expect(onSelect.mock.calls[0]?.[0]).toBe(ComparisonOperator.is)
expect(onNumberVarTypeChange.mock.calls[0]?.[0]).toBe(NumberVarType.variable)
expect(onValueChange).toHaveBeenCalledWith('42')
})

View File

@ -2,17 +2,18 @@ import type { ComparisonOperator } from '../../types'
import type { VarType } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine } from '@remixicon/react'
import {
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils'
const i18nPrefix = 'nodes.ifElse'
@ -34,7 +35,6 @@ const ConditionOperator = ({
onSelect,
}: ConditionOperatorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = useMemo(() => {
return getOperators(varType, file).map((o) => {
@ -46,49 +46,48 @@ const ConditionOperator = ({
}, [t, varType, file])
const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
className={cn('shrink-0', !selectedOption && 'opacity-50', className)}
size="small"
variant="ghost"
disabled={disabled}
<DropdownMenu>
<DropdownMenuTrigger
render={(
<Button
className={cn('shrink-0', !selectedOption && 'opacity-50', className)}
size="small"
variant="ghost"
disabled={disabled}
/>
)}
>
{
selectedOption
? selectedOption.label
: t(`${i18nPrefix}.select`, { ns: 'workflow' })
}
<RiArrowDownSLine className="ml-1 h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuRadioGroup
value={selectedOption?.value}
onValueChange={onSelect}
>
{
selectedOption
? selectedOption.label
: t(`${i18nPrefix}.select`, { ns: 'workflow' })
}
<RiArrowDownSLine className="ml-1 h-3.5 w-3.5" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
options.map(option => (
<div
<DropdownMenuRadioItem
key={option.value}
className="flex h-7 cursor-pointer items-center rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => {
onSelect(option.value)
setOpen(false)
}}
value={option.value}
closeOnClick
className="h-7 rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary"
>
{option.label}
</div>
</DropdownMenuRadioItem>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -4,6 +4,18 @@ import type {
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { capitalize } from 'es-toolkit/string'
@ -14,11 +26,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { VarType } from '@/app/components/workflow/types'
import { variableTransformer } from '@/app/components/workflow/utils'
@ -63,58 +70,61 @@ const ConditionNumberInput = ({
return (
<div className="flex cursor-pointer items-center">
<PortalToFollowElem
<DropdownMenu
open={numberVarTypeVisible}
onOpenChange={setNumberVarTypeVisible}
placement="bottom-start"
offset={{ mainAxis: 2, crossAxis: 0 }}
>
<PortalToFollowElemTrigger onClick={() => setNumberVarTypeVisible(v => !v)}>
<Button
className="shrink-0"
variant="ghost"
size="small"
<DropdownMenuTrigger
render={(
<Button
className="shrink-0"
variant="ghost"
size="small"
/>
)}
>
{capitalize(numberVarType)}
<RiArrowDownSLine className="ml-px h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={2}
popupClassName="w-[112px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuRadioGroup
value={numberVarType}
onValueChange={onNumberVarTypeChange}
>
{capitalize(numberVarType)}
<RiArrowDownSLine className="ml-px h-3.5 w-3.5" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<div className="w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
options.map(option => (
<div
<DropdownMenuRadioItem
key={option}
value={option}
closeOnClick
className={cn(
'flex h-7 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover',
'h-7 rounded-md px-3',
'text-[13px] font-medium text-text-secondary',
numberVarType === option && 'bg-state-base-hover',
)}
onClick={() => {
onNumberVarTypeChange(option)
setNumberVarTypeVisible(false)
}}
>
{capitalize(option)}
</div>
</DropdownMenuRadioItem>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="mx-1 h-4 w-px bg-divider-regular"></div>
<div className="ml-0.5 w-0 grow">
{
numberVarType === NumberVarType.variable && (
<PortalToFollowElem
<Popover
open={variableSelectorVisible}
onOpenChange={setVariableSelectorVisible}
placement="bottom-start"
offset={{ mainAxis: 2, crossAxis: 0 }}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => setVariableSelectorVisible(v => !v)}
<PopoverTrigger
nativeButton={false}
render={<div className="w-full" />}
>
{
value && (
@ -133,16 +143,20 @@ const ConditionNumberInput = ({
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={2}
popupClassName="border-none bg-transparent shadow-none"
>
<div className={cn('w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-1 shadow-lg', isShort && 'w-[200px]')}>
<VarReferenceVars
vars={variables}
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
{

View File

@ -9,8 +9,6 @@ import { VarInInspectType } from '@/types/workflow'
import useCurrentVars from '../hooks/use-inspect-vars-crud'
import { useNodesInteractions } from '../hooks/use-nodes-interactions'
import { useStore } from '../store'
// import ActionButton from '@/app/components/base/action-button'
// import Tooltip from '@/app/components/base/tooltip'
import Group from './group'
type Props = {

View File

@ -2,12 +2,11 @@
This document tracks the Dify-web migration away from legacy overlay APIs.
> **See also:** [`packages/dify-ui/README.md`] for the permanent overlay / portal / z-index contract of the replacement primitives. This document covers the one-off migration mechanics (allowlist, deprecated import paths, coexistence z-index strategy) and is expected to shrink and eventually be removed once the legacy overlays are gone.
> **See also:** [`packages/dify-ui/README.md`] for the permanent overlay / portal / z-index contract of the replacement primitives. This document covers the one-off migration mechanics (deprecated import paths and coexistence z-index strategy) and is expected to shrink and eventually be removed once the legacy overlays are gone.
## Scope
- Deprecated imports:
- `@/app/components/base/portal-to-follow-elem`
- `@/app/components/base/tooltip`
- `@/app/components/base/modal`
- `@/app/components/base/dialog`
@ -28,26 +27,18 @@ This document tracks the Dify-web migration away from legacy overlay APIs.
- `no-restricted-imports` blocks all deprecated imports listed above.
- The rule is enabled for normal source files (`.ts` / `.tsx`) and test files are excluded.
- Legacy `app/components/base/*` callers are temporarily allowlisted in `OVERLAY_MIGRATION_LEGACY_BASE_FILES` (`web/eslint.constants.mjs`).
- New files must not be added to the allowlist without migration owner approval.
## Migration phases
1. Business/UI features outside `app/components/base/**`
- Migrate old calls to semantic primitives from `@langgenius/dify-ui/*`.
- Keep deprecated imports out of newly touched files.
1. Legacy base components in allowlist
- Migrate allowlisted base callers gradually.
- Remove migrated files from `OVERLAY_MIGRATION_LEGACY_BASE_FILES` immediately.
1. Legacy base components
- Migrate legacy base callers gradually.
- Keep deprecated imports out of newly touched files.
1. Cleanup
- Remove remaining allowlist entries.
- Remove legacy overlay implementations when import count reaches zero.
## Allowlist maintenance
- If a migrated file was in the allowlist, remove it from `web/eslint.constants.mjs` in the same PR.
- Never increase allowlist scope to bypass new code.
## z-index strategy
All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index value:
@ -58,13 +49,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) |
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
| **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` |
| 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` |
`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,
@ -82,8 +72,6 @@ back to `z-9999`.
parent legacy overlay should be migrated instead.
- When migrating a legacy overlay that has a high z-index, remove the z-index
entirely — the new primitive's default `z-1002` handles it.
- `portalToFollowElemContentClassName` with z-index values (e.g. `z-1000`)
should be deleted when the surrounding legacy container is migrated.
### Post-migration cleanup

Some files were not shown because too many files have changed in this diff Show More