From d65a6b481066cb0abd46a4d376795060e555fc06 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 21 Apr 2026 18:09:22 +0800 Subject: [PATCH] refactor: migrate from PortalToFollowElem to Popover component across various components (#35454) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 115 ----- .../context-var/__tests__/index.spec.tsx | 84 +++- .../context-var/__tests__/var-picker.spec.tsx | 42 +- .../dataset-config/context-var/var-picker.tsx | 91 ++-- .../member-selector.tsx | 77 +-- .../__tests__/selector-entry.spec.tsx | 55 ++- .../subscription-list/selector-entry.tsx | 53 +-- .../plugin-tasks/__tests__/index.spec.tsx | 25 +- .../plugin-page/plugin-tasks/index.tsx | 1 + .../__tests__/index.spec.tsx | 64 ++- .../__tests__/tool-picker.spec.tsx | 75 +-- .../auto-update-setting/tool-picker.tsx | 128 ++--- .../tools/labels/__tests__/selector.spec.tsx | 59 +++ web/app/components/tools/labels/selector.tsx | 63 +-- .../workflow/__tests__/custom-edge.spec.tsx | 4 + .../__tests__/tool-picker.spec.tsx | 4 +- .../workflow/block-selector/tool-picker.tsx | 72 +-- web/app/components/workflow/custom-edge.tsx | 8 +- .../agent-strategy-selector.spec.tsx | 446 ++++++++++++++++++ .../components/agent-strategy-selector.tsx | 164 ++++--- .../var-reference-picker.trigger.spec.tsx | 146 +++--- .../variable/var-reference-picker.trigger.tsx | 85 ++-- .../variable/var-reference-picker.tsx | 58 ++- .../variable/var-reference-vars.tsx | 128 ++--- .../workflow/nodes/code/dependency-picker.tsx | 41 +- .../__tests__/member-selector.spec.tsx | 53 +++ .../delivery-method/recipient/email-input.tsx | 61 ++- .../recipient/member-selector.tsx | 59 ++- .../if-else/components/condition-add.tsx | 60 +-- .../condition-list/condition-var-selector.tsx | 49 +- .../components/metadata/add-condition.tsx | 89 ++-- .../condition-common-variable-selector.tsx | 86 ++-- .../condition-variable-selector.tsx | 57 +-- .../nodes/loop/components/condition-add.tsx | 60 +-- .../condition-list/condition-var-selector.tsx | 49 +- .../__tests__/search-input.spec.tsx | 159 +++++++ web/app/education-apply/search-input.tsx | 102 ++-- 37 files changed, 1900 insertions(+), 1072 deletions(-) create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx create mode 100644 web/app/education-apply/__tests__/search-input.spec.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d969e0bf83e..0e0970f90d9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx index 6726ba0583b..91fe47d83d0 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx @@ -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 ( + + {children} + + ) + } + + const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { + const { open, setOpen } = React.useContext(PopoverContext) + return ( +
setOpen(!open)} + > + {render} +
+ ) + } + + const PopoverContent = ({ + children, + ...props + }: React.HTMLAttributes & { children?: React.ReactNode }) => { + const { open } = React.useContext(PopoverContext) + if (!open) + return null + + return ( +
+ {children} +
+ ) + } + + return { + Popover, + PopoverTrigger, + PopoverContent, + } +}) + type PortalToFollowElemProps = { children: React.ReactNode open?: boolean @@ -209,20 +275,17 @@ describe('ContextVar', () => { // Act render() - 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() - 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() }) }) diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx index 1d81a310910..7890343720d 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx @@ -18,18 +18,21 @@ type PortalToFollowElemProps = { type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode, asChild?: boolean } type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode } -vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const PortalContext = React.createContext({ open: false }) +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 ( - +
{children}
) } - 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) => { + 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) + } + if (asChild && React.isValidElement(children)) { return React.cloneElement(children, { ...props, + 'onClick': handleClick, 'data-testid': 'portal-trigger', } as React.HTMLAttributes) } return ( -
- {children} +
+ {content}
) } return { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, + Popover, + PopoverContent, + PopoverTrigger, } }) diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx index d29b2e34dfe..9bac1c7a416 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx @@ -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 }) => (
) + const VarPicker: FC = ({ triggerClassName, className, @@ -45,47 +46,51 @@ const VarPicker: FC = ({ const [open, setOpen] = useState(false) const currItem = options.find(item => item.value === value) const notSetVar = !currItem + return ( - - setOpen(v => !v)}> -
-
- {value - ? ( - - ) - : ( -
- {notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })} -
- )} + + +
+
+ {currItem + ? ( + + ) + : ( +
+ {notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })} +
+ )} +
+ +
- -
-
- + )} + /> + {options.length > 0 ? (
- {options.map(({ name, value, type }, index) => ( + {options.map(({ name, value, type }) => (
{ onChange(value) @@ -103,9 +108,9 @@ const VarPicker: FC = ({
{t('feature.dataSet.queryVariable.noVarTip', { ns: 'appDebug' })}
)} - - - + + ) } + export default React.memo(VarPicker) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx index 5875b4fb6a1..4dc41a307b0 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx @@ -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 = ({ 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 = ({ return name.toLowerCase().includes(searchValue.toLowerCase()) || email.toLowerCase().includes(searchValue.toLowerCase()) }).filter(account => !exclude.includes(account.id)) - }, [data, searchValue, exclude]) + }, [data, exclude, searchValue]) return ( - - setOpen(v => !v)} + + + {!currentValue && ( +
{t('members.transferModal.transferPlaceholder', { ns: 'common' })}
+ )} + {currentValue && ( + <> + +
{currentValue.name}
+
{currentValue.email}
+ + )} +
+
+ )} + /> + -
- {!currentValue && ( -
{t('members.transferModal.transferPlaceholder', { ns: 'common' })}
- )} - {currentValue && ( - <> - -
{currentValue.name}
-
{currentValue.email}
- - )} -
-
- -
= ({ ))}
-
- + + ) } + export default MemberSelector diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx index 37d828591ff..1eb02fb15a2 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx @@ -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 ( + + {children} + + ) + } + + const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { + const { open, setOpen } = React.useContext(PopoverContext) + return ( +
setOpen(!open)}> + {render} +
+ ) + } + + const PopoverContent = ({ children }: { children: React.ReactNode }) => { + const { open } = React.useContext(PopoverContext) + return open ?
{children}
: 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() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx index 5f755ff634f..21f1d4898b1 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx @@ -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 = ({ selectedId, - onClick, isOpen = false, className, }) => { @@ -44,7 +42,7 @@ const SubscriptionTriggerButton: React.FC = ({ } 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 = ({ return (
+ )} + /> +
- -
+ + ) } diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index c87673b750b..12fc7965314 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -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() // 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() // 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() - 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() // 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() // 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() // 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() - fireEvent.click(document.getElementById('plugin-task-trigger')!) + fireEvent.click(getTaskMenuTrigger()) expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) @@ -837,7 +838,7 @@ describe('PluginTasks Component', () => { ]) render() - fireEvent.click(document.getElementById('plugin-task-trigger')!) + fireEvent.click(getTaskMenuTrigger()) expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) @@ -892,7 +893,7 @@ describe('PluginTasks Integration', () => { render() // Open popover - fireEvent.click(document.getElementById('plugin-task-trigger')!) + fireEvent.click(getTaskMenuTrigger()) // All sections should be visible const sections = document.querySelectorAll('.max-h-\\[300px\\]') diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index 7603cae33d0..f3102c49091 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -97,6 +97,7 @@ const PluginTasks = () => { onOpenChange={setOpen} > } disabled={!canOpenMenu} > diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx index 19ce12b3284..ed3c457aaf4 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx @@ -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 ( +
{children}
+ ) + }, + PopoverTrigger: ({ children, render, onClick, className }: { + children?: React.ReactNode + render?: React.ReactNode + onClick?: (e: React.MouseEvent) => void + className?: string + }) => ( +
{ + onClick?.(e) + if (!onClick) + mockPortalOnOpenChange?.(!mockPortalOpen) + }} + className={className} + > + {render ?? children} +
+ ), + PopoverContent: ({ children, className, popupClassName }: { + children: React.ReactNode + className?: string + popupClassName?: string + }) => { + if (!mockPortalOpen && !forcePortalContentVisible) + return null + return
{children}
+ }, +})) + +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
{children}
}, PortalToFollowElemTrigger: ({ children, onClick, className }: { - children: React.ReactNode - onClick: (e: React.MouseEvent) => void + children?: React.ReactNode + onClick?: (e: React.MouseEvent) => void className?: string }) => (
{children}
), - 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
{children}
+ return
{children}
}, })) @@ -319,6 +362,7 @@ describe('auto-update-setting', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpen = false + mockPortalOnOpenChange = undefined forcePortalContentVisible = false mockPluginsData.plugins = [] }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx index e89b1b31615..176598158cc 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx @@ -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: () =>
loading
, })) -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 + }) => ( + onOpenChange?.(nextOpen) }}> + {children} + + ) + + const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { + const { open, setOpen } = React.useContext(PopoverContext) + return ( +
setOpen(!open)}> + {render} +
+ ) + } + + const PopoverContent = ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => { + const { open } = React.useContext(PopoverContext) + return open ?
{children}
: null + } + return { - PortalToFollowElem: ({ - open, - children, - }: { - open: boolean - children: React.ReactNode - }) => { - portalOpen = open - return
{children}
- }, - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick: () => void - }) => , - PortalToFollowElemContent: ({ - children, - className, - }: { - children: React.ReactNode - className?: string - }) => portalOpen ?
{children}
: 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) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx index 975dd2217eb..1c499104c24 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx @@ -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 = ({ @@ -35,43 +34,16 @@ const ToolPicker: FC = ({ 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(PLUGIN_TYPE_SEARCH_MAP.all) @@ -89,14 +61,13 @@ const ToolPicker: FC = ({ ) }) }, [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 = (
@@ -105,7 +76,7 @@ const ToolPicker: FC = ({ key={item.plugin_id} payload={item} isChecked={value.includes(item.plugin_id)} - onCheckChange={handleCheckChange(item.plugin_id)} + onCheckChange={() => handleCheckChange(item.plugin_id)} /> ))}
@@ -121,21 +92,18 @@ const ToolPicker: FC = ({ ) + const resolvedTrigger = React.isValidElement(trigger) ? trigger :
{trigger}
+ return ( - - + + - {trigger} - - -
+
= ({
- { - tabs.map(tab => ( -
setPluginType(tab.key)} - > - {tab.name} -
- )) - } + {tabs.map(tab => ( +
setPluginType(tab.key)} + > + {tab.name} +
+ ))}
{!isLoading && filteredList.length > 0 && listContent} {!isLoading && filteredList.length === 0 && noData} {isLoading && loadingContent}
- - + + ) } diff --git a/web/app/components/tools/labels/__tests__/selector.spec.tsx b/web/app/components/tools/labels/__tests__/selector.spec.tsx index b495d2d227c..4ef4534759c 100644 --- a/web/app/components/tools/labels/__tests__/selector.spec.tsx +++ b/web/app/components/tools/labels/__tests__/selector.spec.tsx @@ -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 ( + + {children} + + ) + } + + const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { + const { open, setOpen } = React.useContext(PopoverContext) + return ( +
setOpen(!open)}> + {render} +
+ ) + } + + const PopoverContent = ({ + children, + ...props + }: React.HTMLAttributes & { children?: React.ReactNode }) => { + const { open } = React.useContext(PopoverContext) + if (!open) + return null + + return
{children}
+ } + + return { + Popover, + PopoverTrigger, + PopoverContent, + } +}) + // Mock useTags hook with controlled test data const mockTags = [ { name: 'agent', label: 'Agent' }, diff --git a/web/app/components/tools/labels/selector.tsx b/web/app/components/tools/labels/selector.tsx index 830b4d7aafd..67539715a76 100644 --- a/web/app/components/tools/labels/selector.tsx +++ b/web/app/components/tools/labels/selector.tsx @@ -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 = ({ value, onChange, @@ -34,6 +35,7 @@ const LabelSelector: FC = ({ const { run: handleSearch } = useDebounceFn(() => { setSearchKeywords(keywords) }, { wait: 500 }) + const handleKeywordsChange = (value: string) => { setKeywords(value) handleSearch() @@ -55,32 +57,31 @@ const LabelSelector: FC = ({ } return ( - +
- setOpen(v => !v)} - className="block" - > -
+
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} +
+
+ +
+
)} - > -
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} -
-
- -
-
- - + /> +
= ({ )}
-
+
-
+ ) } diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx index f8ff9a1a0e0..5c98402d9e9 100644 --- a/web/app/components/workflow/__tests__/custom-edge.spec.tsx +++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx @@ -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', + }) }) }) diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx index 9c55d174fd6..1ec4ed241fd 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx @@ -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') }) diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index e7e948a54de..0ee25a9ce64 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -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 = ({ 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 = ({ 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 = ({ ) } - return ( - - - {trigger} - + const resolvedTrigger = React.isValidElement(trigger) ? trigger :
{trigger}
+ 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 - + return ( + { + if (disabled && nextOpen) + return + onShowChange(nextOpen) + }} + > + { + if (disabled) + e.preventDefault() + }} + /> +
= ({ placeholder={t('searchTools', { ns: 'plugin' })!} supportAddCustomTool={supportAddCustomTool} onAddedCustomTool={handleAddedCustomTool} - onShowAddCustomCollectionModal={showEditCustomCollectionModal} + onShowAddCustomCollectionModal={handleShowAddCustomCollectionModal} inputClassName="grow" />
@@ -209,8 +223,8 @@ const ToolPicker: FC = ({ }} />
-
-
+ + ) } diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index af0fb19e6bf..d8c3b59a4ae 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -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 = ({
setIsTriggerHovered(true)} onMouseLeave={() => setIsTriggerHovered(false)} diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx new file mode 100644 index 00000000000..995d3c8a707 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx @@ -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 + }) => ( + onChange(e.target.value)} + /> + ), +})) + +vi.mock('@/app/components/workflow/block-selector/view-type-select', () => ({ + default: ({ + onChange, + }: { + viewType: string + onChange: (value: string) => void + }) => ( + + ), + 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 + }> + }> + onSelect: (value: unknown, tool: { + tool_name: string + provider_name: string + tool_label: string + output_schema?: Record + provider_id: string + meta?: unknown + }) => void + }) => ( +
+ {tools.map(tool => ( +
+ {tool.name} + +
+ ))} +
+ ), +})) + +vi.mock('@/app/components/workflow/block-selector/market-place-plugin/list', () => ({ + default: ({ + list, + searchText, + }: { + list: Array<{ plugin_id: string }> + searchText: string + }) => ( +
+ {`${searchText}:${list.map(item => item.plugin_id).join(',')}`} +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ + InstallPluginButton: ({ + onClick, + }: { + onClick?: (event: { stopPropagation: () => void }) => void + uniqueIdentifier: string + size: string + }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({ + SwitchPluginVersion: ({ + onChange, + }: { + onChange: () => void + uniqueIdentifier: string + tooltip: ReactNode + }) => ( + + ), +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + className, + }: { + href: string + children: ReactNode + className?: string + }) => {children}, +})) + +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 ( + + {children} + + ) + } + + const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { + const { open, setOpen } = React.useContext(PopoverContext) + return ( +
setOpen(!open)}> + {render} +
+ ) + } + + const PopoverContent = ({ children }: { children: React.ReactNode }) => { + const { open } = React.useContext(PopoverContext) + return open ?
{children}
: null + } + + return { + Popover, + PopoverTrigger, + PopoverContent, + } +}) + +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ render }: { render: ReactNode }) =>
{render}
, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByTestId('install-plugin-button')).toBeInTheDocument() + + mocks.useStrategyInfo.mockReturnValue({ + strategyStatus: { + plugin: { + source: 'marketplace', + installed: true, + }, + isExistInPlugin: false, + }, + refetch: mocks.refetchStrategyInfo, + }) + + rerender( + , + ) + + await user.click(screen.getByTestId('switch-plugin-version')) + + expect(mocks.refetchStrategyInfo).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx index 8e7c1609d16..765c8ea76a9 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx @@ -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 ( - +
} + /> +

{title} @@ -51,11 +61,7 @@ const NotFoundWarn = (props: {

- )} - > -
- -
+
) } @@ -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(null) return ( - - -
setOpen(o => !o)} - > - { } - {icon && ( -
- icon -
- )} -

- {value?.agent_strategy_label || t('nodes.agent.strategy.selectTip', { ns: 'workflow' })} -

-
- {showInstallButton && value && ( - e.stopPropagation()} - size="small" - uniqueIdentifier={value.plugin_unique_identifier} - /> + + + {icon && ( +
+ icon +
)} - {showPluginNotInstalledWarn - ? ( - - ) - : showUnsupportedStrategy +

+ {value?.agent_strategy_label || t('nodes.agent.strategy.selectTip', { ns: 'workflow' })} +

+
+ {showInstallButton && value && ( + e.stopPropagation()} + size="small" + uniqueIdentifier={value.plugin_unique_identifier} + /> + )} + {showPluginNotInstalledWarn ? ( ) - : } - {showSwitchVersion && ( - - {t('nodes.agent.strategyNotFoundDescAndSwitchVersion', { ns: 'workflow' })} - - )} - onChange={() => { - refetchStrategyInfo() - }} - /> - )} + : showUnsupportedStrategy + ? ( + + ) + : } + {showSwitchVersion && value && ( + +

+ {t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })} +

+

+ {t('nodes.agent.strategyNotFoundDescAndSwitchVersion', { ns: 'workflow' })} +

+
+ )} + onChange={() => { + refetchStrategyInfo() + }} + /> + )} +
-
- - + )} + /> +
@@ -260,8 +272,8 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => )}
-
-
+ + ) }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx index c4013774504..a01d2a03873 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx @@ -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> = {}, +) => { + const onOpenChange = vi.fn() + + render( + + + +
picker-content
+
+
, + ) + + return { onOpenChange } +} + describe('VarReferencePickerTrigger', () => { it('should show the placeholder state and open the picker for variable mode', () => { - const setOpen = vi.fn() - render( - , - ) + 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( - , - ) + 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( - , - ) + 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( - , - ) + 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( - , - ) + 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( - , - ) + renderWithPopover({ + hasValue: false, + isInTable: true, + isLoading: true, + onRemove, + placeholder: 'Loading variable', + }) expect(screen.getByText('Loading variable'))!.toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx index 5a0362a07eb..7f77f379879 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx @@ -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 = ({ @@ -114,9 +113,14 @@ const VarReferencePickerTrigger: FC = ({ varKindTypes, varName, variableCategory, - VarPickerWrap, - WrapElem, }) => { + const handleTriggerReadonlyClick = (e: React.MouseEvent) => { + if (!readonly) + return + e.preventDefault() + e.stopPropagation() + } + const pill = (
{hasValue @@ -212,18 +216,36 @@ const VarReferencePickerTrigger: FC = ({ ) : pill - return ( - { - if (readonly) - return - if (!isConstant) - setOpen(!open) - else - setControlFocus(Date.now()) - }} + const variablePicker = ( +
+
+ {hoveredPill} +
+
+ ) + + const resolvedVariablePicker = isSupportConstantValue + ? ( + readonly + ? variablePicker + : ( + + ) + ) + : variablePicker + + const triggerContent = ( +
{ + if (!isConstant || readonly) + return + setControlFocus(Date.now()) + }} > <> {isAddBtnTrigger @@ -278,24 +300,7 @@ const VarReferencePickerTrigger: FC = ({ isLoading={isLoading} /> ) - : ( - { - if (readonly) - return - if (!isConstant) - setOpen(!open) - else - setControlFocus(Date.now()) - }} - className="h-full grow" - > -
- {hoveredPill} -
- -
- )} + : resolvedVariablePicker} {(hasValue && !readonly && !isInTable && !isJustShowValue) && (
= ({ )} - +
) + + if (!isSupportConstantValue) { + if (readonly) + return triggerContent + + return ( + + ) + } + + return triggerContent } export default VarReferencePickerTrigger diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 9b985c8a9ed..7e99988ae88 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -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 = ({ }) 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(null) @@ -210,13 +210,11 @@ const VarReferencePicker: FC = ({ }, [onChange]) const inputRef = useRef(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 = ({ }, [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 = ({ maxVarNameWidth, } = getWidthAllocations(triggerWidth, outputVarNode?.title || '', varName || '', type || '') - const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger - const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger - const hoverPopup = useMemo(() => { const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar) if (tooltipType === 'full-path') { @@ -349,15 +344,23 @@ const VarReferencePicker: FC = ({ ) const triggerPlaceholder = placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' }) + const resolvedTrigger = React.isValidElement(trigger) ? trigger :
{trigger}
return (
- - {!!trigger && setOpen(!open)}>{trigger}} + {!!trigger && ( + { + if (readonly) + e.preventDefault() + }} + /> + )} {!trigger && ( = ({ varKindTypes={varKindTypes} varName={varName} variableCategory={variableCategory} - VarPickerWrap={VarPickerWrap} - WrapElem={WrapElem} /> )} - {!isConstant && ( = ({ preferSchemaType={preferSchemaType} /> )} - - + +
) } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 648e795dcc1..28ad104ed73 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -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 = ({ 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 = ({ () => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }), [isEnv, isChatVar, isLoopVar, isRagVariable], ) + + const itemTrigger = ( +
{ + e.preventDefault() + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + }} + > +
+ {!isFlat && ( + + )} + {isFlat && flatVarIcon} + + {!isEnv && !isChatVar && !isRagVariable && ( +
{varName}
+ )} + {isEnv && ( +
{itemData.variable.replace('env.', '')}
+ )} + {isChatVar && ( +
{itemData.variable.replace('conversation.', '')}
+ )} + {isRagVariable && ( +
{itemData.variable.split('.').slice(-1)[0]}
+ )} +
+
{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}
+ { + (isObj || isStructureOutput) && ( + + ) + } +
+ ) + return ( - - -
{ - e.preventDefault() - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - }} - > -
- {!isFlat && ( - - )} - {isFlat && flatVarIcon} - - {!isEnv && !isChatVar && !isRagVariable && ( -
{varName}
- )} - {isEnv && ( -
{itemData.variable.replace('env.', '')}
- )} - {isChatVar && ( -
{itemData.variable.replace('conversation.', '')}
- )} - {isRagVariable && ( -
{itemData.variable.split('.').slice(-1)[0]}
- )} -
-
{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}
- { - (isObj || isStructureOutput) && ( - - ) - } -
-
- + {(isStructureOutput || isObj) && ( = ({ }} /> )} - -
+ + ) } diff --git a/web/app/components/workflow/nodes/code/dependency-picker.tsx b/web/app/components/workflow/nodes/code/dependency-picker.tsx index feed5234a72..dd9e97af6f2 100644 --- a/web/app/components/workflow/nodes/code/dependency-picker.tsx +++ b/web/app/components/workflow/nodes/code/dependency-picker.tsx @@ -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 = ({ }, [onChange]) return ( - - setOpen(!open)} className="grow cursor-pointer"> -
-
{value.name}
- -
-
- + +
+
{value.name}
+ +
+
+ )} + /> +
= ({ ))}
- - + + ) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx index 823aa68047a..e1d959bd863 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx @@ -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 ( + + {children} + + ) + } + + const PopoverTrigger = ({ render }: { render: import('react').ReactNode }) => { + const { open, setOpen } = React.useContext(PopoverContext) + return ( +
setOpen(!open)}> + {render} +
+ ) + } + + const PopoverContent = ({ children }: { children: import('react').ReactNode }) => { + const { open } = React.useContext(PopoverContext) + return open ?
{children}
: null + } + + return { + Popover, + PopoverTrigger, + PopoverContent, + } +}) + const mockMemberList = vi.hoisted(() => vi.fn()) vi.mock('../member-list', () => ({ diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx index 1f18ef076d1..cb8de634a20 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx @@ -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) => { @@ -141,28 +139,29 @@ const EmailInput = ({ /> ))} {!disabled && ( - - - setIsFocus(true)} - onBlur={handleInputBlur} - value={searchKey} - onChange={handleValueChange} - onKeyDown={handleKeyDown} - /> - - + + setIsFocus(true)} + onBlur={handleInputBlur} + value={searchKey} + onChange={handleValueChange} + onKeyDown={handleKeyDown} + /> + - - + + )} diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx index c2df6afb153..f091ec5a30e 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx @@ -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 = ({ const [open, setOpen] = useState(false) const [searchValue, setSearchValue] = useState('') + const handleSelect = useCallback((memberId: string) => { + onSelect(memberId) + setOpen(false) + }, [onSelect]) + return ( - - setOpen(v => !v)} + + + +
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}
+ + )} + /> + - -
- - -
+ + ) } + export default MemberSelector diff --git a/web/app/components/workflow/nodes/if-else/components/condition-add.tsx b/web/app/components/workflow/nodes/if-else/components/condition-add.tsx index 85a45ac5c79..b8f5dab85be 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-add.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-add.tsx @@ -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 ( - - setOpen(!open)}> - - - + + + + {t('nodes.ifElse.addCondition', { ns: 'workflow' })} + + )} + onClick={(e) => { + if (disabled) + e.preventDefault() + }} + /> +
-
-
+ + ) } diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx index 27404716107..e94eca7b389 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx @@ -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 ( - - onOpenChange(!open)}> -
- -
-
- + + + + + )} + /> +
-
-
+ + ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx index ea7870431a7..2787aafafdf 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx @@ -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 ( - - setOpen(!open)}> - - - + + + + {t('nodes.knowledgeRetrieval.metadata.panel.add', { ns: 'workflow' })} + + )} + /> +
- { - filteredMetadataList?.map(metadata => ( -
-
- -
-
handleAddConditionWrapped(metadata)} - > - {metadata.name} -
-
{metadata.type}
+ {filteredMetadataList?.map(metadata => ( +
+
+
- )) - } +
handleAddConditionWrapped(metadata)} + > + {metadata.name} +
+
{metadata.type}
+
+ ))}
- - + + ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx index dd0858942dc..d4aedbd8b97 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx @@ -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 ( - - { - if (!variables.length) - return - setOpen(!open) - }} - > -
- { - selected && ( + + + {selected && (
{selected.value}
- ) - } - { - !selected && ( + )} + {!selected && ( <>
@@ -68,27 +51,34 @@ const ConditionCommonVariableSelector = ({ {varType}
- ) - } -
-
- + )} +
+ )} + onClick={(e) => { + if (!variables.length) + e.preventDefault() + }} + /> +
- { - variables.map(v => ( -
handleChange(v.value)} - > - - {v.value} -
- )) - } + {variables.map(v => ( +
handleChange(v.value)} + > + + {v.value} +
+ ))}
-
-
+ + ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx index 15f5ec9377c..7ef4ed7388e 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx @@ -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 ( - - setOpen(!open)}> -
- { - !!valueSelector.length && ( + + + {!!valueSelector.length && ( - ) - } - { - !valueSelector.length && ( + )} + {!valueSelector.length && ( <>
@@ -72,11 +62,16 @@ const ConditionVariableSelector = ({ {varType}
- ) - } -
-
- + )} + + )} + /> +
-
-
+ + ) } diff --git a/web/app/components/workflow/nodes/loop/components/condition-add.tsx b/web/app/components/workflow/nodes/loop/components/condition-add.tsx index 36a09dd434d..a28b066098b 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-add.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-add.tsx @@ -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 ( - - setOpen(!open)}> - - - + + + + {t('nodes.ifElse.addCondition', { ns: 'workflow' })} + + )} + onClick={(e) => { + if (disabled) + e.preventDefault() + }} + /> +
-
-
+ + ) } diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx index 7a7957ad711..496ed4087df 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx @@ -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 ( - - onOpenChange(!open)}> -
- -
-
- + + + + + )} + /> +
-
-
+ + ) } diff --git a/web/app/education-apply/__tests__/search-input.spec.tsx b/web/app/education-apply/__tests__/search-input.spec.tsx new file mode 100644 index 00000000000..bb3cd8cc840 --- /dev/null +++ b/web/app/education-apply/__tests__/search-input.spec.tsx @@ -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 + }) => ( + 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 ( + + {children} + + ) + } + + const PopoverTrigger = ({ render }: { render: ReactNode }) => <>{render} + + const PopoverContent = ({ children }: { children: ReactNode }) => { + const { open } = React.useContext(PopoverContext) + return open ?
{children}
: null + } + + return { + Popover, + PopoverTrigger, + PopoverContent, + } +}) + +const ControlledSearchInput = () => { + const [value, setValue] = useState('') + return +} + +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() + + 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() + + 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, + }) + }) +}) diff --git a/web/app/education-apply/search-input.tsx b/web/app/education-apply/search-input.tsx index 47eca921ee7..4f930eb3ebc 100644 --- a/web/app/education-apply/search-input.tsx +++ b/web/app/education-apply/search-input.tsx @@ -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 = 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 = useCallback((e) => { + const target = e.currentTarget const { scrollTop, scrollHeight, @@ -74,48 +75,45 @@ const SearchInput = ({ }, [handleSearch, hasNext]) return ( - - - - - - { - !!schools.length && value && ( -
- { - schools.map((school, index) => ( -
{ - onChange(school) - setOpen(false) - }} - > - {school} -
- )) - } -
- ) - } -
-
+ + + )} + /> + {!!schools.length && !!value && ( + +
+ {schools.map(school => ( +
{ + onChange(school) + setOpen(false) + }} + > + {school} +
+ ))} +
+
+ )} +
) }