mirror of
https://github.com/langgenius/dify.git
synced 2026-05-08 20:08:36 +08:00
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:
parent
c6a5de3c18
commit
9331024d91
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
172
web/__mocks__/base-ui-dropdown-menu.tsx
Normal file
172
web/__mocks__/base-ui-dropdown-menu.tsx
Normal 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}</>
|
||||
@ -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}
|
||||
|
||||
65
web/__mocks__/base-ui-select.tsx
Normal file
65
web/__mocks__/base-ui-select.tsx
Normal 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} />
|
||||
95
web/__mocks__/base-ui-tooltip.tsx
Normal file
95
web/__mocks__/base-ui-tooltip.tsx
Normal 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}</>
|
||||
@ -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: ({
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>) => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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)
|
||||
@ -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 = {}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
@ -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'
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -138,6 +138,7 @@ const OAuthClientSettings = ({
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
disablePointerDismissal
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DialogContent
|
||||
|
||||
@ -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()]
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 => ({
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -45,7 +45,7 @@ const PluginVersionPicker: FC<Props> = ({
|
||||
trigger,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = -16,
|
||||
alignOffset = 0,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
{
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
{
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user