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 (
+ )}
+ />
+
-
-
- {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' })}
-
-
-
+
+
+
+ {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' })}
-
-
-
+
+
+
+ {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' })}
-
-
-
+
+
+
+ {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}
+
+ ))}
+
+
+ )}
+
)
}