mirror of
https://github.com/langgenius/dify.git
synced 2026-06-22 11:11:09 +08:00
refactor: migrate from PortalToFollowElem to Popover component across various components (#35454)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
44a91e344c
commit
d65a6b4810
@ -488,11 +488,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/dataset-config/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -3060,14 +3055,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/declarations.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 11
|
||||
@ -3554,11 +3541,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3672,11 +3654,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
@ -3933,11 +3910,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/labels/selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/create-card.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -4123,11 +4095,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/tool-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -4313,14 +4280,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/agent-strategy.tsx": {
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
@ -4547,22 +4506,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -4742,11 +4685,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/code/dependency-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/code/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -4897,16 +4835,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -4957,11 +4885,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/if-else/components/condition-add.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -4977,11 +4900,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -5085,16 +5003,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -5110,11 +5018,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -5294,11 +5197,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/loop/components/condition-add.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -5314,11 +5212,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/loop/components/condition-number-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -6095,14 +5988,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/search-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/verify-state-modal.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
||||
@ -10,6 +10,72 @@ vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled)
|
||||
setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-trigger"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="popover-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
type PortalToFollowElemProps = {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
@ -209,20 +275,17 @@ describe('ContextVar', () => {
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
const varPickerTrigger = triggers[triggers.length - 1]
|
||||
const varPickerTrigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Select a different option
|
||||
const options = screen.getAllByText('var2')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
await user.click(options[0]!)
|
||||
await user.click(screen.getByText('var2'))
|
||||
|
||||
// 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 the trigger button', async () => {
|
||||
@ -233,16 +296,15 @@ describe('ContextVar', () => {
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
const varPickerTrigger = triggers[triggers.length - 1]
|
||||
const varPickerTrigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
// Open dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -18,18 +18,21 @@ type PortalToFollowElemProps = {
|
||||
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 })
|
||||
vi.mock('@langgenius/dify-ui/popover', () => {
|
||||
const PortalContext = React.createContext({
|
||||
open: false,
|
||||
onOpenChange: undefined as ((open: boolean) => void) | undefined,
|
||||
})
|
||||
|
||||
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
|
||||
const Popover = ({ children, open, onOpenChange }: PortalToFollowElemProps) => {
|
||||
return (
|
||||
<PortalContext.Provider value={{ open: !!open }}>
|
||||
<PortalContext.Provider value={{ open: !!open, onOpenChange }}>
|
||||
<div data-testid="portal">{children}</div>
|
||||
</PortalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
|
||||
const PopoverContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
|
||||
const { open } = React.useContext(PortalContext)
|
||||
if (!open)
|
||||
return null
|
||||
@ -40,24 +43,41 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
|
||||
const PopoverTrigger = ({ children, asChild, render, ...props }: PortalToFollowElemTriggerProps & { render?: React.ReactNode }) => {
|
||||
const { open, onOpenChange } = React.useContext(PortalContext)
|
||||
const content = render ?? children
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
props.onClick?.(e)
|
||||
if (!props.onClick)
|
||||
onOpenChange?.(!open)
|
||||
}
|
||||
|
||||
if (React.isValidElement(content)) {
|
||||
return React.cloneElement(content, {
|
||||
...props,
|
||||
'onClick': handleClick,
|
||||
'data-testid': 'portal-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
...props,
|
||||
'onClick': handleClick,
|
||||
'data-testid': 'portal-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
return (
|
||||
<div data-testid="portal-trigger" {...props}>
|
||||
{children}
|
||||
<div data-testid="portal-trigger" {...props} onClick={handleClick}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -3,15 +3,15 @@ import type { FC } from 'react'
|
||||
import type { IInputTypeIconProps } from '@/app/components/app/configuration/config-var/input-type-icon'
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import IconTypeIcon from '@/app/components/app/configuration/config-var/input-type-icon'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type Option = { name: string, value: string, type: string }
|
||||
export type Props = {
|
||||
@ -33,6 +33,7 @@ const VarItem: FC<{ item: Option }> = ({ item }) => (
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const VarPicker: FC<Props> = ({
|
||||
triggerClassName,
|
||||
className,
|
||||
@ -45,47 +46,51 @@ const VarPicker: FC<Props> = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
const currItem = options.find(item => item.value === value)
|
||||
const notSetVar = !currItem
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className={cn(triggerClassName)} onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
className,
|
||||
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
|
||||
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
|
||||
`
|
||||
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
|
||||
font-medium shadow-xs
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{value
|
||||
? (
|
||||
<VarItem item={currItem as Option} />
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(triggerClassName)}>
|
||||
<div className={cn(
|
||||
className,
|
||||
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
|
||||
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
|
||||
`
|
||||
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
|
||||
font-medium shadow-xs
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{currItem
|
||||
? (
|
||||
<VarItem item={currItem} />
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={8}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
{options.length > 0
|
||||
? (
|
||||
<div className="max-h-[50vh] w-[240px] overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
|
||||
{options.map(({ name, value, type }, index) => (
|
||||
{options.map(({ name, value, type }) => (
|
||||
<div
|
||||
key={index}
|
||||
key={value}
|
||||
className="flex cursor-pointer rounded-lg px-3 py-1 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange(value)
|
||||
@ -103,9 +108,9 @@ const VarPicker: FC<Props> = ({
|
||||
<div className="text-xs leading-normal text-text-tertiary">{t('feature.dataSet.queryVariable.noVarTip', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(VarPicker)
|
||||
|
||||
@ -2,16 +2,20 @@
|
||||
import type { FC } from 'react'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
value?: any
|
||||
onSelect: (value: any) => void
|
||||
value?: string
|
||||
onSelect: (value: string) => void
|
||||
exclude?: string[]
|
||||
}
|
||||
|
||||
@ -27,12 +31,9 @@ const MemberSelector: FC<Props> = ({
|
||||
const { data } = useMembers()
|
||||
|
||||
const currentValue = useMemo(() => {
|
||||
if (!data?.accounts)
|
||||
if (!data?.accounts || !value)
|
||||
return null
|
||||
const accounts = data.accounts || []
|
||||
if (!value)
|
||||
return null
|
||||
return accounts.find(account => account.id === value)
|
||||
return data.accounts.find(account => account.id === value) ?? null
|
||||
}, [data, value])
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
@ -47,37 +48,36 @@ const MemberSelector: FC<Props> = ({
|
||||
return name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
|| email.toLowerCase().includes(searchValue.toLowerCase())
|
||||
}).filter(account => !exclude.includes(account.id))
|
||||
}, [data, searchValue, exclude])
|
||||
}, [data, exclude, searchValue])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="w-full"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div
|
||||
data-testid="member-selector-trigger"
|
||||
className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}
|
||||
>
|
||||
{!currentValue && (
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
|
||||
)}
|
||||
{currentValue && (
|
||||
<>
|
||||
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
|
||||
<div className="grow truncate system-sm-medium text-text-secondary">{currentValue.name}</div>
|
||||
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
|
||||
</>
|
||||
)}
|
||||
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1002 } }}
|
||||
>
|
||||
<div
|
||||
data-testid="member-selector-trigger"
|
||||
className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}
|
||||
>
|
||||
{!currentValue && (
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
|
||||
)}
|
||||
{currentValue && (
|
||||
<>
|
||||
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
|
||||
<div className="grow truncate system-sm-medium text-text-secondary">{currentValue.name}</div>
|
||||
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
|
||||
</>
|
||||
)}
|
||||
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<div className="min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@ -105,8 +105,9 @@ const MemberSelector: FC<Props> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default MemberSelector
|
||||
|
||||
@ -4,6 +4,59 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionSelectorEntry } from '../selector-entry'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled)
|
||||
setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
return open ? <div data-testid="popover-content">{children}</div> : null
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
@ -92,6 +145,6 @@ describe('SubscriptionSelectorEntry', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), expect.any(Function))
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,28 +1,26 @@
|
||||
'use client'
|
||||
import type { SimpleSubscription } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine, RiWebhookLine } 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 { SubscriptionList } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||
import { SubscriptionListMode } from './types'
|
||||
import { useSubscriptionList } from './use-subscription-list'
|
||||
|
||||
type SubscriptionTriggerButtonProps = {
|
||||
selectedId?: string
|
||||
onClick?: () => void
|
||||
isOpen?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
|
||||
selectedId,
|
||||
onClick,
|
||||
isOpen = false,
|
||||
className,
|
||||
}) => {
|
||||
@ -44,7 +42,7 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
|
||||
}
|
||||
|
||||
if (subscriptions && subscriptions.length > 0) {
|
||||
const selectedSubscription = subscriptions?.find(sub => sub.id === selectedId)
|
||||
const selectedSubscription = subscriptions.find(sub => sub.id === selectedId)
|
||||
|
||||
if (!selectedSubscription) {
|
||||
return {
|
||||
@ -67,13 +65,13 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-1 rounded-lg px-2 transition-colors',
|
||||
'hover:bg-state-base-hover-alt',
|
||||
isOpen && 'bg-state-base-hover-alt',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<RiWebhookLine className={cn('h-3.5 w-3.5 shrink-0 text-text-secondary', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')} />
|
||||
<span className={cn('truncate system-xs-medium text-components-button-ghost-text', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')}>
|
||||
@ -97,22 +95,23 @@ export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<div>
|
||||
<SubscriptionTriggerButton
|
||||
selectedId={selectedId}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div>
|
||||
<SubscriptionTriggerButton
|
||||
selectedId={selectedId}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 11 } }}
|
||||
>
|
||||
<div className="rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||
<SubscriptionList
|
||||
mode={SubscriptionListMode.SELECTOR}
|
||||
@ -123,7 +122,7 @@ export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@ -61,6 +61,9 @@ const setupMocks = (plugins: PluginStatus[] = []) => {
|
||||
return { mockMutateAsync, mockHandleRefetch }
|
||||
}
|
||||
|
||||
const getTaskMenuTrigger = () =>
|
||||
document.getElementById('plugin-task-trigger')!.closest('[role="button"]') as HTMLElement
|
||||
|
||||
describe('usePluginTaskStatus Hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -637,7 +640,7 @@ describe('PluginTasks Component', () => {
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Click to open
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
// The popover content should be visible (PluginTaskList)
|
||||
// The popover content should be visible (PluginTaskList)
|
||||
@ -666,7 +669,7 @@ describe('PluginTasks Component', () => {
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
// Wait for popover content to render
|
||||
await waitFor(() => {
|
||||
@ -692,7 +695,7 @@ describe('PluginTasks Component', () => {
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
@ -713,16 +716,14 @@ describe('PluginTasks Component', () => {
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the clear all button in error section
|
||||
const clearButtons = screen.getAllByRole('button')
|
||||
if (clearButtons.length > 0)
|
||||
fireEvent.click(clearButtons[0]!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
@ -741,7 +742,7 @@ describe('PluginTasks Component', () => {
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
|
||||
@ -813,7 +814,7 @@ describe('PluginTasks Component', () => {
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
|
||||
})
|
||||
@ -825,7 +826,7 @@ describe('PluginTasks Component', () => {
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
@ -837,7 +838,7 @@ describe('PluginTasks Component', () => {
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
@ -892,7 +893,7 @@ describe('PluginTasks Integration', () => {
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
fireEvent.click(getTaskMenuTrigger())
|
||||
|
||||
// All sections should be visible
|
||||
const sections = document.querySelectorAll('.max-h-\\[300px\\]')
|
||||
|
||||
@ -97,6 +97,7 @@ const PluginTasks = () => {
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
nativeButton={false}
|
||||
render={<div />}
|
||||
disabled={!canOpenMenu}
|
||||
>
|
||||
|
||||
@ -61,35 +61,78 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock portal component for ToolPicker and StrategyPicker
|
||||
// Mock popover component for ToolPicker and StrategyPicker
|
||||
let mockPortalOpen = false
|
||||
let forcePortalContentVisible = false // Allow tests to force content visibility
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
|
||||
let mockPortalOnOpenChange: ((open: boolean) => void) | undefined
|
||||
vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||
Popover: ({ children, open = false, onOpenChange }: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
mockPortalOpen = open
|
||||
mockPortalOnOpenChange = onOpenChange
|
||||
return (
|
||||
<div data-testid="portal-elem" data-open={open}>{children}</div>
|
||||
)
|
||||
},
|
||||
PopoverTrigger: ({ children, render, onClick, className }: {
|
||||
children?: React.ReactNode
|
||||
render?: React.ReactNode
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
className?: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="portal-trigger"
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
if (!onClick)
|
||||
mockPortalOnOpenChange?.(!mockPortalOpen)
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{render ?? children}
|
||||
</div>
|
||||
),
|
||||
PopoverContent: ({ 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>
|
||||
},
|
||||
}))
|
||||
|
||||
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
|
||||
children?: React.ReactNode
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: {
|
||||
PortalToFollowElemContent: ({ children, className, popupClassName }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
}) => {
|
||||
// Allow forcing content visibility for testing option selection
|
||||
if (!mockPortalOpen && !forcePortalContentVisible)
|
||||
return null
|
||||
return <div data-testid="portal-content" className={className}>{children}</div>
|
||||
return <div data-testid="portal-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
@ -319,6 +362,7 @@ describe('auto-update-setting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpen = false
|
||||
mockPortalOnOpenChange = undefined
|
||||
forcePortalContentVisible = false
|
||||
mockPluginsData.plugins = []
|
||||
})
|
||||
|
||||
@ -4,8 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '@/app/components/plugins/types'
|
||||
import ToolPicker from '../tool-picker'
|
||||
|
||||
let portalOpen = false
|
||||
|
||||
const mockInstalledPluginList = vi.hoisted(() => ({
|
||||
data: {
|
||||
plugins: [] as PluginDetail[],
|
||||
@ -21,33 +19,51 @@ vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div data-testid="loading">loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const _React = await import('react')
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => (
|
||||
<PopoverContext.Provider value={{ open: !!open, setOpen: (nextOpen: boolean) => onOpenChange?.(nextOpen) }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
return open ? <div data-testid="popover-content" className={className}>{children}</div> : null
|
||||
}
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
portalOpen = open
|
||||
return <div>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => <button data-testid="trigger" onClick={onClick}>{children}</button>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => portalOpen ? <div data-testid="portal-content" className={className}>{children}</div> : null,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
@ -118,7 +134,6 @@ const createPlugin = (
|
||||
describe('ToolPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
portalOpen = false
|
||||
mockInstalledPluginList.data = {
|
||||
plugins: [],
|
||||
}
|
||||
@ -137,7 +152,7 @@ describe('ToolPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
fireEvent.click(screen.getByText('trigger'))
|
||||
|
||||
expect(onShowChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ActivePluginType } from '../../marketplace/constants'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants'
|
||||
@ -24,7 +24,6 @@ type Props = {
|
||||
onChange: (value: string[]) => void
|
||||
isShow: boolean
|
||||
onShowChange: (isShow: boolean) => void
|
||||
|
||||
}
|
||||
|
||||
const ToolPicker: FC<Props> = ({
|
||||
@ -35,43 +34,16 @@ const ToolPicker: FC<Props> = ({
|
||||
onShowChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const toggleShowPopup = useCallback(() => {
|
||||
onShowChange(!isShow)
|
||||
}, [onShowChange, isShow])
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
name: t('category.all', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
key: PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
name: t('category.models', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
key: PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
name: t('category.tools', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
key: PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
name: t('category.agents', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
key: PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
name: t('category.extensions', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
key: PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
name: t('category.datasources', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
key: PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
name: t('category.triggers', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
key: PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
name: t('category.bundles', { ns: 'plugin' }),
|
||||
},
|
||||
{ key: PLUGIN_TYPE_SEARCH_MAP.all, name: t('category.all', { ns: 'plugin' }) },
|
||||
{ key: PLUGIN_TYPE_SEARCH_MAP.model, name: t('category.models', { ns: 'plugin' }) },
|
||||
{ key: PLUGIN_TYPE_SEARCH_MAP.tool, name: t('category.tools', { ns: 'plugin' }) },
|
||||
{ key: PLUGIN_TYPE_SEARCH_MAP.agent, name: t('category.agents', { ns: 'plugin' }) },
|
||||
{ key: PLUGIN_TYPE_SEARCH_MAP.extension, name: t('category.extensions', { ns: 'plugin' }) },
|
||||
{ key: PLUGIN_TYPE_SEARCH_MAP.datasource, name: t('category.datasources', { ns: 'plugin' }) },
|
||||
{ key: PLUGIN_TYPE_SEARCH_MAP.trigger, name: t('category.triggers', { ns: 'plugin' }) },
|
||||
{ key: PLUGIN_TYPE_SEARCH_MAP.bundle, name: t('category.bundles', { ns: 'plugin' }) },
|
||||
]
|
||||
|
||||
const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
@ -89,14 +61,13 @@ const ToolPicker: FC<Props> = ({
|
||||
)
|
||||
})
|
||||
}, [data, pluginType, query, tags])
|
||||
const handleCheckChange = useCallback((pluginId: string) => {
|
||||
return () => {
|
||||
const newValue = value.includes(pluginId)
|
||||
? value.filter(id => id !== pluginId)
|
||||
: [...value, pluginId]
|
||||
onChange(newValue)
|
||||
}
|
||||
}, [onChange, value])
|
||||
|
||||
const handleCheckChange = (pluginId: string) => {
|
||||
const newValue = value.includes(pluginId)
|
||||
? value.filter(id => id !== pluginId)
|
||||
: [...value, pluginId]
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
const listContent = (
|
||||
<div className="max-h-[396px] overflow-y-auto">
|
||||
@ -105,7 +76,7 @@ const ToolPicker: FC<Props> = ({
|
||||
key={item.plugin_id}
|
||||
payload={item}
|
||||
isChecked={value.includes(item.plugin_id)}
|
||||
onCheckChange={handleCheckChange(item.plugin_id)}
|
||||
onCheckChange={() => handleCheckChange(item.plugin_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -121,21 +92,18 @@ const ToolPicker: FC<Props> = ({
|
||||
<NoDataPlaceholder className="h-[396px]" noPlugins={!query} />
|
||||
)
|
||||
|
||||
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="top"
|
||||
offset={0}
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="block w-full"
|
||||
onClick={toggleShowPopup}
|
||||
<Popover open={isShow} onOpenChange={onShowChange}>
|
||||
<PopoverTrigger render={resolvedTrigger} />
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
{trigger}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<div className={cn('relative min-h-20 w-full rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-xs')}>
|
||||
<div className="relative min-h-20 w-full rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<SearchBox
|
||||
search={query}
|
||||
@ -148,29 +116,27 @@ const ToolPicker: FC<Props> = ({
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b-[0.5px] border-divider-subtle bg-background-default-hover px-3 shadow-xs">
|
||||
<div className="flex h-8 items-center space-x-1">
|
||||
{
|
||||
tabs.map(tab => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
|
||||
'text-xs font-medium text-text-secondary',
|
||||
pluginType === tab.key && 'bg-state-base-hover-alt',
|
||||
)}
|
||||
key={tab.key}
|
||||
onClick={() => setPluginType(tab.key)}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
|
||||
'text-xs font-medium text-text-secondary',
|
||||
pluginType === tab.key && 'bg-state-base-hover-alt',
|
||||
)}
|
||||
key={tab.key}
|
||||
onClick={() => setPluginType(tab.key)}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!isLoading && filteredList.length > 0 && listContent}
|
||||
{!isLoading && filteredList.length === 0 && noData}
|
||||
{isLoading && loadingContent}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,65 @@ import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LabelSelector from '../selector'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled)
|
||||
setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return <div {...props}>{children}</div>
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useTags hook with controlled test data
|
||||
const mockTags = [
|
||||
{ name: 'agent', label: 'Agent' },
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Label } from '@/app/components/tools/labels/constant'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
@ -9,17 +14,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
|
||||
type LabelSelectorProps = {
|
||||
value: string[]
|
||||
onChange: (v: string[]) => void
|
||||
}
|
||||
|
||||
const LabelSelector: FC<LabelSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
@ -34,6 +35,7 @@ const LabelSelector: FC<LabelSelectorProps> = ({
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
}, { wait: 500 })
|
||||
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
@ -55,32 +57,31 @@ const LabelSelector: FC<LabelSelectorProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="block"
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
|
||||
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={cn(
|
||||
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
|
||||
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
>
|
||||
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
|
||||
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
|
||||
{!!value.length && selectedLabels}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
|
||||
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
|
||||
{!!value.length && selectedLabels}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1040">
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1040 } }}
|
||||
>
|
||||
<div className="relative w-[591px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
<div className="border-b-[0.5px] border-divider-regular p-2">
|
||||
<Input
|
||||
@ -114,9 +115,9 @@ const LabelSelector: FC<LabelSelectorProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -231,5 +231,9 @@ describe('CustomEdge', () => {
|
||||
|
||||
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)')
|
||||
expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all')
|
||||
expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
|
||||
opacity: '0',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -455,12 +455,12 @@ describe('ToolPicker', () => {
|
||||
|
||||
it('should create a custom collection from the add button and refresh custom tools', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderToolPicker({
|
||||
renderToolPicker({
|
||||
isShow: true,
|
||||
supportAddCustomTool: true,
|
||||
})
|
||||
|
||||
const addCustomToolButton = Array.from(container.querySelectorAll('button')).find((button) => {
|
||||
const addCustomToolButton = Array.from(document.querySelectorAll('button')).find((button) => {
|
||||
return button.className.includes('bg-components-button-primary-bg')
|
||||
})
|
||||
|
||||
|
||||
@ -8,17 +8,17 @@ 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 { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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'
|
||||
@ -43,7 +43,7 @@ type Props = {
|
||||
disabled: boolean
|
||||
trigger: React.ReactNode
|
||||
placement?: Placement
|
||||
offset?: OffsetOptions
|
||||
offset?: OffsetOptions | number
|
||||
isShow: boolean
|
||||
onShowChange: (isShow: boolean) => void
|
||||
onSelect: (tool: ToolDefaultValue) => void
|
||||
@ -120,12 +120,6 @@ const ToolPicker: FC<Props> = ({
|
||||
|
||||
const handleAddedCustomTool = invalidateCustomTools
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled)
|
||||
return
|
||||
onShowChange(true)
|
||||
}
|
||||
|
||||
const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => {
|
||||
onSelect(tool!)
|
||||
}
|
||||
@ -139,6 +133,11 @@ const ToolPicker: FC<Props> = ({
|
||||
setTrue: showEditCustomCollectionModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleShowAddCustomCollectionModal = useCallback(() => {
|
||||
onShowChange(false)
|
||||
showEditCustomCollectionModal()
|
||||
}, [onShowChange, showEditCustomCollectionModal])
|
||||
|
||||
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
|
||||
await createCustomCollection(data)
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
@ -157,20 +156,35 @@ const ToolPicker: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={handleTriggerClick}
|
||||
>
|
||||
{trigger}
|
||||
</PortalToFollowElemTrigger>
|
||||
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
|
||||
const resolvedOffset = typeof offset === 'object' && offset !== null
|
||||
? offset as { mainAxis?: number, crossAxis?: number, alignmentAxis?: number | null }
|
||||
: undefined
|
||||
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
|
||||
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
|
||||
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
return (
|
||||
<Popover
|
||||
open={isShow}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled && nextOpen)
|
||||
return
|
||||
onShowChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={resolvedTrigger}
|
||||
onClick={(e) => {
|
||||
if (disabled)
|
||||
e.preventDefault()
|
||||
}}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-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
|
||||
@ -181,7 +195,7 @@ const ToolPicker: FC<Props> = ({
|
||||
placeholder={t('searchTools', { ns: 'plugin' })!}
|
||||
supportAddCustomTool={supportAddCustomTool}
|
||||
onAddedCustomTool={handleAddedCustomTool}
|
||||
onShowAddCustomCollectionModal={showEditCustomCollectionModal}
|
||||
onShowAddCustomCollectionModal={handleShowAddCustomCollectionModal}
|
||||
inputClassName="grow"
|
||||
/>
|
||||
</div>
|
||||
@ -209,8 +223,8 @@ const ToolPicker: FC<Props> = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -63,6 +63,7 @@ const CustomEdge = ({
|
||||
_sourceRunningStatus,
|
||||
_targetRunningStatus,
|
||||
} = data
|
||||
const isTriggerVisible = !!(data?._hovering || isTriggerHovered || open)
|
||||
|
||||
const linearGradientId = useMemo(() => {
|
||||
if (
|
||||
@ -144,16 +145,15 @@ const CustomEdge = ({
|
||||
<div
|
||||
className={cn(
|
||||
'nopan nodrag',
|
||||
(data?._hovering || isTriggerHovered) ? 'block' : 'hidden',
|
||||
open && 'block!',
|
||||
'transition-opacity duration-150',
|
||||
data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`,
|
||||
data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`,
|
||||
)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
opacity: data._waitingRun ? 0.7 : 1,
|
||||
pointerEvents: isTriggerVisible ? 'all' : 'none',
|
||||
opacity: isTriggerVisible ? (data._waitingRun ? 0.7 : 1) : 0,
|
||||
}}
|
||||
onMouseEnter={() => setIsTriggerHovered(true)}
|
||||
onMouseLeave={() => setIsTriggerHovered(false)}
|
||||
|
||||
@ -0,0 +1,446 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { StrategyPluginDetail } from '@/app/components/plugins/types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { AgentStrategySelector } from '../agent-strategy-selector'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useSuspenseQuery: vi.fn(),
|
||||
useStrategyProviders: vi.fn(),
|
||||
useMarketplacePlugins: vi.fn(),
|
||||
useStrategyInfo: vi.fn(),
|
||||
refetchStrategyInfo: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useSuspenseQuery: mocks.useSuspenseQuery,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/system-features', () => ({
|
||||
systemFeaturesQueryOptions: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-strategy', () => ({
|
||||
useStrategyProviders: mocks.useStrategyProviders,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplacePlugins: mocks.useMarketplacePlugins,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/agent/use-config', () => ({
|
||||
useStrategyInfo: mocks.useStrategyInfo,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
|
||||
default: () => ({
|
||||
getIconUrl: (icon: string) => `https://example.com/${icon}`,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/search-input', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}) => (
|
||||
<input
|
||||
aria-label={placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/view-type-select', () => ({
|
||||
default: ({
|
||||
onChange,
|
||||
}: {
|
||||
viewType: string
|
||||
onChange: (value: string) => void
|
||||
}) => (
|
||||
<button type="button" onClick={() => onChange('grid')}>
|
||||
view-type
|
||||
</button>
|
||||
),
|
||||
ViewType: {
|
||||
flat: 'flat',
|
||||
grid: 'grid',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/tools', () => ({
|
||||
default: ({
|
||||
tools,
|
||||
onSelect,
|
||||
}: {
|
||||
tools: Array<{
|
||||
id: string
|
||||
name: string
|
||||
meta?: unknown
|
||||
tools: Array<{
|
||||
name: string
|
||||
label: string | { en_US?: string }
|
||||
output_schema?: Record<string, unknown>
|
||||
}>
|
||||
}>
|
||||
onSelect: (value: unknown, tool: {
|
||||
tool_name: string
|
||||
provider_name: string
|
||||
tool_label: string
|
||||
output_schema?: Record<string, unknown>
|
||||
provider_id: string
|
||||
meta?: unknown
|
||||
}) => void
|
||||
}) => (
|
||||
<div data-testid="tools-list">
|
||||
{tools.map(tool => (
|
||||
<div key={tool.id}>
|
||||
<span>{tool.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(undefined, {
|
||||
tool_name: tool.tools[0]!.name,
|
||||
provider_name: tool.id,
|
||||
tool_label: typeof tool.tools[0]!.label === 'string'
|
||||
? tool.tools[0]!.label
|
||||
: tool.tools[0]!.label.en_US || '',
|
||||
output_schema: tool.tools[0]!.output_schema,
|
||||
provider_id: tool.id,
|
||||
meta: tool.meta,
|
||||
})}
|
||||
>
|
||||
{`select-${tool.name}`}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/list', () => ({
|
||||
default: ({
|
||||
list,
|
||||
searchText,
|
||||
}: {
|
||||
list: Array<{ plugin_id: string }>
|
||||
searchText: string
|
||||
}) => (
|
||||
<div data-testid="plugin-list">
|
||||
{`${searchText}:${list.map(item => item.plugin_id).join(',')}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
|
||||
InstallPluginButton: ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick?: (event: { stopPropagation: () => void }) => void
|
||||
uniqueIdentifier: string
|
||||
size: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="install-plugin-button"
|
||||
onClick={() => onClick?.({ stopPropagation: vi.fn() })}
|
||||
>
|
||||
install-plugin
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
|
||||
SwitchPluginVersion: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: () => void
|
||||
uniqueIdentifier: string
|
||||
tooltip: ReactNode
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="switch-plugin-version"
|
||||
onClick={onChange}
|
||||
>
|
||||
switch-plugin-version
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
href: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) => <a href={href} className={className}>{children}</a>,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled)
|
||||
setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div data-testid="agent-strategy-trigger" onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
return open ? <div data-testid="agent-strategy-popover">{children}</div> : null
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ render }: { render: ReactNode }) => <div>{render}</div>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
const createStrategyDetail = (
|
||||
name: string,
|
||||
strategyName: string,
|
||||
strategyLabel: string,
|
||||
): StrategyPluginDetail => ({
|
||||
plugin_unique_identifier: `provider/${name}`,
|
||||
plugin_id: `plugin-${name}`,
|
||||
declaration: {
|
||||
identity: {
|
||||
author: 'Dify',
|
||||
name,
|
||||
description: { en_US: `${name} description` },
|
||||
icon: `${name}.png`,
|
||||
label: { en_US: `${name} label` },
|
||||
tags: [],
|
||||
},
|
||||
strategies: [{
|
||||
identity: {
|
||||
name: strategyName,
|
||||
author: 'Dify',
|
||||
label: { en_US: strategyLabel },
|
||||
},
|
||||
description: { en_US: `${strategyLabel} description` },
|
||||
parameters: [],
|
||||
output_schema: { result: { type: 'string' } },
|
||||
}],
|
||||
},
|
||||
meta: { version: '1.0.0' },
|
||||
} as unknown as StrategyPluginDetail)
|
||||
|
||||
describe('AgentStrategySelector', () => {
|
||||
const alphaDetail = createStrategyDetail('alpha', 'alpha-strategy', 'Alpha Strategy')
|
||||
const betaDetail = createStrategyDetail('beta', 'beta-strategy', 'Beta Strategy')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mocks.useSuspenseQuery.mockReturnValue({ data: true })
|
||||
mocks.useStrategyProviders.mockReturnValue({ data: [alphaDetail, betaDetail] })
|
||||
mocks.useMarketplacePlugins.mockReturnValue({
|
||||
queryPluginsWithDebounced: mocks.queryPluginsWithDebounced,
|
||||
plugins: [{ plugin_id: 'market-agent' }],
|
||||
})
|
||||
mocks.useStrategyInfo.mockReturnValue({
|
||||
strategyStatus: undefined,
|
||||
refetch: mocks.refetchStrategyInfo,
|
||||
})
|
||||
})
|
||||
|
||||
it('filters strategies and queries marketplace when searching', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AgentStrategySelector
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('agent-strategy-trigger'))
|
||||
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('beta')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plugin-list')).toHaveTextContent(':market-agent')
|
||||
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'nodes.agent.strategy.searchPlaceholder' }),
|
||||
'alp',
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.queryPluginsWithDebounced).toHaveBeenLastCalledWith({
|
||||
query: 'alp',
|
||||
category: PluginCategoryEnum.agent,
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument()
|
||||
expect(screen.queryByText('beta')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('maps the selected tool and closes the popover', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentStrategySelector
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('agent-strategy-trigger'))
|
||||
await user.click(screen.getByRole('button', { name: 'select-alpha' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
agent_strategy_name: 'alpha-strategy',
|
||||
agent_strategy_provider_name: 'provider/alpha',
|
||||
agent_strategy_label: 'Alpha Strategy',
|
||||
agent_output_schema: { result: { type: 'string' } },
|
||||
plugin_unique_identifier: 'provider/alpha',
|
||||
meta: { version: '1.0.0' },
|
||||
})
|
||||
expect(screen.queryByTestId('agent-strategy-popover')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the plugin-not-installed warning for external strategies', () => {
|
||||
mocks.useStrategyInfo.mockReturnValue({
|
||||
strategyStatus: {
|
||||
plugin: {
|
||||
source: 'external',
|
||||
installed: false,
|
||||
},
|
||||
isExistInPlugin: true,
|
||||
},
|
||||
refetch: mocks.refetchStrategyInfo,
|
||||
})
|
||||
|
||||
render(
|
||||
<AgentStrategySelector
|
||||
value={{
|
||||
agent_strategy_provider_name: 'provider/alpha',
|
||||
agent_strategy_name: 'alpha-strategy',
|
||||
agent_strategy_label: 'Alpha Strategy',
|
||||
agent_output_schema: {},
|
||||
plugin_unique_identifier: 'provider/alpha',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('nodes.agent.pluginNotInstalled')).toBeInTheDocument()
|
||||
expect(screen.getByText('nodes.agent.pluginNotInstalledDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders install and switch-version actions for marketplace strategies', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
mocks.useStrategyInfo.mockReturnValueOnce({
|
||||
strategyStatus: {
|
||||
plugin: {
|
||||
source: 'marketplace',
|
||||
installed: false,
|
||||
},
|
||||
isExistInPlugin: false,
|
||||
},
|
||||
refetch: mocks.refetchStrategyInfo,
|
||||
})
|
||||
|
||||
const { rerender } = render(
|
||||
<AgentStrategySelector
|
||||
value={{
|
||||
agent_strategy_provider_name: 'provider/alpha',
|
||||
agent_strategy_name: 'alpha-strategy',
|
||||
agent_strategy_label: 'Alpha Strategy',
|
||||
agent_output_schema: {},
|
||||
plugin_unique_identifier: 'provider/alpha',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('install-plugin-button')).toBeInTheDocument()
|
||||
|
||||
mocks.useStrategyInfo.mockReturnValue({
|
||||
strategyStatus: {
|
||||
plugin: {
|
||||
source: 'marketplace',
|
||||
installed: true,
|
||||
},
|
||||
isExistInPlugin: false,
|
||||
},
|
||||
refetch: mocks.refetchStrategyInfo,
|
||||
})
|
||||
|
||||
rerender(
|
||||
<AgentStrategySelector
|
||||
value={{
|
||||
agent_strategy_provider_name: 'provider/alpha',
|
||||
agent_strategy_name: 'alpha-strategy',
|
||||
agent_strategy_label: 'Alpha Strategy',
|
||||
agent_output_schema: {},
|
||||
plugin_unique_identifier: 'provider/alpha',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('switch-plugin-version'))
|
||||
|
||||
expect(mocks.refetchStrategyInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -4,14 +4,21 @@ import type { Strategy } from './agent-strategy'
|
||||
import type { StrategyPluginDetail } from '@/app/components/plugins/types'
|
||||
import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { RiArrowDownSLine, RiErrorWarningFill } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ToolTipContent } from '@/app/components/base/tooltip/content'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
@ -36,8 +43,11 @@ const NotFoundWarn = (props: {
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<div><RiErrorWarningFill className="size-4 text-text-destructive" /></div>}
|
||||
/>
|
||||
<TooltipContent className="w-[180px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<h3 className="font-semibold text-text-primary">
|
||||
{title}
|
||||
@ -51,11 +61,7 @@ const NotFoundWarn = (props: {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<RiErrorWarningFill className="size-4 text-text-destructive" />
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -66,18 +72,18 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s
|
||||
id: item.plugin_unique_identifier,
|
||||
author: item.declaration.identity.author,
|
||||
name: item.declaration.identity.name,
|
||||
description: item.declaration.identity.description as any,
|
||||
description: item.declaration.identity.description as ToolWithProvider['description'],
|
||||
plugin_id: item.plugin_id,
|
||||
icon: getIcon(item.declaration.identity.icon),
|
||||
label: item.declaration.identity.label as any,
|
||||
label: item.declaration.identity.label as ToolWithProvider['label'],
|
||||
type: CollectionType.all,
|
||||
meta: item.meta,
|
||||
tools: item.declaration.strategies.map(strategy => ({
|
||||
name: strategy.identity.name,
|
||||
author: strategy.identity.author,
|
||||
label: strategy.identity.label as any,
|
||||
label: strategy.identity.label as ToolWithProvider['tools'][number]['label'],
|
||||
description: strategy.description,
|
||||
parameters: strategy.parameters as any,
|
||||
parameters: strategy.parameters as unknown as ToolWithProvider['tools'][number]['parameters'],
|
||||
output_schema: strategy.output_schema,
|
||||
labels: [],
|
||||
})),
|
||||
@ -151,76 +157,82 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
|
||||
category: PluginCategoryEnum.agent,
|
||||
})
|
||||
}
|
||||
}, [query])
|
||||
}, [enable_marketplace, fetchPlugins, query])
|
||||
|
||||
const pluginRef = useRef<ListRef>(null)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open={open} onOpenChange={setOpen} placement="bottom">
|
||||
<PortalToFollowElemTrigger className="w-full">
|
||||
<div
|
||||
className="flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 select-none hover:bg-state-base-hover-alt"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{ }
|
||||
{icon && (
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<img
|
||||
src={icon}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
|
||||
alt="icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={cn(value ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', 'px-1 text-xs')}
|
||||
>
|
||||
{value?.agent_strategy_label || t('nodes.agent.strategy.selectTip', { ns: 'workflow' })}
|
||||
</p>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{showInstallButton && value && (
|
||||
<InstallPluginButton
|
||||
onClick={e => e.stopPropagation()}
|
||||
size="small"
|
||||
uniqueIdentifier={value.plugin_unique_identifier}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 select-none hover:bg-state-base-hover-alt">
|
||||
{icon && (
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<img
|
||||
src={icon}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
|
||||
alt="icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPluginNotInstalledWarn
|
||||
? (
|
||||
<NotFoundWarn
|
||||
title={t('nodes.agent.pluginNotInstalled', { ns: 'workflow' })}
|
||||
description={t('nodes.agent.pluginNotInstalledDesc', { ns: 'workflow' })}
|
||||
/>
|
||||
)
|
||||
: showUnsupportedStrategy
|
||||
<p
|
||||
className={cn(value ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', 'px-1 text-xs')}
|
||||
>
|
||||
{value?.agent_strategy_label || t('nodes.agent.strategy.selectTip', { ns: 'workflow' })}
|
||||
</p>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{showInstallButton && value && (
|
||||
<InstallPluginButton
|
||||
onClick={e => e.stopPropagation()}
|
||||
size="small"
|
||||
uniqueIdentifier={value.plugin_unique_identifier}
|
||||
/>
|
||||
)}
|
||||
{showPluginNotInstalledWarn
|
||||
? (
|
||||
<NotFoundWarn
|
||||
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
|
||||
description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })}
|
||||
title={t('nodes.agent.pluginNotInstalled', { ns: 'workflow' })}
|
||||
description={t('nodes.agent.pluginNotInstalledDesc', { ns: 'workflow' })}
|
||||
/>
|
||||
)
|
||||
: <RiArrowDownSLine className="size-4 text-text-tertiary" />}
|
||||
{showSwitchVersion && (
|
||||
<SwitchPluginVersion
|
||||
uniqueIdentifier={value.plugin_unique_identifier}
|
||||
tooltip={(
|
||||
<ToolTipContent
|
||||
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
|
||||
>
|
||||
{t('nodes.agent.strategyNotFoundDescAndSwitchVersion', { ns: 'workflow' })}
|
||||
</ToolTipContent>
|
||||
)}
|
||||
onChange={() => {
|
||||
refetchStrategyInfo()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
: showUnsupportedStrategy
|
||||
? (
|
||||
<NotFoundWarn
|
||||
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
|
||||
description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })}
|
||||
/>
|
||||
)
|
||||
: <RiArrowDownSLine className="size-4 text-text-tertiary" />}
|
||||
{showSwitchVersion && value && (
|
||||
<SwitchPluginVersion
|
||||
uniqueIdentifier={value.plugin_unique_identifier}
|
||||
tooltip={(
|
||||
<div className="w-[180px] space-y-1 text-xs">
|
||||
<h3 className="font-semibold text-text-primary">
|
||||
{t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
|
||||
</h3>
|
||||
<p className="text-text-tertiary">
|
||||
{t('nodes.agent.strategyNotFoundDescAndSwitchVersion', { ns: 'workflow' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
onChange={() => {
|
||||
refetchStrategyInfo()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 10 } }}
|
||||
>
|
||||
<div className="w-[388px] overflow-hidden rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow">
|
||||
<header className="flex gap-1 p-2">
|
||||
<SearchInput placeholder={t('nodes.agent.strategy.searchPlaceholder', { ns: 'workflow' })} value={query} onChange={setQuery} className="w-full" />
|
||||
@ -260,8 +272,8 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { VarType as VarKindType } from '../../../../tool/types'
|
||||
@ -38,46 +42,53 @@ const createProps = (
|
||||
],
|
||||
varName: '',
|
||||
variableCategory: 'system',
|
||||
WrapElem: 'div',
|
||||
VarPickerWrap: 'div',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderWithPopover = (
|
||||
overrides: Partial<ComponentProps<typeof VarReferencePickerTrigger>> = {},
|
||||
) => {
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Popover onOpenChange={onOpenChange}>
|
||||
<VarReferencePickerTrigger
|
||||
{...createProps(overrides)}
|
||||
/>
|
||||
<PopoverContent popupClassName="border-none bg-transparent p-0 shadow-none">
|
||||
<div>picker-content</div>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
return { onOpenChange }
|
||||
}
|
||||
|
||||
describe('VarReferencePickerTrigger', () => {
|
||||
it('should show the placeholder state and open the picker for variable mode', () => {
|
||||
const setOpen = vi.fn()
|
||||
render(
|
||||
<VarReferencePickerTrigger
|
||||
{...createProps({
|
||||
placeholder: 'Pick variable',
|
||||
setOpen,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const { onOpenChange } = renderWithPopover({
|
||||
placeholder: 'Pick variable',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Pick variable'))!.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
|
||||
expect(setOpen).toHaveBeenCalledWith(true)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything())
|
||||
})
|
||||
|
||||
it('should render the selected variable state and clear it', () => {
|
||||
const handleClearVar = vi.fn()
|
||||
const handleVariableJump = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferencePickerTrigger
|
||||
{...createProps({
|
||||
handleClearVar,
|
||||
handleVariableJump,
|
||||
hasValue: true,
|
||||
outputVarNode: { title: 'Source Node', desc: '', type: BlockEnum.Code },
|
||||
outputVarNodeId: 'node-a',
|
||||
type: VarType.string,
|
||||
value: ['node-a', 'answer'],
|
||||
varName: 'answer',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
renderWithPopover({
|
||||
handleClearVar,
|
||||
handleVariableJump,
|
||||
hasValue: true,
|
||||
outputVarNode: { title: 'Source Node', desc: '', type: BlockEnum.Code },
|
||||
outputVarNodeId: 'node-a',
|
||||
type: VarType.string,
|
||||
value: ['node-a', 'answer'],
|
||||
varName: 'answer',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Source Node'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('answer'))!.toBeInTheDocument()
|
||||
@ -93,20 +104,16 @@ describe('VarReferencePickerTrigger', () => {
|
||||
const setControlFocus = vi.fn()
|
||||
const setOpen = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferencePickerTrigger
|
||||
{...createProps({
|
||||
isConstant: true,
|
||||
isSupportConstantValue: true,
|
||||
schemaWithDynamicSelect: {
|
||||
type: 'text-input',
|
||||
} as never,
|
||||
setOpen,
|
||||
setControlFocus,
|
||||
value: 'constant-value',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
renderWithPopover({
|
||||
isConstant: true,
|
||||
isSupportConstantValue: true,
|
||||
schemaWithDynamicSelect: {
|
||||
type: 'text-input',
|
||||
} as never,
|
||||
setOpen,
|
||||
setControlFocus,
|
||||
value: 'constant-value',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
|
||||
expect(setControlFocus).toHaveBeenCalledTimes(1)
|
||||
@ -116,38 +123,27 @@ describe('VarReferencePickerTrigger', () => {
|
||||
})
|
||||
|
||||
it('should render add button trigger in table mode', () => {
|
||||
render(
|
||||
<VarReferencePickerTrigger
|
||||
{...createProps({
|
||||
hasValue: true,
|
||||
isAddBtnTrigger: true,
|
||||
isInTable: true,
|
||||
value: ['node-a', 'answer'],
|
||||
varName: 'answer',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
renderWithPopover({
|
||||
hasValue: true,
|
||||
isAddBtnTrigger: true,
|
||||
isInTable: true,
|
||||
value: ['node-a', 'answer'],
|
||||
varName: 'answer',
|
||||
})
|
||||
|
||||
expect(document.querySelector('button'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-button'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stay inert in readonly mode and show value type placeholder badge', () => {
|
||||
const setOpen = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferencePickerTrigger
|
||||
{...createProps({
|
||||
placeholder: 'Readonly placeholder',
|
||||
readonly: true,
|
||||
setOpen,
|
||||
typePlaceHolder: 'string',
|
||||
valueTypePlaceHolder: 'text',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const { onOpenChange } = renderWithPopover({
|
||||
placeholder: 'Readonly placeholder',
|
||||
readonly: true,
|
||||
typePlaceHolder: 'string',
|
||||
valueTypePlaceHolder: 'text',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
|
||||
expect(setOpen).not.toHaveBeenCalled()
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('string'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('text'))!.toBeInTheDocument()
|
||||
})
|
||||
@ -155,17 +151,13 @@ describe('VarReferencePickerTrigger', () => {
|
||||
it('should show loading placeholder and remove rows in table mode', () => {
|
||||
const onRemove = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferencePickerTrigger
|
||||
{...createProps({
|
||||
hasValue: false,
|
||||
isInTable: true,
|
||||
isLoading: true,
|
||||
onRemove,
|
||||
placeholder: 'Loading variable',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
renderWithPopover({
|
||||
hasValue: false,
|
||||
isInTable: true,
|
||||
isLoading: true,
|
||||
onRemove,
|
||||
placeholder: 'Loading variable',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Loading variable'))!.toBeInTheDocument()
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import type { Tool } from '@/app/components/tools/types'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import type { Node, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiArrowDownSLine, RiCloseLine, RiErrorWarningFill, RiLoader4Line, RiMoreLine } from '@remixicon/react'
|
||||
@ -68,8 +69,6 @@ type Props = {
|
||||
varKindTypes: Array<{ label: string, value: VarKindType }>
|
||||
varName: string
|
||||
variableCategory: string
|
||||
WrapElem: React.ElementType
|
||||
VarPickerWrap: React.ElementType
|
||||
}
|
||||
|
||||
const VarReferencePickerTrigger: FC<Props> = ({
|
||||
@ -114,9 +113,14 @@ const VarReferencePickerTrigger: FC<Props> = ({
|
||||
varKindTypes,
|
||||
varName,
|
||||
variableCategory,
|
||||
VarPickerWrap,
|
||||
WrapElem,
|
||||
}) => {
|
||||
const handleTriggerReadonlyClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!readonly)
|
||||
return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const pill = (
|
||||
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
|
||||
{hasValue
|
||||
@ -212,18 +216,36 @@ const VarReferencePickerTrigger: FC<Props> = ({
|
||||
)
|
||||
: pill
|
||||
|
||||
return (
|
||||
<WrapElem
|
||||
onClick={() => {
|
||||
if (readonly)
|
||||
return
|
||||
if (!isConstant)
|
||||
setOpen(!open)
|
||||
else
|
||||
setControlFocus(Date.now())
|
||||
}}
|
||||
const variablePicker = (
|
||||
<div className="h-full grow">
|
||||
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
|
||||
{hoveredPill}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const resolvedVariablePicker = isSupportConstantValue
|
||||
? (
|
||||
readonly
|
||||
? variablePicker
|
||||
: (
|
||||
<PopoverTrigger
|
||||
render={variablePicker}
|
||||
onClick={handleTriggerReadonlyClick}
|
||||
/>
|
||||
)
|
||||
)
|
||||
: variablePicker
|
||||
|
||||
const triggerContent = (
|
||||
<div
|
||||
className={cn(className, 'group/picker-trigger-wrap relative flex!', !readonly && 'cursor-pointer')}
|
||||
data-testid="var-reference-picker-trigger"
|
||||
onClick={() => {
|
||||
if (!isConstant || readonly)
|
||||
return
|
||||
setControlFocus(Date.now())
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{isAddBtnTrigger
|
||||
@ -278,24 +300,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<VarPickerWrap
|
||||
onClick={() => {
|
||||
if (readonly)
|
||||
return
|
||||
if (!isConstant)
|
||||
setOpen(!open)
|
||||
else
|
||||
setControlFocus(Date.now())
|
||||
}}
|
||||
className="h-full grow"
|
||||
>
|
||||
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
|
||||
{hoveredPill}
|
||||
</div>
|
||||
|
||||
</VarPickerWrap>
|
||||
)}
|
||||
: resolvedVariablePicker}
|
||||
{(hasValue && !readonly && !isInTable && !isJustShowValue) && (
|
||||
<div
|
||||
className="group invisible absolute top-[50%] right-1 h-5 translate-y-[-50%] cursor-pointer rounded-md p-1 group-hover/wrap:visible hover:bg-state-base-hover"
|
||||
@ -330,8 +335,22 @@ const VarReferencePickerTrigger: FC<Props> = ({
|
||||
)}
|
||||
</>
|
||||
<input ref={inputRef} className="sr-only" value={controlFocus} readOnly />
|
||||
</WrapElem>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isSupportConstantValue) {
|
||||
if (readonly)
|
||||
return triggerContent
|
||||
|
||||
return (
|
||||
<PopoverTrigger
|
||||
render={triggerContent}
|
||||
onClick={handleTriggerReadonlyClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return triggerContent
|
||||
}
|
||||
|
||||
export default VarReferencePickerTrigger
|
||||
|
||||
@ -6,6 +6,11 @@ import type { Tool } from '@/app/components/tools/types'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
@ -16,11 +21,6 @@ import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
useIsChatMode,
|
||||
@ -141,10 +141,10 @@ const VarReferencePicker: FC<Props> = ({
|
||||
})
|
||||
|
||||
const node = nodes.find(n => n.id === nodeId)
|
||||
const isInIteration = !!(node?.data as any)?.isInIteration
|
||||
const isInIteration = !!node?.data.isInIteration
|
||||
const iterationNode = isInIteration ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
|
||||
|
||||
const isInLoop = !!(node?.data as any)?.isInLoop
|
||||
const isInLoop = !!node?.data.isInLoop
|
||||
const loopNode = isInLoop ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
|
||||
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
@ -210,13 +210,11 @@ const VarReferencePicker: FC<Props> = ({
|
||||
}, [onChange])
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isFocus, setIsFocus] = useState(false)
|
||||
const [controlFocus, setControlFocus] = useState(0)
|
||||
const isFocus = controlFocus > 0
|
||||
useEffect(() => {
|
||||
if (controlFocus && inputRef.current) {
|
||||
if (controlFocus && inputRef.current)
|
||||
inputRef.current.focus()
|
||||
setIsFocus(true)
|
||||
}
|
||||
}, [controlFocus])
|
||||
|
||||
const handleVarReferenceChange = useCallback((value: ValueSelector, varInfo: Var) => {
|
||||
@ -264,7 +262,7 @@ const VarReferencePicker: FC<Props> = ({
|
||||
}, [availableNodes, reactflow, store])
|
||||
|
||||
const type = getCurrentVariableType({
|
||||
parentNode: (isInIteration ? iterationNode : loopNode) as any,
|
||||
parentNode: isInIteration ? iterationNode : loopNode,
|
||||
valueSelector: value as ValueSelector,
|
||||
availableNodes,
|
||||
isChatMode,
|
||||
@ -289,9 +287,6 @@ const VarReferencePicker: FC<Props> = ({
|
||||
maxVarNameWidth,
|
||||
} = getWidthAllocations(triggerWidth, outputVarNode?.title || '', varName || '', type || '')
|
||||
|
||||
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
|
||||
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
|
||||
|
||||
const hoverPopup = useMemo<HoverPopup | null>(() => {
|
||||
const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar)
|
||||
if (tooltipType === 'full-path') {
|
||||
@ -349,15 +344,23 @@ const VarReferencePicker: FC<Props> = ({
|
||||
)
|
||||
|
||||
const triggerPlaceholder = placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
|
||||
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={isAddBtnTrigger ? 'bottom-end' : 'bottom-start'}
|
||||
>
|
||||
{!!trigger && <PortalToFollowElemTrigger onClick={() => setOpen(!open)}>{trigger}</PortalToFollowElemTrigger>}
|
||||
{!!trigger && (
|
||||
<PopoverTrigger
|
||||
render={resolvedTrigger}
|
||||
onClick={(e) => {
|
||||
if (readonly)
|
||||
e.preventDefault()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!trigger && (
|
||||
<VarReferencePickerTrigger
|
||||
className={className}
|
||||
@ -403,15 +406,18 @@ const VarReferencePicker: FC<Props> = ({
|
||||
varKindTypes={varKindTypes}
|
||||
varName={varName}
|
||||
variableCategory={variableCategory}
|
||||
VarPickerWrap={VarPickerWrap}
|
||||
WrapElem={WrapElem}
|
||||
/>
|
||||
)}
|
||||
<PortalToFollowElemContent
|
||||
style={{
|
||||
zIndex: zIndex || 100,
|
||||
}}
|
||||
<PopoverContent
|
||||
placement={isAddBtnTrigger ? 'bottom-end' : 'bottom-start'}
|
||||
sideOffset={0}
|
||||
className="mt-1"
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{
|
||||
style: {
|
||||
zIndex: zIndex || 100,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!isConstant && (
|
||||
<VarReferencePopup
|
||||
@ -424,8 +430,8 @@ const VarReferencePicker: FC<Props> = ({
|
||||
preferSchemaType={preferSchemaType}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,11 @@ import type { StructuredOutput } from '../../../llm/types'
|
||||
import type { Field } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useHover } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
@ -13,11 +18,6 @@ import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows
|
||||
import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
|
||||
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
@ -143,7 +143,7 @@ const Item: FC<ItemProps> = ({
|
||||
const open = (isObj || isStructureOutput) && isHovering
|
||||
useEffect(() => {
|
||||
onHovering?.(isHovering)
|
||||
}, [isHovering])
|
||||
}, [isHovering, onHovering])
|
||||
const handleChosen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
@ -167,62 +167,70 @@ const Item: FC<ItemProps> = ({
|
||||
() => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }),
|
||||
[isEnv, isChatVar, isLoopVar, isRagVariable],
|
||||
)
|
||||
|
||||
const itemTrigger = (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={cn(
|
||||
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
|
||||
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
|
||||
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
|
||||
className,
|
||||
)}
|
||||
onClick={handleChosen}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
}}
|
||||
>
|
||||
<div className="flex w-0 grow items-center">
|
||||
{!isFlat && (
|
||||
<VariableIconWithColor
|
||||
variables={itemData.variable.split('.')}
|
||||
variableCategory={variableCategory}
|
||||
isExceptionVariable={isException}
|
||||
/>
|
||||
)}
|
||||
{isFlat && flatVarIcon}
|
||||
|
||||
{!isEnv && !isChatVar && !isRagVariable && (
|
||||
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div>
|
||||
)}
|
||||
{isEnv && (
|
||||
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div>
|
||||
)}
|
||||
{isChatVar && (
|
||||
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div>
|
||||
)}
|
||||
{isRagVariable && (
|
||||
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
|
||||
{
|
||||
(isObj || isStructureOutput) && (
|
||||
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={noop}
|
||||
placement="left-start"
|
||||
>
|
||||
<PortalToFollowElemTrigger className="w-full">
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={cn(
|
||||
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
|
||||
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
|
||||
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
|
||||
className,
|
||||
)}
|
||||
onClick={handleChosen}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
}}
|
||||
>
|
||||
<div className="flex w-0 grow items-center">
|
||||
{!isFlat && (
|
||||
<VariableIconWithColor
|
||||
variables={itemData.variable.split('.')}
|
||||
variableCategory={variableCategory}
|
||||
isExceptionVariable={isException}
|
||||
/>
|
||||
)}
|
||||
{isFlat && flatVarIcon}
|
||||
|
||||
{!isEnv && !isChatVar && !isRagVariable && (
|
||||
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div>
|
||||
)}
|
||||
{isEnv && (
|
||||
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div>
|
||||
)}
|
||||
{isChatVar && (
|
||||
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div>
|
||||
)}
|
||||
{isRagVariable && (
|
||||
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
|
||||
{
|
||||
(isObj || isStructureOutput) && (
|
||||
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{
|
||||
zIndex: zIndex || 100,
|
||||
}}
|
||||
<PopoverTrigger render={itemTrigger} />
|
||||
<PopoverContent
|
||||
placement="left-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{
|
||||
style: {
|
||||
zIndex: zIndex || 100,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(isStructureOutput || isObj) && (
|
||||
<PickerStructurePanel
|
||||
@ -234,8 +242,8 @@ const Item: FC<ItemProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CodeDependency } from './types'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
@ -8,7 +13,6 @@ import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type Props = {
|
||||
value: CodeDependency
|
||||
@ -32,21 +36,22 @@ const DependencyPicker: FC<Props> = ({
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)} className="grow cursor-pointer">
|
||||
<div className="flex h-8 items-center justify-between rounded-lg border-0 bg-gray-100 px-2.5 text-[13px] text-gray-900">
|
||||
<div className="w-0 grow truncate" title={value.name}>{value.name}</div>
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 shrink-0 text-gray-700" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{
|
||||
zIndex: 100,
|
||||
}}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="grow cursor-pointer">
|
||||
<div className="flex h-8 items-center justify-between rounded-lg border-0 bg-gray-100 px-2.5 text-[13px] text-gray-900">
|
||||
<div className="w-0 grow truncate" title={value.name}>{value.name}</div>
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 shrink-0 text-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 100 } }}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg bg-white p-1 shadow-sm"
|
||||
@ -82,8 +87,8 @@ const DependencyPicker: FC<Props> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,59 @@ import type { Member } from '@/models/common'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import MemberSelector from '../member-selector'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: import('react').ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled)
|
||||
setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: import('react').ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({ children }: { children: import('react').ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
return open ? <div data-testid="popover-content">{children}</div> : null
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
const mockMemberList = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../member-list', () => ({
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import type { Recipient as RecipientItem } from '../../../types'
|
||||
import type { Member } from '@/models/common'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import EmailItem from './email-item'
|
||||
import MemberList from './member-list'
|
||||
|
||||
@ -58,8 +57,7 @@ const EmailInput = ({
|
||||
if (disabled)
|
||||
return
|
||||
setIsFocus(true)
|
||||
const input = inputRef.current?.children[0] as HTMLInputElement
|
||||
input?.focus()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -141,28 +139,29 @@ const EmailInput = ({
|
||||
/>
|
||||
))}
|
||||
{!disabled && (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -40,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className="block h-6 min-w-[166px]">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="h-6 min-w-[166px] appearance-none bg-transparent p-1 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder"
|
||||
placeholder={placeholder}
|
||||
onFocus={() => setIsFocus(true)}
|
||||
onBlur={handleInputBlur}
|
||||
value={searchKey}
|
||||
onChange={handleValueChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="h-6 min-w-[166px] appearance-none bg-transparent p-1 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder"
|
||||
placeholder={placeholder}
|
||||
onFocus={() => setIsFocus(true)}
|
||||
onBlur={handleInputBlur}
|
||||
value={searchKey}
|
||||
onChange={handleValueChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
alignOffset={-40}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{
|
||||
anchor: inputRef,
|
||||
style: {
|
||||
zIndex: 1000,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MemberList
|
||||
searchValue={searchKey}
|
||||
list={list}
|
||||
@ -172,8 +171,8 @@ const EmailInput = ({
|
||||
email={email}
|
||||
hideSearch
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,12 +4,16 @@ import type { Recipient } from '@/app/components/workflow/nodes/human-input/type
|
||||
import type { Member } from '@/models/common'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiContactsBookLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import MemberList from './member-list'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput'
|
||||
@ -31,39 +35,42 @@ const MemberSelector: FC<Props> = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
const handleSelect = useCallback((memberId: string) => {
|
||||
onSelect(memberId)
|
||||
setOpen(false)
|
||||
}, [onSelect])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 35,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="w-full"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
className={cn('w-full justify-between', open && 'bg-state-accent-hover')}
|
||||
variant="ghost-accent"
|
||||
>
|
||||
<RiContactsBookLine className="mr-1 h-4 w-4" />
|
||||
<div>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={35}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
<Button
|
||||
className={cn('w-full justify-between', open && 'bg-state-accent-hover')}
|
||||
variant="ghost-accent"
|
||||
>
|
||||
<RiContactsBookLine className="mr-1 h-4 w-4" />
|
||||
<div className="">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}</div>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<MemberList
|
||||
searchValue={searchValue}
|
||||
list={list}
|
||||
value={value}
|
||||
onSearchChange={setSearchValue}
|
||||
onSelect={onSelect}
|
||||
onSelect={handleSelect}
|
||||
email={email}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default MemberSelector
|
||||
|
||||
@ -5,17 +5,17 @@ import type {
|
||||
Var,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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'
|
||||
|
||||
type ConditionAddProps = {
|
||||
@ -25,6 +25,7 @@ type ConditionAddProps = {
|
||||
onSelectVariable: HandleAddCondition
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ConditionAdd = ({
|
||||
className,
|
||||
caseId,
|
||||
@ -38,29 +39,32 @@ const ConditionAdd = ({
|
||||
const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
|
||||
onSelectVariable(caseId, valueSelector, varItem)
|
||||
setOpen(false)
|
||||
}, [caseId, onSelectVariable, setOpen])
|
||||
}, [caseId, onSelectVariable])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<Button
|
||||
size="small"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-3.5 w-3.5" />
|
||||
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
size="small"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-3.5 w-3.5" />
|
||||
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled)
|
||||
e.preventDefault()
|
||||
}}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<VarReferenceVars
|
||||
vars={variables}
|
||||
@ -68,8 +72,8 @@ const ConditionAdd = ({
|
||||
onChange={handleSelectVariable}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
|
||||
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
|
||||
@ -23,26 +27,25 @@ const ConditionVarSelector = ({
|
||||
onChange,
|
||||
}: ConditionVarSelectorProps) => {
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => onOpenChange(!open)}>
|
||||
<div className="w-full cursor-pointer">
|
||||
<VariableTag
|
||||
valueSelector={valueSelector}
|
||||
varType={varType}
|
||||
availableNodes={availableNodes}
|
||||
isShort
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="w-full cursor-pointer">
|
||||
<VariableTag
|
||||
valueSelector={valueSelector}
|
||||
varType={varType}
|
||||
availableNodes={availableNodes}
|
||||
isShort
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<VarReferenceVars
|
||||
vars={nodesOutputVars}
|
||||
@ -50,8 +53,8 @@ const ConditionVarSelector = ({
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,11 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-re
|
||||
import type { MetadataInDoc } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
@ -11,11 +14,6 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import MetadataIcon from './metadata-icon'
|
||||
|
||||
const AddCondition = ({
|
||||
@ -36,25 +34,24 @@ const AddCondition = ({
|
||||
}, [handleAddCondition])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 3,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
{t('nodes.knowledgeRetrieval.metadata.panel.add', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
{t('nodes.knowledgeRetrieval.metadata.panel.add', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={12}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1002 } }}
|
||||
>
|
||||
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@ -65,30 +62,28 @@ const AddCondition = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{
|
||||
filteredMetadataList?.map(metadata => (
|
||||
<div
|
||||
key={metadata.name}
|
||||
className="flex h-6 cursor-pointer items-center rounded-md px-3 system-sm-medium text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="mr-1 p-px">
|
||||
<MetadataIcon type={metadata.type} />
|
||||
</div>
|
||||
<div
|
||||
className="grow truncate"
|
||||
title={metadata.name}
|
||||
onClick={() => handleAddConditionWrapped(metadata)}
|
||||
>
|
||||
{metadata.name}
|
||||
</div>
|
||||
<div className="shrink-0 system-xs-regular text-text-tertiary">{metadata.type}</div>
|
||||
{filteredMetadataList?.map(metadata => (
|
||||
<div
|
||||
key={metadata.name}
|
||||
className="flex h-6 cursor-pointer items-center rounded-md px-3 system-sm-medium text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="mr-1 p-px">
|
||||
<MetadataIcon type={metadata.type} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div
|
||||
className="grow truncate"
|
||||
title={metadata.name}
|
||||
onClick={() => handleAddConditionWrapped(metadata)}
|
||||
>
|
||||
{metadata.name}
|
||||
</div>
|
||||
<div className="shrink-0 system-xs-regular text-text-tertiary">{metadata.type}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { VarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useCallback, useState } 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'
|
||||
|
||||
type ConditionCommonVariableSelectorProps = {
|
||||
variables?: { name: string, type: string, value: string }[]
|
||||
@ -31,34 +31,17 @@ const ConditionCommonVariableSelector = ({
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
asChild
|
||||
onClick={() => {
|
||||
if (!variables.length)
|
||||
return
|
||||
setOpen(!open)
|
||||
}}
|
||||
>
|
||||
<div className="flex h-6 grow cursor-pointer items-center">
|
||||
{
|
||||
selected && (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="flex h-6 grow cursor-pointer items-center">
|
||||
{selected && (
|
||||
<div className="inline-flex h-6 items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pr-1.5 pl-[5px] system-xs-medium text-text-secondary shadow-xs">
|
||||
<Variable02 className="mr-1 h-3.5 w-3.5 text-text-accent" />
|
||||
{selected.value}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!selected && (
|
||||
)}
|
||||
{!selected && (
|
||||
<>
|
||||
<div className="flex grow items-center system-sm-regular text-components-input-text-placeholder">
|
||||
<Variable02 className="mr-1 h-4 w-4" />
|
||||
@ -68,27 +51,34 @@ const ConditionCommonVariableSelector = ({
|
||||
{varType}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!variables.length)
|
||||
e.preventDefault()
|
||||
}}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
<div className="w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{
|
||||
variables.map(v => (
|
||||
<div
|
||||
key={v.value}
|
||||
className="flex h-6 cursor-pointer items-center rounded-md px-2 system-xs-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleChange(v.value)}
|
||||
>
|
||||
<Variable02 className="mr-1 h-4 w-4 text-text-accent" />
|
||||
{v.value}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{variables.map(v => (
|
||||
<div
|
||||
key={v.value}
|
||||
className="flex h-6 cursor-pointer items-center rounded-md px-2 system-xs-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleChange(v.value)}
|
||||
>
|
||||
<Variable02 className="mr-1 h-4 w-4 text-text-accent" />
|
||||
{v.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -4,14 +4,14 @@ import type {
|
||||
ValueSelector,
|
||||
Var,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useCallback, useState } 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 VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
|
||||
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
@ -34,35 +34,25 @@ const ConditionVariableSelector = ({
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleChange = useCallback((valueSelector: ValueSelector, varItem: Var) => {
|
||||
onChange(valueSelector, varItem)
|
||||
const handleChange = useCallback((nextValueSelector: ValueSelector, varItem: Var) => {
|
||||
onChange(nextValueSelector, varItem)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}>
|
||||
<div className="flex h-6 grow cursor-pointer items-center">
|
||||
{
|
||||
!!valueSelector.length && (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="flex h-6 grow cursor-pointer items-center">
|
||||
{!!valueSelector.length && (
|
||||
<VariableTag
|
||||
valueSelector={valueSelector}
|
||||
varType={varType}
|
||||
availableNodes={availableNodes}
|
||||
isShort
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!valueSelector.length && (
|
||||
)}
|
||||
{!valueSelector.length && (
|
||||
<>
|
||||
<div className="flex grow items-center system-sm-regular text-components-input-text-placeholder">
|
||||
<Variable02 className="mr-1 h-4 w-4" />
|
||||
@ -72,11 +62,16 @@ const ConditionVariableSelector = ({
|
||||
{varType}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<VarReferenceVars
|
||||
vars={nodesOutputVars}
|
||||
@ -84,8 +79,8 @@ const ConditionVariableSelector = ({
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -5,17 +5,17 @@ import type {
|
||||
Var,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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'
|
||||
|
||||
type ConditionAddProps = {
|
||||
@ -24,6 +24,7 @@ type ConditionAddProps = {
|
||||
onSelectVariable: HandleAddCondition
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ConditionAdd = ({
|
||||
className,
|
||||
variables,
|
||||
@ -36,29 +37,32 @@ const ConditionAdd = ({
|
||||
const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
|
||||
onSelectVariable(valueSelector, varItem)
|
||||
setOpen(false)
|
||||
}, [onSelectVariable, setOpen])
|
||||
}, [onSelectVariable])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<Button
|
||||
size="small"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-3.5 w-3.5" />
|
||||
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
size="small"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-3.5 w-3.5" />
|
||||
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled)
|
||||
e.preventDefault()
|
||||
}}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<VarReferenceVars
|
||||
vars={variables}
|
||||
@ -66,8 +70,8 @@ const ConditionAdd = ({
|
||||
onChange={handleSelectVariable}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
|
||||
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
|
||||
|
||||
@ -23,26 +27,25 @@ const ConditionVarSelector = ({
|
||||
onChange,
|
||||
}: ConditionVarSelectorProps) => {
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
|
||||
<div className="cursor-pointer">
|
||||
<VariableTag
|
||||
valueSelector={valueSelector}
|
||||
varType={varType}
|
||||
availableNodes={availableNodes}
|
||||
isShort
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="cursor-pointer">
|
||||
<VariableTag
|
||||
valueSelector={valueSelector}
|
||||
varType={varType}
|
||||
availableNodes={availableNodes}
|
||||
isShort
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<VarReferenceVars
|
||||
vars={nodesOutputVars}
|
||||
@ -50,8 +53,8 @@ const ConditionVarSelector = ({
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
159
web/app/education-apply/__tests__/search-input.spec.tsx
Normal file
159
web/app/education-apply/__tests__/search-input.spec.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SearchInput from '../search-input'
|
||||
|
||||
const educationMocks = vi.hoisted(() => ({
|
||||
schools: ['Alpha University', 'Beta College'],
|
||||
setSchools: vi.fn(),
|
||||
querySchoolsWithDebounced: vi.fn(),
|
||||
handleUpdateSchools: vi.fn(),
|
||||
hasNext: false,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useEducation: () => educationMocks,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: {
|
||||
value?: string
|
||||
onChange: (event: { target: { value: string } }) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}) => (
|
||||
<input
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange({ target: { value: e.target.value } })}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled)
|
||||
setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: ReactNode }) => <>{render}</>
|
||||
|
||||
const PopoverContent = ({ children }: { children: ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
return open ? <div data-testid="education-search-popover">{children}</div> : null
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
const ControlledSearchInput = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return <SearchInput value={value} onChange={setValue} />
|
||||
}
|
||||
|
||||
describe('education-apply/search-input', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
educationMocks.schools = ['Alpha University', 'Beta College']
|
||||
educationMocks.hasNext = false
|
||||
})
|
||||
|
||||
it('opens the popover, queries schools, and closes after selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ControlledSearchInput />)
|
||||
|
||||
const input = screen.getByPlaceholderText('form.schoolName.placeholder')
|
||||
await user.type(input, 'A')
|
||||
|
||||
expect(educationMocks.setSchools).toHaveBeenCalledWith([])
|
||||
expect(educationMocks.querySchoolsWithDebounced).toHaveBeenLastCalledWith({
|
||||
keywords: 'A',
|
||||
page: 0,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('education-search-popover')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha University')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Beta College'))
|
||||
|
||||
expect(screen.getByDisplayValue('Beta College')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('education-search-popover')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads the next page when the dropdown is scrolled to the bottom', async () => {
|
||||
const user = userEvent.setup()
|
||||
educationMocks.hasNext = true
|
||||
|
||||
render(<ControlledSearchInput />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('form.schoolName.placeholder'), 'A')
|
||||
|
||||
const scrollContainer = screen.getByText('Alpha University').parentElement as HTMLDivElement
|
||||
Object.defineProperties(scrollContainer, {
|
||||
scrollTop: {
|
||||
value: 60,
|
||||
configurable: true,
|
||||
},
|
||||
scrollHeight: {
|
||||
value: 100,
|
||||
configurable: true,
|
||||
},
|
||||
clientHeight: {
|
||||
value: 40,
|
||||
configurable: true,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(educationMocks.handleUpdateSchools).toHaveBeenCalledWith({
|
||||
keywords: 'A',
|
||||
page: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,9 @@
|
||||
import type { ChangeEventHandler } from 'react'
|
||||
import type { ChangeEventHandler, UIEventHandler } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
@ -6,17 +11,13 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useEducation } from './hooks'
|
||||
|
||||
type SearchInputProps = {
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const SearchInput = ({
|
||||
value,
|
||||
onChange,
|
||||
@ -48,7 +49,7 @@ const SearchInput = ({
|
||||
keywords,
|
||||
page,
|
||||
})
|
||||
}, [querySchoolsWithDebounced, handleUpdateSchools])
|
||||
}, [handleUpdateSchools, querySchoolsWithDebounced])
|
||||
|
||||
const handleValueChange: ChangeEventHandler<HTMLInputElement> = useCallback((e) => {
|
||||
setOpen(true)
|
||||
@ -58,10 +59,10 @@ const SearchInput = ({
|
||||
valueRef.current = inputValue
|
||||
onChange(inputValue)
|
||||
handleSearch(true)
|
||||
}, [onChange, handleSearch, setSchools])
|
||||
}, [handleSearch, onChange, setSchools])
|
||||
|
||||
const handleScroll = useCallback((e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
const handleScroll: UIEventHandler<HTMLDivElement> = useCallback((e) => {
|
||||
const target = e.currentTarget
|
||||
const {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
@ -74,48 +75,45 @@ const SearchInput = ({
|
||||
}, [handleSearch, hasNext])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom"
|
||||
offset={4}
|
||||
triggerPopupSameWidth
|
||||
>
|
||||
<PortalToFollowElemTrigger className="block w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder={t('form.schoolName.placeholder', { ns: 'education' })}
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-32">
|
||||
{
|
||||
!!schools.length && value && (
|
||||
<div
|
||||
className="max-h-[330px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1"
|
||||
onScroll={handleScroll as any}
|
||||
>
|
||||
{
|
||||
schools.map((school, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
|
||||
title={school}
|
||||
onClick={() => {
|
||||
onChange(school)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{school}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder={t('form.schoolName.placeholder', { ns: 'education' })}
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!!schools.length && !!value && (
|
||||
<PopoverContent
|
||||
placement="bottom"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
|
||||
positionerProps={{ style: { zIndex: 32 } }}
|
||||
>
|
||||
<div
|
||||
className="max-h-[330px] overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{schools.map(school => (
|
||||
<div
|
||||
key={school}
|
||||
className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
|
||||
title={school}
|
||||
onClick={() => {
|
||||
onChange(school)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{school}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user