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:
Coding On Star 2026-04-21 18:09:22 +08:00 committed by GitHub
parent 44a91e344c
commit d65a6b4810
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1900 additions and 1072 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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\\]')

View File

@ -97,6 +97,7 @@ const PluginTasks = () => {
onOpenChange={setOpen}
>
<DropdownMenuTrigger
nativeButton={false}
render={<div />}
disabled={!canOpenMenu}
>

View File

@ -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 = []
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
})
})
})

View File

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