diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 798ae1ec28..96af36d27a 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -129,11 +129,6 @@
"count": 3
}
},
- "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": {
"no-restricted-imports": {
"count": 1
@@ -1086,21 +1081,11 @@
"count": 1
}
},
- "web/app/components/base/date-and-time-picker/date-picker/index.tsx": {
- "react/set-state-in-effect": {
- "count": 4
- }
- },
"web/app/components/base/date-and-time-picker/hooks.ts": {
"react/no-unnecessary-use-prefix": {
"count": 2
}
},
- "web/app/components/base/date-and-time-picker/time-picker/index.tsx": {
- "react/set-state-in-effect": {
- "count": 2
- }
- },
"web/app/components/base/date-and-time-picker/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@@ -1195,11 +1180,6 @@
"count": 5
}
},
- "web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx": {
- "ts/no-explicit-any": {
- "count": 1
- }
- },
"web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": {
"no-restricted-imports": {
"count": 1
@@ -1223,11 +1203,6 @@
"count": 2
}
},
- "web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx": {
- "ts/no-explicit-any": {
- "count": 1
- }
- },
"web/app/components/base/features/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@@ -1878,11 +1853,6 @@
"count": 1
}
},
- "web/app/components/base/portal-to-follow-elem/index.tsx": {
- "ts/no-explicit-any": {
- "count": 1
- }
- },
"web/app/components/base/prompt-editor/index.stories.tsx": {
"no-console": {
"count": 1
@@ -1906,11 +1876,6 @@
"count": 1
}
},
- "web/app/components/base/prompt-editor/plugins/context-block/component.tsx": {
- "ts/no-explicit-any": {
- "count": 1
- }
- },
"web/app/components/base/prompt-editor/plugins/context-block/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 3
@@ -1940,11 +1905,6 @@
"count": 2
}
},
- "web/app/components/base/prompt-editor/plugins/history-block/component.tsx": {
- "ts/no-explicit-any": {
- "count": 1
- }
- },
"web/app/components/base/prompt-editor/plugins/history-block/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 3
@@ -2268,16 +2228,6 @@
"count": 1
}
},
- "web/app/components/datasets/common/document-picker/index.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
- "web/app/components/datasets/common/document-picker/preview-document-picker.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/datasets/common/image-previewer/index.tsx": {
"no-irregular-whitespace": {
"count": 1
@@ -2894,14 +2844,6 @@
"count": 1
}
},
- "web/app/components/datasets/settings/permission-selector/index.tsx": {
- "no-restricted-imports": {
- "count": 1
- },
- "react/no-missing-key": {
- "count": 1
- }
- },
"web/app/components/datasets/settings/summary-index-setting.tsx": {
"no-restricted-imports": {
"count": 1
@@ -3069,21 +3011,11 @@
"count": 1
}
},
- "web/app/components/header/account-setting/api-based-extension-page/selector.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/header/account-setting/data-source-page-new/card.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
- "web/app/components/header/account-setting/data-source-page-new/configure.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@@ -3167,19 +3099,6 @@
"count": 4
}
},
- "web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": {
- "no-restricted-imports": {
- "count": 2
- }
- },
- "web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": {
- "no-restricted-imports": {
- "count": 2
- },
- "ts/no-explicit-any": {
- "count": 2
- }
- },
"web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": {
"no-restricted-imports": {
"count": 1
@@ -3411,11 +3330,6 @@
"count": 1
}
},
- "web/app/components/plugins/marketplace/search-box/tags-filter.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -3447,14 +3361,6 @@
"count": 1
}
},
- "web/app/components/plugins/plugin-auth/authorized/index.tsx": {
- "no-restricted-imports": {
- "count": 2
- },
- "ts/no-explicit-any": {
- "count": 2
- }
- },
"web/app/components/plugins/plugin-auth/authorized/item.tsx": {
"no-restricted-imports": {
"count": 1
@@ -3510,16 +3416,6 @@
"count": 8
}
},
- "web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
- "web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -3713,11 +3609,6 @@
"count": 2
}
},
- "web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"ts/no-explicit-any": {
"count": 5
@@ -3756,16 +3647,6 @@
"count": 2
}
},
- "web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
- "web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/plugins/plugin-page/index.tsx": {
"no-restricted-imports": {
"count": 1
@@ -3918,11 +3799,6 @@
"count": 1
}
},
- "web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -4248,11 +4124,6 @@
"count": 1
}
},
- "web/app/components/workflow/block-selector/main.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/workflow/block-selector/market-place-plugin/action.tsx": {
"react/set-state-in-effect": {
"count": 1
@@ -4378,19 +4249,6 @@
"count": 1
}
},
- "web/app/components/workflow/header/view-history.tsx": {
- "no-restricted-imports": {
- "count": 2
- }
- },
- "web/app/components/workflow/header/view-workflow-history.tsx": {
- "no-restricted-imports": {
- "count": 1
- },
- "ts/no-explicit-any": {
- "count": 1
- }
- },
"web/app/components/workflow/hooks-store/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@@ -5053,11 +4911,6 @@
"count": 5
}
},
- "web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": {
"no-restricted-imports": {
"count": 1
@@ -5306,16 +5159,6 @@
"count": 1
}
},
- "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
- "web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/workflow/nodes/knowledge-retrieval/default.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -5414,17 +5257,6 @@
"count": 2
}
},
- "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": {
- "erasable-syntax-only/enums": {
- "count": 1
- },
- "no-restricted-imports": {
- "count": 1
- },
- "react/set-state-in-effect": {
- "count": 2
- }
- },
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx": {
"no-restricted-imports": {
"count": 1
@@ -5871,24 +5703,11 @@
"count": 1
}
},
- "web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
- "no-restricted-imports": {
- "count": 1
- },
- "react-refresh/only-export-components": {
- "count": 1
- }
- },
"web/app/components/workflow/note-node/note-editor/toolbar/command.tsx": {
"no-restricted-imports": {
"count": 1
}
},
- "web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/workflow/note-node/note-editor/utils.ts": {
"regexp/no-useless-quantifier": {
"count": 1
@@ -6012,11 +5831,6 @@
"count": 1
}
},
- "web/app/components/workflow/panel/version-history-panel/filter/index.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": {
"no-restricted-imports": {
"count": 1
diff --git a/web/__mocks__/__tests__/base-ui-popover.spec.tsx b/web/__mocks__/__tests__/base-ui-popover.spec.tsx
new file mode 100644
index 0000000000..3b5b741ca0
--- /dev/null
+++ b/web/__mocks__/__tests__/base-ui-popover.spec.tsx
@@ -0,0 +1,127 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '../base-ui-popover'
+
+type PopoverHarnessProps = {
+ useRenderElement?: boolean
+ preventDefaultOnTrigger?: boolean
+}
+
+const PopoverHarness = ({
+ useRenderElement = false,
+ preventDefaultOnTrigger = false,
+}: PopoverHarnessProps) => {
+ const [open, setOpen] = React.useState(false)
+
+ return (
+
+
outside
+
+ {
+ if (preventDefaultOnTrigger)
+ event.preventDefault()
+ }}
+ >
+ toggle
+
+ )
+ : undefined}
+ >
+ fallback trigger
+
+ }
+ popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes}
+ >
+ popover body
+
+
+
{open ? 'open' : 'closed'}
+
+ )
+}
+
+describe('base-ui-popover mock', () => {
+ it('should toggle popover content from the fallback trigger and expose content props', () => {
+ render()
+
+ expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
+ expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+
+ expect(screen.getByTestId('open-state')).toHaveTextContent('open')
+ expect(screen.getByTestId('popover-content')).toHaveAttribute('data-placement', 'bottom-start')
+ expect(screen.getByTestId('popover-content')).toHaveAttribute('data-side-offset', '4')
+ expect(screen.getByTestId('popover-content')).toHaveAttribute('data-align-offset', '8')
+ expect(screen.getByTestId('popover-content')).toHaveAttribute('data-positioner', 'true')
+ expect(screen.getByTestId('popover-content')).toHaveAttribute('data-popup', 'true')
+ expect(screen.getByTestId('popover-content')).toHaveClass('custom-content')
+ })
+
+ it('should keep the popover open on inside clicks and close it on outside clicks or escape', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('custom-trigger'))
+ expect(screen.getByTestId('open-state')).toHaveTextContent('open')
+
+ fireEvent.mouseDown(screen.getByTestId('popover-content'))
+ expect(screen.getByTestId('open-state')).toHaveTextContent('open')
+
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
+
+ fireEvent.click(screen.getByTestId('custom-trigger'))
+ expect(screen.getByTestId('open-state')).toHaveTextContent('open')
+
+ fireEvent.mouseDown(screen.getByTestId('outside-area'))
+ expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
+ })
+
+ it('should preserve rendered trigger props and respect preventDefault', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('custom-trigger'))
+
+ expect(screen.getByTestId('custom-trigger')).toHaveAttribute('data-popover-trigger', 'true')
+ expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
+ expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
+ })
+
+ it('should keep the popover closed when the fallback trigger click is prevented', () => {
+ const handleClick = (event: React.MouseEvent) => {
+ event.preventDefault()
+ }
+
+ render(
+
+
+
+ fallback trigger
+
+
+ popover body
+
+
+
,
+ )
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+
+ expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/__mocks__/base-ui-popover.tsx b/web/__mocks__/base-ui-popover.tsx
new file mode 100644
index 0000000000..8818f60f4e
--- /dev/null
+++ b/web/__mocks__/base-ui-popover.tsx
@@ -0,0 +1,154 @@
+import type { ReactNode } from 'react'
+import * as React from 'react'
+
+const PopoverContext = React.createContext({
+ open: false,
+ onOpenChange: (_open: boolean) => {},
+})
+
+type PopoverProps = {
+ children?: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+
+type PopoverTriggerProps = React.HTMLAttributes & {
+ children?: ReactNode
+ nativeButton?: boolean
+ render?: React.ReactElement
+}
+
+type PopoverContentProps = React.HTMLAttributes & {
+ children?: ReactNode
+ placement?: string
+ sideOffset?: number
+ alignOffset?: number
+ positionerProps?: React.HTMLAttributes
+ popupProps?: React.HTMLAttributes
+}
+
+export const Popover = ({
+ children,
+ open = false,
+ onOpenChange,
+}: PopoverProps) => {
+ React.useEffect(() => {
+ if (!open)
+ return
+
+ const handleMouseDown = (event: MouseEvent) => {
+ const target = event.target as Element | null
+ if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]'))
+ return
+
+ onOpenChange?.(false)
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape')
+ onOpenChange?.(false)
+ }
+
+ document.addEventListener('mousedown', handleMouseDown)
+ document.addEventListener('keydown', handleKeyDown)
+
+ return () => {
+ document.removeEventListener('mousedown', handleMouseDown)
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [open, onOpenChange])
+
+ return (
+ {}),
+ }}
+ >
+
+ {children}
+
+
+ )
+}
+
+export const PopoverTrigger = ({
+ children,
+ render,
+ nativeButton: _nativeButton,
+ onClick,
+ ...props
+}: PopoverTriggerProps) => {
+ const { open, onOpenChange } = React.useContext(PopoverContext)
+ const node = render ?? children
+
+ if (React.isValidElement(node)) {
+ const triggerElement = node as React.ReactElement>
+ const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes & { 'data-testid'?: string }
+
+ return React.cloneElement(triggerElement, {
+ ...props,
+ ...childProps,
+ 'data-testid': childProps['data-testid'] ?? 'popover-trigger',
+ 'data-popover-trigger': 'true',
+ 'onClick': (event: React.MouseEvent) => {
+ childProps.onClick?.(event)
+ onClick?.(event)
+ if (event.defaultPrevented)
+ return
+ onOpenChange(!open)
+ },
+ })
+ }
+
+ return (
+ {
+ onClick?.(event)
+ if (event.defaultPrevented)
+ return
+ onOpenChange(!open)
+ }}
+ {...props}
+ >
+ {node}
+
+ )
+}
+
+export const PopoverContent = ({
+ children,
+ className,
+ placement,
+ sideOffset,
+ alignOffset,
+ positionerProps,
+ popupProps,
+ ...props
+}: PopoverContentProps) => {
+ const { open } = React.useContext(PopoverContext)
+
+ if (!open)
+ return null
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children}>
+export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children}>
+export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children}>
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx
index 29de1a1eae..6d7c178728 100644
--- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx
+++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx
@@ -3,13 +3,13 @@ import type { FC } from 'react'
import type { PopupProps } from './config-popup'
import { cn } from '@langgenius/dify-ui/cn'
-import * as React from 'react'
-import { useCallback, useRef, useState } from 'react'
import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
+import * as React from 'react'
+import { useState } from 'react'
import ConfigPopup from './config-popup'
type Props = {
@@ -25,36 +25,31 @@ const ConfigBtn: FC = ({
children,
...popupProps
}) => {
- const [open, doSetOpen] = useState(false)
- const openRef = useRef(open)
- const setOpen = useCallback((v: boolean) => {
- doSetOpen(v)
- openRef.current = v
- }, [doSetOpen])
-
- const handleTrigger = useCallback(() => {
- setOpen(!openRef.current)
- }, [setOpen])
+ const [open, setOpen] = useState(false)
if (popupProps.readOnly && !hasConfigured)
return null
return (
-
-
-
- {children}
-
-
-
+
+ {children}
+
+ )}
+ />
+
-
-
+
+
)
}
export default React.memo(ConfigBtn)
diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx
index 057f3d03df..3b82db72db 100644
--- a/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx
+++ b/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx
@@ -1,3 +1,4 @@
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiChatSettingsLine,
} from '@remixicon/react'
@@ -6,30 +7,29 @@ import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
-import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
const ViewFormDropdown = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
-
- setOpen(v => !v)}
+
+
+
+ )}
+ />
+
-
-
-
-
-
@@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
-
-
+
+
)
}
diff --git a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx
index 3a2cec9820..1b8518e869 100644
--- a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx
+++ b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx
@@ -6,6 +6,8 @@ import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import Popup from '../popup'
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDownload: vi.fn(),
}))
diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx
index 51a73bc4b6..9ea4a6b742 100644
--- a/web/app/components/base/chat/chat/citation/popup.tsx
+++ b/web/app/components/base/chat/chat/citation/popup.tsx
@@ -1,13 +1,9 @@
import type { FC, MouseEvent } from 'react'
import type { Resources } from './index'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileIcon from '@/app/components/base/file-icon'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import Link from '@/next/link'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
@@ -47,22 +43,25 @@ const Popup: FC = ({
}
return (
-
- setOpen(v => !v)}>
-
-
-
{data.documentName}
-
-
-
+
+
+ {data.documentName}
+
+ )}
+ />
+
@@ -156,8 +155,8 @@ const Popup: FC
= ({
-
-
+
+
)
}
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
index 68cddb97b0..f7fc80819e 100644
--- a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
@@ -1,10 +1,10 @@
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 ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
-import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
type Props = {
iconColor?: string
@@ -17,25 +17,27 @@ const ViewFormDropdown = ({
const [open, setOpen] = useState(false)
return (
-
- setOpen(v => !v)}>
-
-
-
-
-
+
+
+
+ )}
+ />
+
-
-
+
+
)
}
diff --git a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx
index ea4f1bb928..d37647f358 100644
--- a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx
+++ b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx
@@ -3,6 +3,20 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'
import dayjs from '../../utils/dayjs'
import DatePicker from '../index'
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+vi.mock('@langgenius/dify-ui/button', () => ({
+ Button: ({ children, onClick, disabled, className }: {
+ children?: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ className?: string
+ }) => (
+
+ ),
+}))
+
// Mock scrollIntoView
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@@ -113,14 +127,13 @@ describe('DatePicker', () => {
render()
openPicker()
+ expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
- // Simulate a mousedown event outside the container
act(() => {
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
- // The picker should now be closed - input shows its value
- // The picker should now be closed - input shows its value
+ expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
})
diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx
index 9c84e4c096..7858fa2fbe 100644
--- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx
+++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx
@@ -1,14 +1,10 @@
import type { Dayjs } from 'dayjs'
import type { DatePickerProps, Period } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import Calendar from '../calendar'
import TimePickerHeader from '../time-picker/header'
import TimePickerOptions from '../time-picker/options'
@@ -35,15 +31,14 @@ const DatePicker = ({
needTimePicker = true,
renderTrigger,
triggerWrapClassName,
- popupZIndexClassname = 'z-11',
+ popupZIndexClassname,
noConfirm,
getIsDateDisabled,
}: DatePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState(ViewType.date)
- const containerRef = useRef(null)
- const isInitial = useRef(true)
+ const isInitialRef = useRef(true)
// Normalize the value to ensure that all subsequent uses are Day.js objects.
const normalizedValue = useMemo(() => {
@@ -62,46 +57,41 @@ const DatePicker = ({
const [selectedYear, setSelectedYear] = useState(() => (inputValue || defaultValue).year())
useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
- setIsOpen(false)
- setView(ViewType.date)
- }
- }
- document.addEventListener('mousedown', handleClickOutside)
- return () => document.removeEventListener('mousedown', handleClickOutside)
- }, [])
-
- useEffect(() => {
- if (isInitial.current) {
- isInitial.current = false
+ if (isInitialRef.current) {
+ isInitialRef.current = false
return
}
clearMonthMapCache()
if (normalizedValue) {
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
+ // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
setCurrentDate(newValue)
+ // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
setSelectedDate(newValue)
onChange(newValue)
}
else {
+ // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
+ // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
}
+ // eslint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes.
}, [timezone])
- const handleClickTrigger = (e: React.MouseEvent) => {
- e.stopPropagation()
- if (isOpen) {
- setIsOpen(false)
- return
- }
+ const handleOpenChange = useCallback((nextOpen: boolean) => {
+ setIsOpen(nextOpen)
setView(ViewType.date)
- setIsOpen(true)
- if (normalizedValue) {
+ if (nextOpen && normalizedValue) {
setCurrentDate(normalizedValue)
setSelectedDate(normalizedValue)
}
+ }, [normalizedValue])
+
+ const handleClickTrigger = (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ handleOpenChange(!isOpen)
}
const handleClear = (e: React.MouseEvent) => {
@@ -210,21 +200,21 @@ const DatePicker = ({
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('defaultPlaceholder', { ns: 'time' }))
return (
-
-
- {renderTrigger
- ? (
- renderTrigger({
- value: normalizedValue,
- selectedDate,
- isOpen,
- handleClear,
- handleClickTrigger,
- }))
+
)}
-
-
+ />
+
{/* Header */}
{view === ViewType.date
@@ -319,8 +314,8 @@ const DatePicker = ({
)
}
-
-
+
+
)
}
diff --git a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx
index 7fbed3a736..0d02b3b5d5 100644
--- a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx
+++ b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx
@@ -3,6 +3,20 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
import dayjs, { isDayjsObject } from '../../utils/dayjs'
import TimePicker from '../index'
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+vi.mock('@langgenius/dify-ui/button', () => ({
+ Button: ({ children, onClick, disabled, className }: {
+ children?: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ className?: string
+ }) => (
+
+ ),
+}))
+
// Mock scrollIntoView since the test DOM runtime doesn't implement it
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@@ -106,7 +120,7 @@ describe('TimePicker', () => {
expect(input)!.toHaveValue('')
fireEvent.mouseDown(document.body)
- expect(input)!.toHaveValue('')
+ expect(input)!.toHaveValue('10:00 AM')
})
it('should call onClear when clear is clicked while picker is closed', () => {
diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx
index e07ea177a5..3fcb88215e 100644
--- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx
+++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx
@@ -1,14 +1,10 @@
import type { Dayjs } from 'dayjs'
import type { TimePickerProps } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import TimezoneLabel from '@/app/components/base/timezone-label'
import { Period } from '../types'
import dayjs, {
@@ -43,31 +39,20 @@ const TimePicker = ({
}: TimePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
- const containerRef = useRef(null)
- const isInitial = useRef(true)
+ const isInitialRef = useRef(true)
// Initialize selectedTime
const [selectedTime, setSelectedTime] = useState(() => {
return toDayjs(value, { timezone })
})
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- /* v8 ignore next 2 -- outside-click closing is handled by PortalToFollowElem; this local ref guard is a defensive fallback. */
- if (containerRef.current && !containerRef.current.contains(event.target as Node))
- setIsOpen(false)
- }
- document.addEventListener('mousedown', handleClickOutside)
- return () => document.removeEventListener('mousedown', handleClickOutside)
- }, [])
-
// Track previous values to avoid unnecessary updates
const prevValueRef = useRef(value)
const prevTimezoneRef = useRef(timezone)
useEffect(() => {
- if (isInitial.current) {
- isInitial.current = false
+ if (isInitialRef.current) {
+ isInitialRef.current = false
// Save initial values on first render
prevValueRef.current = value
prevTimezoneRef.current = timezone
@@ -91,6 +76,7 @@ const TimePicker = ({
if (!dayjsValue)
return
+ // eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
setSelectedTime(dayjsValue)
if (timezoneChanged && !valueChanged)
@@ -98,6 +84,7 @@ const TimePicker = ({
return
}
+ // eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
setSelectedTime((prev) => {
if (!isDayjsObject(prev))
return undefined
@@ -105,24 +92,30 @@ const TimePicker = ({
})
}, [timezone, value, onChange])
- const handleClickTrigger = (e: React.MouseEvent) => {
- e.stopPropagation()
- if (isOpen) {
- setIsOpen(false)
+ const syncSelectedTimeFromValue = useCallback(() => {
+ if (!value)
return
- }
- setIsOpen(true)
- if (value) {
- const dayjsValue = toDayjs(value, { timezone })
- const needsUpdate = dayjsValue && (
- !selectedTime
- || !isDayjsObject(selectedTime)
- || !dayjsValue.isSame(selectedTime, 'minute')
- )
- if (needsUpdate)
- setSelectedTime(dayjsValue)
- }
+ const dayjsValue = toDayjs(value, { timezone })
+ const needsUpdate = dayjsValue && (
+ !selectedTime
+ || !isDayjsObject(selectedTime)
+ || !dayjsValue.isSame(selectedTime, 'minute')
+ )
+ if (needsUpdate)
+ setSelectedTime(dayjsValue)
+ }, [selectedTime, timezone, value])
+
+ const handleOpenChange = useCallback((nextOpen: boolean) => {
+ setIsOpen(nextOpen)
+ if (nextOpen)
+ syncSelectedTimeFromValue()
+ }, [syncSelectedTimeFromValue])
+
+ const handleClickTrigger = (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ handleOpenChange(!isOpen)
}
const handleClear = (e: React.MouseEvent) => {
@@ -132,7 +125,7 @@ const TimePicker = ({
onClear()
}
- const handleTimeSelect = (hour: string, minute: string, period: Period) => {
+ const handleTimeSelect = useCallback((hour: string, minute: string, period: Period) => {
const periodAdjustedHour = to24Hour(hour, period)
const nextMinute = Number.parseInt(minute, 10)
setSelectedTime((prev) => {
@@ -145,7 +138,7 @@ const TimePicker = ({
.set('second', 0)
.set('millisecond', 0)
})
- }
+ }, [timezone])
const getSafeTimeObject = useCallback(() => {
if (isDayjsObject(selectedTime))
@@ -156,17 +149,17 @@ const TimePicker = ({
const handleSelectHour = useCallback((hour: string) => {
const time = getSafeTimeObject()
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
- }, [getSafeTimeObject])
+ }, [getSafeTimeObject, handleTimeSelect])
const handleSelectMinute = useCallback((minute: string) => {
const time = getSafeTimeObject()
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
- }, [getSafeTimeObject])
+ }, [getSafeTimeObject, handleTimeSelect])
const handleSelectPeriod = useCallback((period: Period) => {
const time = getSafeTimeObject()
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
- }, [getSafeTimeObject])
+ }, [getSafeTimeObject, handleTimeSelect])
const handleSelectCurrentTime = useCallback(() => {
const newDate = getDateWithTimezone({ timezone })
@@ -207,18 +200,19 @@ const TimePicker = ({
/>
)
return (
-
-
- {renderTrigger
- ? (renderTrigger({
+
)}
-
-
+ />
+
{/* Header */}
@@ -258,8 +257,8 @@ const TimePicker = ({
/>
-
-
+
+
)
}
diff --git a/web/app/components/base/date-and-time-picker/types.ts b/web/app/components/base/date-and-time-picker/types.ts
index 0068ec22ac..2773fb7bc7 100644
--- a/web/app/components/base/date-and-time-picker/types.ts
+++ b/web/app/components/base/date-and-time-picker/types.ts
@@ -28,7 +28,7 @@ export type DatePickerProps = {
onChange: (date: Dayjs | undefined) => void
onClear: () => void
triggerWrapClassName?: string
- renderTrigger?: (props: TriggerProps) => React.ReactNode
+ renderTrigger?: (props: TriggerProps) => React.ReactElement
minuteFilter?: (minutes: string[]) => string[]
popupZIndexClassname?: string
noConfirm?: boolean
@@ -62,7 +62,7 @@ export type TimePickerProps = {
placeholder?: string
onChange: (date: Dayjs | undefined) => void
onClear: () => void
- renderTrigger?: (props: TriggerParams) => React.ReactNode
+ renderTrigger?: (props: TriggerParams) => React.ReactElement
title?: string
minuteFilter?: (minutes: string[]) => string[]
popupClassName?: string
diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx
index dc111a680b..6259c7cb4f 100644
--- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx
+++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx
@@ -61,7 +61,7 @@ describe('FileUploadSettings (setting-modal)', () => {
})
})
- it('should call onOpen with toggle function when trigger is clicked', () => {
+ it('should call onOpen with true when trigger is clicked', () => {
const onOpen = vi.fn()
renderWithProvider(
@@ -71,12 +71,7 @@ describe('FileUploadSettings (setting-modal)', () => {
fireEvent.click(screen.getByText('Upload Settings'))
- expect(onOpen).toHaveBeenCalled()
- // The toggle function should flip the open state
- const toggleFn = onOpen.mock.calls[0]![0]
- expect(typeof toggleFn).toBe('function')
- expect(toggleFn(false)).toBe(true)
- expect(toggleFn(true)).toBe(false)
+ expect(onOpen).toHaveBeenCalledWith(true)
})
it('should not call onOpen when disabled', () => {
diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx
index 2a09f63bee..a1c6bffbe0 100644
--- a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx
+++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx
@@ -1,16 +1,16 @@
'use client'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { memo } from 'react'
import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
type FileUploadSettingsProps = {
open: boolean
- onOpen: (state: any) => void
+ onOpen: (state: boolean) => void
onChange?: OnFeaturesChange
disabled?: boolean
children?: React.ReactNode
@@ -25,18 +25,27 @@ const FileUploadSettings = ({
imageUpload,
}: FileUploadSettingsProps) => {
return (
- {
+ if (disabled)
+ return
+ onOpen(nextOpen)
}}
>
- !disabled && onOpen((open: boolean) => !open)}>
- {children}
-
-
+
+ {children}
+
+ )}
+ />
+
-
-
+
+
)
}
export default memo(FileUploadSettings)
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx
index 574aeddd4a..b2dd37d1e8 100644
--- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx
@@ -1,38 +1,17 @@
+import type { ReactNode } from 'react'
import type { Features } from '../../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { FeaturesProvider } from '../../../context'
import VoiceSettings from '../voice-settings'
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({
- children,
- placement,
- offset,
- }: {
- children: React.ReactNode
- placement?: string
- offset?: { mainAxis?: number }
- }) => (
-
- {children}
-
- ),
- PortalToFollowElemTrigger: ({
- children,
- onClick,
- }: {
- children: React.ReactNode
- onClick?: () => void
- }) => (
-
- {children}
-
- ),
- PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {children}
,
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
+vi.mock('@langgenius/dify-ui/toast', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
}))
vi.mock('@/next/navigation', () => ({
@@ -46,6 +25,25 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
+vi.mock('@langgenius/dify-ui/switch', () => ({
+ Switch: ({
+ checked,
+ onCheckedChange,
+ ...props
+ }: {
+ checked?: boolean
+ onCheckedChange?: (checked: boolean) => void
+ }) => (
+
+ )}
+ />
+
{!!hasUploadFromLocal && (
@@ -115,8 +117,8 @@ const UploaderButton: FC = ({
>
)}
-
-
+
+
)
}
diff --git a/web/app/components/base/image-uploader/text-generation-image-uploader.tsx b/web/app/components/base/image-uploader/text-generation-image-uploader.tsx
index 1b986744f2..af05eb2a35 100644
--- a/web/app/components/base/image-uploader/text-generation-image-uploader.tsx
+++ b/web/app/components/base/image-uploader/text-generation-image-uploader.tsx
@@ -1,5 +1,10 @@
import type { FC } from 'react'
import type { ImageFile, VisionSettings } from '@/types/app'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import {
Fragment,
useEffect,
@@ -8,11 +13,6 @@ import {
import { useTranslation } from 'react-i18next'
import { Link03 } from '@/app/components/base/icons/src/vender/line/general'
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { TransferMethod } from '@/types/app'
import { useImageFiles } from './hooks'
import ImageLinkInput from './image-link-input'
@@ -35,35 +35,38 @@ const PasteImageLinkButton: FC = ({
onUpload(imageFile)
}
- const handleToggle = () => {
- if (disabled)
- return
-
- setOpen(v => !v)
- }
-
return (
- {
+ if (disabled)
+ return
+ setOpen(nextOpen)
+ }}
>
-
-
-
- {t('imageUploader.pasteImageLink', { ns: 'common' })}
-
-
-
+
+
+ {t('imageUploader.pasteImageLink', { ns: 'common' })}
+
+ )}
+ />
+
-
-
+
+
)
}
diff --git a/web/app/components/base/portal-to-follow-elem/index.tsx b/web/app/components/base/portal-to-follow-elem/index.tsx
index 8b531be309..fbadeaf302 100644
--- a/web/app/components/base/portal-to-follow-elem/index.tsx
+++ b/web/app/components/base/portal-to-follow-elem/index.tsx
@@ -148,14 +148,17 @@ export const PortalToFollowElemTrigger = (
}: React.HTMLProps & { ref?: React.RefObject, asChild?: boolean },
) => {
const context = usePortalToFollowElemContext()
- const childrenRef = (children as any).props?.ref
+ const childElement = React.isValidElement<{ ref?: React.Ref }>(children)
+ ? children
+ : null
+ const childrenRef = childElement?.props.ref
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
// `asChild` allows the user to pass any element as the anchor
- if (asChild && React.isValidElement(children)) {
- const childProps = (children.props ?? {}) as Record
+ if (asChild && childElement) {
+ const childProps = (childElement.props ?? {}) as Record
return React.cloneElement(
- children,
+ childElement,
context.getReferenceProps({
ref,
...props,
diff --git a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx
index eb011af528..64bca0a24d 100644
--- a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx
+++ b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx
@@ -2,6 +2,9 @@ import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../../constants'
import ContextBlockComponent from '../component'
+
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+
// Mock the hooks used by ContextBlockComponent
const mockUseSelectOrDelete = vi.fn()
const mockUseTrigger = vi.fn()
@@ -223,6 +226,21 @@ describe('ContextBlockComponent', () => {
})
describe('User Interactions', () => {
+ it('should keep the popover closed when the trigger prevents the default click', async () => {
+ const user = userEvent.setup()
+ const { triggerSetOpen } = defaultSetup()
+ render(
+ ,
+ )
+
+ await user.click(screen.getByTestId('popover-trigger'))
+
+ expect(triggerSetOpen).not.toHaveBeenCalled()
+ expect(
+ screen.queryByText('common.promptEditor.context.modal.add'),
+ ).not.toBeInTheDocument()
+ })
+
it('should call onAddContext when add button is clicked', async () => {
defaultSetup({ open: true })
const handleAddContext = vi.fn()
@@ -345,6 +363,29 @@ describe('ContextBlockComponent', () => {
// Original datasets still there
expect(screen.getByText('Dataset A')).toBeInTheDocument()
})
+
+ it('should ignore string events from the event emitter', () => {
+ defaultSetup({ open: true })
+ let subscriptionCallback: (v: Record | string) => void = () => { }
+ mockUseSubscription.mockImplementation((cb: (v: Record | string) => void) => {
+ subscriptionCallback = cb
+ })
+
+ render(
+ ,
+ )
+
+ act(() => {
+ subscriptionCallback('ignore-me')
+ })
+
+ expect(screen.getByText('Dataset A')).toBeInTheDocument()
+ expect(screen.getByText('Dataset B')).toBeInTheDocument()
+ })
})
describe('Edge Cases', () => {
diff --git a/web/app/components/base/prompt-editor/plugins/context-block/component.tsx b/web/app/components/base/prompt-editor/plugins/context-block/component.tsx
index 35f6948e07..05fff5b4e8 100644
--- a/web/app/components/base/prompt-editor/plugins/context-block/component.tsx
+++ b/web/app/components/base/prompt-editor/plugins/context-block/component.tsx
@@ -1,13 +1,10 @@
import type { FC } from 'react'
import type { Dataset } from './index'
+import type { EventEmitterValue } from '@/context/event-emitter'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants'
import { useSelectOrDelete, useTrigger } from '../../hooks'
@@ -32,9 +29,12 @@ const ContextBlockComponent: FC = ({
const { eventEmitter } = useEventEmitterContextContext()
const [localDatasets, setLocalDatasets] = useState(datasets)
- eventEmitter?.useSubscription((v: any) => {
- if (v?.type === UPDATE_DATASETS_EVENT_EMITTER)
- setLocalDatasets(v.payload)
+ eventEmitter?.useSubscription((event?: EventEmitterValue) => {
+ if (typeof event === 'string')
+ return
+
+ if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && Array.isArray(event.payload))
+ setLocalDatasets(event.payload as Dataset[])
})
return (
@@ -49,24 +49,31 @@ const ContextBlockComponent: FC = ({
{t('promptEditor.context.item.title', { ns: 'common' })}
{!canNotAddContext && (
-
-
-
- {localDatasets.length}
-
-
-
+ `}
+ ref={triggerRef}
+ onClick={e => e.preventDefault()}
+ >
+ {localDatasets.length}
+
+ )}
+ />
+
@@ -95,8 +102,8 @@ const ContextBlockComponent: FC = ({
{t('promptEditor.context.modal.footer', { ns: 'common' })}
-
-
+
+
)}
diff --git a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx
index aa4f0a85ca..4d57498424 100644
--- a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx
+++ b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx
@@ -6,6 +6,8 @@ import { UPDATE_HISTORY_EVENT_EMITTER } from '../../../constants'
import HistoryBlockComponent from '../component'
import { DELETE_HISTORY_BLOCK_COMMAND } from '../index'
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+
type HistoryEventPayload = {
type?: string
payload?: RoleName
@@ -109,6 +111,24 @@ describe('HistoryBlockComponent', () => {
expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument()
})
+ it('should keep the popover closed when the trigger prevents the default click', async () => {
+ const user = userEvent.setup()
+ const setOpen = vi.fn() as unknown as Dispatch>
+ mockUseTrigger.mockReturnValue(createTriggerHookReturn(false, setOpen))
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByTestId('popover-trigger'))
+
+ expect(setOpen).not.toHaveBeenCalled()
+ expect(screen.queryByText('common.promptEditor.history.modal.edit')).not.toBeInTheDocument()
+ })
+
it('should call onEditRole when edit action is clicked', async () => {
const user = userEvent.setup()
const onEditRole = vi.fn()
@@ -188,6 +208,29 @@ describe('HistoryBlockComponent', () => {
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
})
+ it('should ignore string events from the event emitter', () => {
+ mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
+
+ render(
+ ,
+ )
+
+ expect(subscribedHandler).not.toBeNull()
+ act(() => {
+ subscribedHandler?.('ignore-me' as unknown as HistoryEventPayload)
+ })
+
+ expect(screen.getByText('kept-user')).toBeInTheDocument()
+ expect(screen.getByText('kept-assistant')).toBeInTheDocument()
+ })
+
it('should render when event emitter is unavailable', () => {
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: undefined,
diff --git a/web/app/components/base/prompt-editor/plugins/history-block/component.tsx b/web/app/components/base/prompt-editor/plugins/history-block/component.tsx
index 15ce102bd9..a9bc68ac30 100644
--- a/web/app/components/base/prompt-editor/plugins/history-block/component.tsx
+++ b/web/app/components/base/prompt-editor/plugins/history-block/component.tsx
@@ -1,16 +1,13 @@
import type { FC } from 'react'
import type { RoleName } from './index'
+import type { EventEmitterValue } from '@/context/event-emitter'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiMoreFill,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
import { useSelectOrDelete, useTrigger } from '../../hooks'
@@ -33,9 +30,12 @@ const HistoryBlockComponent: FC = ({
const { eventEmitter } = useEventEmitterContextContext()
const [localRoleName, setLocalRoleName] = useState(roleName)
- eventEmitter?.useSubscription((v: any) => {
- if (v?.type === UPDATE_HISTORY_EVENT_EMITTER)
- setLocalRoleName(v.payload)
+ eventEmitter?.useSubscription((event?: EventEmitterValue) => {
+ if (typeof event === 'string')
+ return
+
+ if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload && typeof event.payload === 'object')
+ setLocalRoleName(event.payload as RoleName)
})
return (
@@ -49,25 +49,31 @@ const HistoryBlockComponent: FC = ({
>
{t('promptEditor.history.item.title', { ns: 'common' })}
-
-
-
-
-
-
-
+ ref={triggerRef}
+ onClick={e => e.preventDefault()}
+ >
+
+
+ )}
+ />
+
{t('promptEditor.history.modal.title', { ns: 'common' })}
@@ -87,8 +93,8 @@ const HistoryBlockComponent: FC
= ({
{t('promptEditor.history.modal.edit', { ns: 'common' })}
-
-
+
+
)
}
diff --git a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx
index f8f0ce6e12..1251eab9fb 100644
--- a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx
@@ -5,34 +5,7 @@ import * as React from 'react'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import DocumentPicker from '../index'
-// Mock portal-to-follow-elem - always render content for testing
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({ children, open }: {
- children: React.ReactNode
- open?: boolean
- }) => (
-
- {children}
-
- ),
- PortalToFollowElemTrigger: ({ children, onClick }: {
- children: React.ReactNode
- onClick?: () => void
- }) => (
-
- {children}
-
- ),
- // Always render content to allow testing document selection
- PortalToFollowElemContent: ({ children, className }: {
- children: React.ReactNode
- className?: string
- }) => (
-
- {children}
-
- ),
-}))
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Mock useDocumentList hook with controllable return value
let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined
@@ -152,6 +125,10 @@ const renderComponent = (props: Partial {
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+}
+
describe('DocumentPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -165,7 +142,7 @@ describe('DocumentPicker', () => {
it('should render without crashing', () => {
renderComponent()
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should render document name when provided', () => {
@@ -273,7 +250,7 @@ describe('DocumentPicker', () => {
onChange,
})
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle value with all fields', () => {
@@ -318,13 +295,13 @@ describe('DocumentPicker', () => {
it('should initialize with popup closed', () => {
renderComponent()
- expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
+ expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
it('should open popup when trigger is clicked', () => {
renderComponent()
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Verify click handler is called
@@ -430,7 +407,7 @@ describe('DocumentPicker', () => {
)
// The component should use the new callback
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should memoize handleChange callback with useCallback', () => {
@@ -440,7 +417,7 @@ describe('DocumentPicker', () => {
renderComponent({ onChange })
// Verify component renders correctly, callback memoization is internal
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@@ -518,7 +495,7 @@ describe('DocumentPicker', () => {
it('should toggle popup when trigger is clicked', () => {
renderComponent()
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Trigger click should be handled
@@ -591,7 +568,7 @@ describe('DocumentPicker', () => {
renderComponent()
// When loading, component should still render without crashing
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should fetch documents on mount', () => {
@@ -611,7 +588,7 @@ describe('DocumentPicker', () => {
renderComponent()
// Component should render without crashing
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle undefined data response', () => {
@@ -620,7 +597,7 @@ describe('DocumentPicker', () => {
renderComponent()
// Should not crash
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@@ -732,13 +709,13 @@ describe('DocumentPicker', () => {
renderComponent()
// Should not crash
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle rapid toggle clicks', () => {
renderComponent()
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
// Rapid clicks
fireEvent.click(trigger)
@@ -795,7 +772,7 @@ describe('DocumentPicker', () => {
renderComponent()
// Should not crash
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle document list mapping with various data_source_detail_dict states', () => {
@@ -819,7 +796,7 @@ describe('DocumentPicker', () => {
renderComponent()
// Should not crash during mapping
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@@ -829,13 +806,13 @@ describe('DocumentPicker', () => {
it('should handle empty datasetId', () => {
renderComponent({ datasetId: '' })
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle UUID format datasetId', () => {
renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' })
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@@ -926,6 +903,7 @@ describe('DocumentPicker', () => {
const onChange = vi.fn()
renderComponent({ onChange })
+ openPopover()
fireEvent.click(screen.getByText('Document 2'))
@@ -939,6 +917,7 @@ describe('DocumentPicker', () => {
mockDocumentListData = { data: docs }
renderComponent()
+ openPopover()
// Documents should be rendered in the list
expect(screen.getByText('Document 1')).toBeInTheDocument()
@@ -978,14 +957,14 @@ describe('DocumentPicker', () => {
// The mapping: d.data_source_detail_dict?.upload_file?.extension || ''
// Should extract 'pdf' from the document
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should render trigger with SearchInput integration', () => {
renderComponent()
// The trigger is always rendered
- expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
+ expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
})
it('should integrate FileIcon component', () => {
@@ -1001,7 +980,7 @@ describe('DocumentPicker', () => {
})
// FileIcon should render an SVG icon for the file extension
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@@ -1010,9 +989,10 @@ describe('DocumentPicker', () => {
describe('Visual States', () => {
it('should render portal content for document selection', () => {
renderComponent()
+ openPopover()
- // Portal content is rendered in our mock for testing
- expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+ // Popover content is rendered after opening the trigger in our mock
+ expect(screen.getByTestId('popover-content')).toBeInTheDocument()
})
})
})
diff --git a/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx
index 7178e9f60c..c7eb2c740c 100644
--- a/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx
+++ b/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx
@@ -3,34 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PreviewDocumentPicker from '../preview-document-picker'
-// Mock portal-to-follow-elem - always render content for testing
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({ children, open }: {
- children: React.ReactNode
- open?: boolean
- }) => (
-
- {children}
-
- ),
- PortalToFollowElemTrigger: ({ children, onClick }: {
- children: React.ReactNode
- onClick?: () => void
- }) => (
-
- {children}
-
- ),
- // Always render content to allow testing document selection
- PortalToFollowElemContent: ({ children, className }: {
- children: React.ReactNode
- className?: string
- }) => (
-
- {children}
-
- ),
-}))
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Factory function to create mock DocumentItem
const createMockDocumentItem = (overrides: Partial = {}): DocumentItem => ({
@@ -67,6 +40,10 @@ const renderComponent = (props: Partial {
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+}
+
describe('PreviewDocumentPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -77,7 +54,7 @@ describe('PreviewDocumentPicker', () => {
it('should render without crashing', () => {
renderComponent()
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should render document name from value prop', () => {
@@ -110,7 +87,7 @@ describe('PreviewDocumentPicker', () => {
files: [], // Use empty files to avoid duplicate icons
})
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
@@ -120,7 +97,7 @@ describe('PreviewDocumentPicker', () => {
files: [], // Use empty files to avoid duplicate icons
})
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@@ -131,22 +108,21 @@ describe('PreviewDocumentPicker', () => {
const props = createDefaultProps()
render()
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should apply className to trigger element', () => {
renderComponent({ className: 'custom-class' })
- const trigger = screen.getByTestId('portal-trigger')
- const innerDiv = trigger.querySelector('.custom-class')
- expect(innerDiv).toBeInTheDocument()
+ const trigger = screen.getByTestId('popover-trigger')
+ expect(trigger).toHaveClass('custom-class')
})
it('should handle empty files array', () => {
// Component should render without crashing with empty files
renderComponent({ files: [] })
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle single file', () => {
@@ -155,7 +131,7 @@ describe('PreviewDocumentPicker', () => {
files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })],
})
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle multiple files', () => {
@@ -164,7 +140,7 @@ describe('PreviewDocumentPicker', () => {
files: createMockDocumentList(5),
})
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should use value.extension for file icon', () => {
@@ -172,7 +148,7 @@ describe('PreviewDocumentPicker', () => {
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
})
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@@ -182,13 +158,13 @@ describe('PreviewDocumentPicker', () => {
it('should initialize with popup closed', () => {
renderComponent()
- expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
+ expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
it('should toggle popup when trigger is clicked', () => {
renderComponent()
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
@@ -196,9 +172,10 @@ describe('PreviewDocumentPicker', () => {
it('should render portal content for document selection', () => {
renderComponent()
+ openPopover()
- // Portal content is always rendered in our mock for testing
- expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+ // Popover content is rendered after opening the trigger in our mock
+ expect(screen.getByTestId('popover-content')).toBeInTheDocument()
})
})
@@ -242,7 +219,7 @@ describe('PreviewDocumentPicker', () => {
,
)
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@@ -265,7 +242,7 @@ describe('PreviewDocumentPicker', () => {
,
)
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@@ -274,7 +251,7 @@ describe('PreviewDocumentPicker', () => {
it('should toggle popup when trigger is clicked', () => {
renderComponent()
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
@@ -283,6 +260,7 @@ describe('PreviewDocumentPicker', () => {
it('should render document list with files', () => {
const files = createMockDocumentList(3)
renderComponent({ files })
+ openPopover()
// Documents should be visible in the list
expect(screen.getByText('Document 1')).toBeInTheDocument()
@@ -295,6 +273,7 @@ describe('PreviewDocumentPicker', () => {
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
+ openPopover()
fireEvent.click(screen.getByText('Document 2'))
@@ -306,7 +285,7 @@ describe('PreviewDocumentPicker', () => {
it('should handle rapid toggle clicks', () => {
renderComponent()
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
// Rapid clicks
fireEvent.click(trigger)
@@ -337,14 +316,14 @@ describe('PreviewDocumentPicker', () => {
// Renders placeholder for missing name
expect(screen.getByText('--')).toBeInTheDocument()
// Portal wrapper renders
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle empty files array', () => {
renderComponent({ files: [] })
// Component should render without crashing
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle very long document names', () => {
@@ -374,7 +353,7 @@ describe('PreviewDocumentPicker', () => {
render()
// Component should render without crashing
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle large number of files', () => {
@@ -382,7 +361,7 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files: manyFiles })
// Component should accept large files array
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle files with same name but different extensions', () => {
@@ -393,7 +372,7 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files })
// Component should handle duplicate names
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@@ -427,7 +406,7 @@ describe('PreviewDocumentPicker', () => {
files: [createMockDocumentItem({ name: 'Single' })],
})
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle two files', () => {
@@ -435,7 +414,7 @@ describe('PreviewDocumentPicker', () => {
files: createMockDocumentList(2),
})
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle many files', () => {
@@ -443,7 +422,7 @@ describe('PreviewDocumentPicker', () => {
files: createMockDocumentList(50),
})
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
@@ -451,23 +430,22 @@ describe('PreviewDocumentPicker', () => {
it('should apply custom className', () => {
renderComponent({ className: 'my-custom-class' })
- const trigger = screen.getByTestId('portal-trigger')
- expect(trigger.querySelector('.my-custom-class')).toBeInTheDocument()
+ const trigger = screen.getByTestId('popover-trigger')
+ expect(trigger).toHaveClass('my-custom-class')
})
it('should work without className', () => {
renderComponent({ className: undefined })
- expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
+ expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
})
it('should handle multiple class names', () => {
renderComponent({ className: 'class-one class-two' })
- const trigger = screen.getByTestId('portal-trigger')
- const element = trigger.querySelector('.class-one')
- expect(element).toBeInTheDocument()
- expect(element).toHaveClass('class-two')
+ const trigger = screen.getByTestId('popover-trigger')
+ expect(trigger).toHaveClass('class-one')
+ expect(trigger).toHaveClass('class-two')
})
})
@@ -480,7 +458,7 @@ describe('PreviewDocumentPicker', () => {
files: [], // Use empty files to avoid duplicate icons
})
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@@ -491,6 +469,7 @@ describe('PreviewDocumentPicker', () => {
it('should render all documents in the list', () => {
const files = createMockDocumentList(5)
renderComponent({ files })
+ openPopover()
// All documents should be visible
files.forEach((file) => {
@@ -503,6 +482,7 @@ describe('PreviewDocumentPicker', () => {
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
+ openPopover()
fireEvent.click(screen.getByText('Document 1'))
@@ -528,6 +508,7 @@ describe('PreviewDocumentPicker', () => {
onChange={vi.fn()}
/>,
)
+ openPopover()
expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument()
})
})
@@ -537,9 +518,8 @@ describe('PreviewDocumentPicker', () => {
it('should apply hover styles on trigger', () => {
renderComponent()
- const trigger = screen.getByTestId('portal-trigger')
- const innerDiv = trigger.querySelector('.hover\\:bg-state-base-hover')
- expect(innerDiv).toBeInTheDocument()
+ const trigger = screen.getByTestId('popover-trigger')
+ expect(trigger).toHaveClass('hover:bg-state-base-hover')
})
it('should have truncate class for long names', () => {
@@ -568,6 +548,7 @@ describe('PreviewDocumentPicker', () => {
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
+ openPopover()
fireEvent.click(screen.getByText('Document 1'))
@@ -582,10 +563,12 @@ describe('PreviewDocumentPicker', () => {
]
renderComponent({ files: customFiles, onChange })
+ openPopover()
fireEvent.click(screen.getByText('Custom File 1'))
expect(onChange).toHaveBeenCalledWith(customFiles[0])
+ openPopover()
fireEvent.click(screen.getByText('Custom File 2'))
expect(onChange).toHaveBeenCalledWith(customFiles[1])
})
@@ -597,8 +580,11 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files, onChange })
// Select multiple documents sequentially
+ openPopover()
fireEvent.click(screen.getByText('Document 1'))
+ openPopover()
fireEvent.click(screen.getByText('Document 3'))
+ openPopover()
fireEvent.click(screen.getByText('Document 2'))
expect(onChange).toHaveBeenCalledTimes(3)
diff --git a/web/app/components/datasets/common/document-picker/index.tsx b/web/app/components/datasets/common/document-picker/index.tsx
index d0e389255a..0566b590de 100644
--- a/web/app/components/datasets/common/document-picker/index.tsx
+++ b/web/app/components/datasets/common/document-picker/index.tsx
@@ -2,6 +2,11 @@
import type { FC } from 'react'
import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
@@ -9,11 +14,6 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
import Loading from '@/app/components/base/loading'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import SearchInput from '@/app/components/base/search-input'
import { ChunkingMode } from '@/models/datasets'
import { useDocumentList } from '@/service/knowledge/use-document'
@@ -61,7 +61,6 @@ const DocumentPicker: FC = ({
const [open, {
set: setOpen,
- toggle: togglePopup,
}] = useBoolean(false)
const ArrowIcon = RiArrowDownSLine
@@ -77,34 +76,40 @@ const DocumentPicker: FC = ({
}, [parentMode, t])
return (
-
-
-
-
-
-
-
- {' '}
- {name || '--'}
-
-
-
-
-
-
- {isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
- {isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
- {isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
-
+
+
+
+
+
+ {' '}
+ {name || '--'}
+
+
+
+
+
+
+ {isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
+ {isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
+ {isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
+
+
-
-
-
+ )}
+ />
+
{documentsList
@@ -125,9 +130,8 @@ const DocumentPicker: FC
= ({
)}
-
-
-
+
+
)
}
export default React.memo(DocumentPicker)
diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx
index 03ee13b513..597ceda9a5 100644
--- a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx
+++ b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx
@@ -2,17 +2,17 @@
import type { FC } from 'react'
import type { DocumentItem } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback } 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 FileIcon from '../document-file-icon'
import DocumentList from './document-list'
@@ -35,7 +35,6 @@ const PreviewDocumentPicker: FC = ({
const [open, {
set: setOpen,
- toggle: togglePopup,
}] = useBoolean(false)
const ArrowIcon = RiArrowDownSLine
@@ -45,27 +44,32 @@ const PreviewDocumentPicker: FC = ({
}, [onChange, setOpen])
return (
-
-
-
-
-
-
-
- {' '}
- {name || '--'}
-
-
+
+
+
+
+
+ {' '}
+ {name || '--'}
+
+
+
-
-
-
+ )}
+ />
+
{files?.length > 1 &&
{t('preprocessDocument', { ns: 'dataset', num: files.length })}
}
{files?.length > 0
@@ -81,9 +85,8 @@ const PreviewDocumentPicker: FC
= ({
)}
-
-
-
+
+
)
}
export default React.memo(PreviewDocumentPicker)
diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx
index 11f1286306..9c29206e7d 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx
@@ -231,8 +231,9 @@ describe('StepTwoPreview', () => {
describe('Props Passing', () => {
it('should render preview button when isIdle is true', () => {
render()
- // ChunkPreview shows a preview button when idle
- const previewButton = screen.queryByRole('button')
+ const previewButton = screen.getByRole('button', {
+ name: 'datasetPipeline.addDocuments.stepTwo.previewChunks',
+ })
expect(previewButton).toBeInTheDocument()
})
@@ -240,13 +241,13 @@ describe('StepTwoPreview', () => {
const onPreview = vi.fn()
render()
- // Find and click the preview button
- const buttons = screen.getAllByRole('button')
- const previewButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('preview'))
- if (previewButton) {
- previewButton.click()
- expect(onPreview).toHaveBeenCalled()
- }
+ const previewButton = screen.getByRole('button', {
+ name: 'datasetPipeline.addDocuments.stepTwo.previewChunks',
+ })
+
+ previewButton.click()
+
+ expect(onPreview).toHaveBeenCalled()
})
})
diff --git a/web/app/components/datasets/settings/permission-selector/index.tsx b/web/app/components/datasets/settings/permission-selector/index.tsx
index a9b0c348d8..724a239c94 100644
--- a/web/app/components/datasets/settings/permission-selector/index.tsx
+++ b/web/app/components/datasets/settings/permission-selector/index.tsx
@@ -1,17 +1,17 @@
import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useCallback, 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 { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { DatasetPermission } from '@/models/datasets'
import MemberItem from './member-item'
@@ -90,93 +90,98 @@ const PermissionSelector = ({
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
return (
- {
+ if (disabled)
+ return
+ setOpen(nextOpen)
+ }}
>
-
!disabled && setOpen(v => !v)}
- className="block"
- >
-
- {
- isOnlyMe && (
- <>
-
-
- {t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
-
- >
- )
- }
- {
- isAllTeamMembers && (
- <>
-
-
-
-
- {t('form.permissionsAllMember', { ns: 'datasetSettings' })}
-
- >
- )
- }
- {
- isPartialMembers && (
- <>
-
- {
- selectedMembers.length === 1 && (
-
- )
- }
- {
- selectedMembers.length >= 2 && (
- <>
+
+ {
+ isOnlyMe && (
+ <>
+
+
+ {t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
+
+ >
+ )
+ }
+ {
+ isAllTeamMembers && (
+ <>
+
+
+
+
+ {t('form.permissionsAllMember', { ns: 'datasetSettings' })}
+
+ >
+ )
+ }
+ {
+ isPartialMembers && (
+ <>
+
+ {
+ selectedMembers.length === 1 && (
-
- >
- )
- }
-
-
- {selectedMemberNames}
-
- >
- )
- }
-
-
-
-
+ )
+ }
+ {
+ selectedMembers.length >= 2 && (
+ <>
+
+
+ >
+ )
+ }
+
+
+ {selectedMemberNames}
+
+ >
+ )
+ }
+
+
+ )}
+ />
+
{/* Only me */}
@@ -236,6 +241,7 @@ const PermissionSelector = ({
)}
{filteredMemberList.map(member => (
}
@@ -256,9 +262,9 @@ const PermissionSelector = ({
)}
-
+
-
+
)
}
diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx
index 82acd22d97..44dfdbc5a5 100644
--- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx
+++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx
@@ -15,6 +15,8 @@ vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx
index 041657d4be..3f207ef23f 100644
--- a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx
+++ b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx
@@ -1,4 +1,5 @@
import type { FC } from 'react'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiAddLine,
RiArrowDownSLine,
@@ -8,11 +9,6 @@ import { useTranslation } from 'react-i18next'
import {
ArrowUpRight,
} from '@/app/components/base/icons/src/vender/line/arrows'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
@@ -41,35 +37,42 @@ const ApiBasedExtensionSelector: FC = ({
const currentItem = data?.find(item => item.id === value)
return (
-
- setOpen(v => !v)} className="w-full">
- {
- currentItem
- ? (
-
-
{currentItem.name}
-
-
- {currentItem.api_endpoint}
+
+ {
+ currentItem
+ ? (
+
+
{currentItem.name}
+
+
+ {currentItem.api_endpoint}
+
+
+
-
-
-
- )
- : (
-
- {t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
-
-
- )
- }
-
-
+ )
+ : (
+
+ {t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
+
+
+ )
+ }
+
+ )}
+ />
+
@@ -116,8 +119,8 @@ const ApiBasedExtensionSelector: FC
= ({
-
-
+
+
)
}
diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx
index 5f844d02e3..07344343f8 100644
--- a/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx
+++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx
@@ -1,3 +1,4 @@
+import type { ButtonHTMLAttributes, ReactNode } from 'react'
import type { DataSourceAuth } from '../types'
import type { FormSchema } from '@/app/components/base/form/types'
import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types'
@@ -6,6 +7,15 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
import Configure from '../configure'
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
+vi.mock('@langgenius/dify-ui/button', () => ({
+ Button: ({ children, ...props }: ButtonHTMLAttributes
& { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
/**
* Configure Component Tests
* Using Unit approach to ensure 100% coverage and stable tests.
diff --git a/web/app/components/header/account-setting/data-source-page-new/configure.tsx b/web/app/components/header/account-setting/data-source-page-new/configure.tsx
index 712fb91415..f242d17079 100644
--- a/web/app/components/header/account-setting/data-source-page-new/configure.tsx
+++ b/web/app/components/header/account-setting/data-source-page-new/configure.tsx
@@ -5,6 +5,11 @@ import type {
PluginPayload,
} from '@/app/components/plugins/plugin-auth/types'
import { Button } from '@langgenius/dify-ui/button'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import {
RiAddLine,
} from '@remixicon/react'
@@ -15,11 +20,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import {
AddApiKeyButton,
AddOAuthButton,
@@ -56,10 +56,6 @@ const Configure = ({
}
}, [pluginPayload, t])
- const handleToggle = useCallback(() => {
- setOpen(v => !v)
- }, [])
-
const handleUpdate = useCallback(() => {
setOpen(false)
onUpdate?.()
@@ -67,24 +63,26 @@ const Configure = ({
return (
<>
-
-
-
-
- {t('dataSource.configure', { ns: 'common' })}
-
-
-
+
+
+ {t('dataSource.configure', { ns: 'common' })}
+
+ )}
+ />
+
{
!!canOAuth && (
@@ -122,8 +120,8 @@ const Configure = ({
)
}
-
-
+
+
>
)
}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx
index 43a27dac9b..d0ba140fde 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx
@@ -34,33 +34,17 @@ vi.mock('@remixicon/react', () => ({
RiAddLine: () => ,
}))
-vi.mock('@/app/components/base/tooltip', () => ({
- default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
+vi.mock('@langgenius/dify-ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => (
{children}
-
{popupContent}
),
+ TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}>,
+ TooltipContent: ({ children }: { children: React.ReactNode }) => {children}
,
}))
-// Mock portal components to avoid async test DOM issues (consistent with sibling tests)
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => (
-
- {children}
-
- ),
- PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
- {children}
- ),
- PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
- // In many tests, we need to find elements inside the content even if "closed" in state
- // but not yet "removed" from DOM. However, to avoid multiple elements issues,
- // we should be careful.
- // For AddCustomModel, we need the content to be present when we click a model.
- return {children}
- },
-}))
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('AddCustomModel', () => {
const mockProvider = {
@@ -94,7 +78,7 @@ describe('AddCustomModel', () => {
/>,
)
- fireEvent.click(screen.getByTestId('portal-trigger'))
+ fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i }))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
@@ -107,10 +91,10 @@ describe('AddCustomModel', () => {
/>,
)
- fireEvent.click(screen.getByTestId('portal-trigger'))
+ fireEvent.click(screen.getByTestId('popover-trigger'))
// The portal should be "open"
- expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
+ expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
@@ -125,7 +109,7 @@ describe('AddCustomModel', () => {
/>,
)
- fireEvent.click(screen.getByTestId('portal-trigger'))
+ fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByText('gpt-4'))
expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model)
@@ -140,7 +124,7 @@ describe('AddCustomModel', () => {
/>,
)
- fireEvent.click(screen.getByTestId('portal-trigger'))
+ fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
@@ -159,7 +143,7 @@ describe('AddCustomModel', () => {
expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument()
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
- fireEvent.click(screen.getByTestId('portal-trigger'))
+ fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i }))
expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled()
})
})
diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx
index a1e181e97e..5191357d83 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx
@@ -7,6 +7,16 @@ import {
Button,
} from '@langgenius/dify-ui/button'
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 {
RiAddCircleFill,
RiAddLine,
@@ -17,12 +27,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
-import Tooltip from '@/app/components/base/tooltip'
import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelIcon from '../model-icon'
import { useAuth } from './hooks/use-auth'
@@ -67,12 +71,12 @@ const AddCustomModel = ({
},
)
const notAllowCustomCredential = provider.allow_custom_token === false
-
- const renderTrigger = useCallback((open?: boolean) => {
- const Item = (
+ const renderTrigger = useCallback((open?: boolean, onClick?: () => void) => {
+ const item = (
- {Item}
+
+
+ {t('auth.credentialUnavailable', { ns: 'plugin' })}
)
}
- return Item
+ return item
}, [t, notAllowCustomCredential, noModels])
+ if (noModels) {
+ return renderTrigger(false, notAllowCustomCredential ? undefined : handleOpenModalForAddNewCustomModel)
+ }
+
return (
-
- {
- if (noModels) {
- if (notAllowCustomCredential)
- return
- handleOpenModalForAddNewCustomModel()
- return
- }
-
- setOpen(prev => !prev)
- }}
+ {renderTrigger(open)} }
+ />
+
- {renderTrigger(open)}
-
-
{
@@ -125,8 +123,8 @@ const AddCustomModel = ({
key={model.model}
className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover"
onClick={() => {
- handleOpenModalForAddCustomModelToModelList(undefined, model)
setOpen(false)
+ handleOpenModalForAddCustomModelToModelList(undefined, model)
}}
>
{
- handleOpenModalForAddNewCustomModel()
setOpen(false)
+ handleOpenModalForAddNewCustomModel()
}}
>
@@ -160,8 +158,8 @@ const AddCustomModel = ({
)
}
-
-
+
+
)
}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx
index a331181619..f405fb5528 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx
@@ -45,6 +45,8 @@ vi.mock('../authorized-item', () => ({
),
}))
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+
describe('Authorized', () => {
const mockProvider: ModelProvider = {
provider: 'openai',
diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx
index ca0e0d9c73..d86eaf40c5 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx
@@ -1,3 +1,8 @@
+import type {
+ OffsetOptions,
+} from '@floating-ui/react'
+import type { Placement } from '@langgenius/dify-ui/popover'
+import type { MouseEvent } from 'react'
import type {
ConfigurationMethodEnum,
Credential,
@@ -6,9 +11,6 @@ import type {
ModelModalModeEnum,
ModelProvider,
} from '../../declarations'
-import type {
- PortalToFollowElemOptions,
-} from '@/app/components/base/portal-to-follow-elem'
import {
AlertDialog,
AlertDialogActions,
@@ -19,6 +21,11 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import {
RiAddLine,
} from '@remixicon/react'
@@ -29,11 +36,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { useAuth } from '../hooks'
import AuthorizedItem from './authorized-item'
@@ -43,7 +45,7 @@ type AuthorizedProps = {
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
authParams?: {
isModelCredential?: boolean
- onUpdate?: (newPayload?: any, formValues?: Record
) => void
+ onUpdate?: (newPayload?: Record, formValues?: Record) => void
onRemove?: (credentialId: string) => void
mode?: ModelModalModeEnum
}
@@ -57,8 +59,8 @@ type AuthorizedProps = {
renderTrigger: (open?: boolean) => React.ReactNode
isOpen?: boolean
onOpenChange?: (open: boolean) => void
- offset?: PortalToFollowElemOptions['offset']
- placement?: PortalToFollowElemOptions['placement']
+ offset?: number | OffsetOptions
+ placement?: Placement
triggerPopupSameWidth?: boolean
popupClassName?: string
showItemSelectedIcon?: boolean
@@ -132,9 +134,13 @@ const Authorized = ({
)
const handleEdit = useCallback((credential?: Credential, model?: CustomModel) => {
- handleOpenModal(credential, model)
setMergedIsOpen(false)
+ handleOpenModal(credential, model)
}, [handleOpenModal, setMergedIsOpen])
+ const handleDelete = useCallback((credential?: Credential, model?: CustomModel) => {
+ setMergedIsOpen(false)
+ openConfirmDelete(credential, model)
+ }, [openConfirmDelete, setMergedIsOpen])
const handleItemClick = useCallback((credential: Credential, model?: CustomModel) => {
if (disableItemClick)
@@ -148,30 +154,37 @@ const Authorized = ({
setMergedIsOpen(false)
}, [handleActiveCredential, onItemClick, setMergedIsOpen, disableItemClick])
const notAllowCustomCredential = provider.allow_custom_token === false
+ const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
+ const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
+ const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
+ const popupProps = triggerPopupSameWidth
+ ? { style: { width: 'var(--anchor-width, auto)' } }
+ : undefined
+ const handleTriggerClick = useCallback((event: MouseEvent) => {
+ if (!triggerOnlyOpenModal)
+ return
+
+ event.preventDefault()
+ handleOpenModal()
+ }, [handleOpenModal, triggerOnlyOpenModal])
return (
<>
-
- {
- if (triggerOnlyOpenModal) {
- handleOpenModal()
- return
- }
-
- setMergedIsOpen(!mergedIsOpen)
- }}
- asChild
+ {renderTrigger(mergedIsOpen)} }
+ onClick={handleTriggerClick}
+ />
+
- {renderTrigger(mergedIsOpen)}
-
-
{
- items.map((item, index) => (
-
+ items.map(item => (
+ credential.credential_id).join('-')}>
{
- index !== items.length - 1 && (
+ item !== items[items.length - 1] && (
)
}
@@ -245,8 +258,8 @@ const Authorized = ({
)
}
-
-
+
+
!open && closeConfirmDelete()}>
diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx
index 8609ba5539..51b4087659 100644
--- a/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx
@@ -62,31 +62,38 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
-// Mock portal-to-follow-elem with shared open state
+// Mock popover with shared open state
let mockPortalOpenState = false
+let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({ children, open }: {
+vi.mock('@langgenius/dify-ui/popover', () => ({
+ Popover: ({ children, open, onOpenChange }: {
children: React.ReactNode
open: boolean
+ onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpenState = open
+ mockPopoverOnOpenChange = onOpenChange
return (
{children}
)
},
- PortalToFollowElemTrigger: ({ children, onClick, className }: {
- children: React.ReactNode
- onClick: () => void
+ PopoverTrigger: ({ children, render, className }: {
+ children?: React.ReactNode
+ render?: React.ReactNode
className?: string
}) => (
-
- {children}
+
mockPopoverOnOpenChange?.(!mockPortalOpenState)}
+ className={className}
+ >
+ {render ?? children}
),
- PortalToFollowElemContent: ({ children, className }: {
+ PopoverContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => {
diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx
index 117b8cdfab..e87022fe38 100644
--- a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx
+++ b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx
@@ -1,10 +1,19 @@
-import { fireEvent, render, screen } from '@testing-library/react'
+import {
+ fireEvent,
+ render,
+ screen,
+ within,
+} from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagsFilter from '../tags-filter'
+const { mockTranslate } = vi.hoisted(() => ({
+ mockTranslate: vi.fn((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key),
+}))
+
vi.mock('#i18n', () => ({
useTranslation: () => ({
- t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
+ t: mockTranslate,
}),
}))
@@ -46,20 +55,7 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
-vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
- const _React = await import('react')
- return {
- PortalToFollowElem: ({ children }: { children: React.ReactNode }) =>
{children}
,
- PortalToFollowElemTrigger: ({
- children,
- onClick,
- }: {
- children: React.ReactNode
- onClick: () => void
- }) =>
{children},
- PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
,
- }
-})
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('../trigger/marketplace', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
@@ -80,8 +76,16 @@ vi.mock('../trigger/tool-selector', () => ({
}))
describe('TagsFilter', () => {
+ const ensurePopoverOpen = () => {
+ if (!screen.queryByTestId('popover-content'))
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+
+ return screen.getByTestId('popover-content')
+ }
+
beforeEach(() => {
vi.clearAllMocks()
+ mockTranslate.mockImplementation((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key)
})
it('renders marketplace trigger when used in marketplace', () => {
@@ -100,6 +104,7 @@ describe('TagsFilter', () => {
it('filters tag options by search text', () => {
render(
)
+ fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
@@ -116,11 +121,20 @@ describe('TagsFilter', () => {
const onTagsChange = vi.fn()
const { rerender } = render(
)
- fireEvent.click(screen.getByText('Agent'))
+ fireEvent.click(within(ensurePopoverOpen()).getByText('Agent'))
expect(onTagsChange).toHaveBeenCalledWith([])
rerender(
)
- fireEvent.click(screen.getByText('RAG'))
+ fireEvent.click(within(ensurePopoverOpen()).getByText('RAG'))
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
})
+
+ it('falls back to an empty placeholder when translation is missing', () => {
+ mockTranslate.mockImplementation(() => undefined as unknown as string)
+
+ render(
)
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+
+ expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '')
+ })
})
diff --git a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx
index b078dbaa9b..d97420b672 100644
--- a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx
+++ b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx
@@ -1,14 +1,14 @@
'use client'
import { useTranslation } from '#i18n'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
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'
import MarketplaceTrigger from './trigger/marketplace'
import ToolSelectorTrigger from './trigger/tool-selector'
@@ -37,43 +37,45 @@ const TagsFilter = ({
const selectedTagsLength = tags.length
return (
-
- setOpen(v => !v)}
+
+ {
+ usedInMarketplace && (
+
+ )
+ }
+ {
+ !usedInMarketplace && (
+
+ )
+ }
+
+ )}
+ />
+
- {
- usedInMarketplace && (
-
- )
- }
- {
- !usedInMarketplace && (
-
- )
- }
-
-
-
-
+
+
)
}
diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx
index 01e195b21b..271252464a 100644
--- a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx
@@ -73,6 +73,8 @@ vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn(),
}))
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+
// Mock service/use-triggers
vi.mock('@/service/use-triggers', () => ({
useTriggerPluginDynamicOptions: () => ({
diff --git a/web/app/components/plugins/plugin-auth/authorized/index.tsx b/web/app/components/plugins/plugin-auth/authorized/index.tsx
index fed2873b98..b8b34e33e0 100644
--- a/web/app/components/plugins/plugin-auth/authorized/index.tsx
+++ b/web/app/components/plugins/plugin-auth/authorized/index.tsx
@@ -1,7 +1,8 @@
-import type { Credential, PluginPayload } from '../types'
import type {
- PortalToFollowElemOptions,
-} from '@/app/components/base/portal-to-follow-elem'
+ OffsetOptions,
+} from '@floating-ui/react'
+import type { Placement } from '@langgenius/dify-ui/popover'
+import type { Credential, PluginPayload } from '../types'
import {
AlertDialog,
AlertDialogActions,
@@ -12,6 +13,11 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiArrowDownSLine,
@@ -23,11 +29,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import Authorize from '../authorize'
import ApiKeyModal from '../authorize/api-key-modal'
@@ -48,8 +49,8 @@ type AuthorizedProps = {
renderTrigger?: (open?: boolean) => React.ReactNode
isOpen?: boolean
onOpenChange?: (open: boolean) => void
- offset?: PortalToFollowElemOptions['offset']
- placement?: PortalToFollowElemOptions['placement']
+ offset?: number | OffsetOptions
+ placement?: Placement
triggerPopupSameWidth?: boolean
popupClassName?: string
disableSetDefault?: boolean
@@ -96,11 +97,12 @@ const Authorized = ({
const [deleteCredentialId, setDeleteCredentialId] = useState
(null)
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
const openConfirm = useCallback((credentialId?: string) => {
+ setMergedIsOpen(false)
if (credentialId)
pendingOperationCredentialId.current = credentialId
setDeleteCredentialId(pendingOperationCredentialId.current)
- }, [])
+ }, [setMergedIsOpen])
const closeConfirm = useCallback(() => {
setDeleteCredentialId(null)
pendingOperationCredentialId.current = null
@@ -130,11 +132,12 @@ const Authorized = ({
handleSetDoingAction(false)
}
}, [deletePluginCredential, onUpdate, t, handleSetDoingAction])
- const [editValues, setEditValues] = useState | null>(null)
- const handleEdit = useCallback((id: string, values: Record) => {
+ const [editValues, setEditValues] = useState | null>(null)
+ const handleEdit = useCallback((id: string, values: Record) => {
+ setMergedIsOpen(false)
pendingOperationCredentialId.current = id
setEditValues(values)
- }, [])
+ }, [setMergedIsOpen])
const handleRemove = useCallback(() => {
setDeleteCredentialId(pendingOperationCredentialId.current)
}, [])
@@ -171,49 +174,59 @@ const Authorized = ({
}, [updatePluginCredential, t, handleSetDoingAction, onUpdate])
const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use)
const unavailableCredential = credentials.find(credential => credential.not_allowed_to_use && credential.is_default)
+ const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
+ const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
+ const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
+ const popupProps = triggerPopupSameWidth
+ ? { style: { width: 'var(--anchor-width, auto)' } }
+ : undefined
return (
<>
-
- setMergedIsOpen(!mergedIsOpen)}
- asChild
- >
- {
- renderTrigger
- ? renderTrigger(mergedIsOpen)
- : (
-
-
- {credentials.length}
+
+ {
+ renderTrigger
+ ? renderTrigger(mergedIsOpen)
+ : (
+
+
+ {credentials.length}
- {
- credentials.length > 1
- ? t('auth.authorizations', { ns: 'plugin' })
- : t('auth.authorization', { ns: 'plugin' })
- }
- {
- !!unavailableCredentials.length && (
- ` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
- )
- }
-
-
- )
- }
-
-
+ {
+ credentials.length > 1
+ ? t('auth.authorizations', { ns: 'plugin' })
+ : t('auth.authorization', { ns: 'plugin' })
+ }
+ {
+ !!unavailableCredentials.length && (
+ ` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
+ )
+ }
+
+
+ )
+ }
+
+ )}
+ />
+
-
-
+
+
!open && closeConfirm()}>
diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx
index a319d2f8c4..af3f97c889 100644
--- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx
@@ -46,8 +46,8 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({
+vi.mock('@langgenius/dify-ui/popover', () => ({
+ Popover: ({
children,
open,
}: {
@@ -58,18 +58,20 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
{children}
),
- PortalToFollowElemTrigger: ({
+ PopoverTrigger: ({
children,
+ render,
onClick,
}: {
children: ReactNode
+ render?: ReactNode
onClick?: () => void
}) => (
- {children}
+ {render ?? children}
),
- PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
+ PopoverContent: ({ children }: { children: ReactNode }) => (
{children}
),
}))
diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx
index f7dd1921e4..7bc23d0223 100644
--- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx
@@ -76,7 +76,7 @@ afterAll(() => {
// Mock portal components for controlled positioning in tests
// Use React context to properly scope open state per portal instance (for nested portals)
-vi.mock('@/app/components/base/portal-to-follow-elem', () => {
+vi.mock('@langgenius/dify-ui/popover', () => {
// Context reference shared across mock components
let sharedContext: React.Context | null = null
@@ -90,7 +90,7 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
}
return {
- PortalToFollowElem: ({
+ Popover: ({
children,
open,
}: {
@@ -104,20 +104,22 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
React.createElement('div', { 'data-testid': 'portal-to-follow-elem', 'data-open': open }, children),
)
},
- PortalToFollowElemTrigger: ({
+ PopoverTrigger: ({
children,
+ render,
onClick,
className,
}: {
children: ReactNode
+ render?: ReactNode
onClick?: () => void
className?: string
}) => (
- {children}
+ {render ?? children}
),
- PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => {
+ PopoverContent: ({ children, className }: { children: ReactNode, className?: string }) => {
const Context = getContext()
const isOpen = React.useContext(Context)
if (!isOpen)
diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx
index 41140ac63b..cf387b1715 100644
--- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx
@@ -5,16 +5,16 @@ import type {
} from '@floating-ui/react'
import type { FC } from 'react'
import type { App } from '@/types/app'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { AppModeEnum } from '@/types/app'
type Props = {
@@ -154,26 +154,33 @@ const AppPicker: FC = ({
}
}
- const handleTriggerClick = () => {
- if (disabled)
+ const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
+ const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
+ const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
+ const handleTriggerClick = useCallback((event: React.MouseEvent) => {
+ event.preventDefault()
+ if (disabled || isShow)
return
+
onShowChange(true)
- }
+ }, [disabled, isShow, onShowChange])
return (
-
- {trigger} }
onClick={handleTriggerClick}
- >
- {trigger}
-
+ />
-
+
-
-
+
+
)
}
diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx
index 97e144af6f..76dbdba7aa 100644
--- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx
@@ -5,14 +5,14 @@ import type {
} from '@floating-ui/react'
import type { FC } from 'react'
import type { App } from '@/types/app'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import * as React 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 AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
@@ -94,6 +94,9 @@ const AppSelector: FC = ({
}, [currentAppInfo, displayedApps])
const hasMore = hasNextPage ?? true
+ const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
+ const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
+ const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
const handleLoadMore = useCallback(async () => {
if (isFetchingNextPage || !hasMore)
@@ -102,11 +105,13 @@ const AppSelector: FC = ({
await fetchNextPage()
}, [fetchNextPage, hasMore, isFetchingNextPage])
- const handleTriggerClick = () => {
- if (disabled)
+ const handleTriggerClick = useCallback((event: React.MouseEvent) => {
+ event.preventDefault()
+ if (disabled || isShow)
return
+
setIsShow(true)
- }
+ }, [disabled, isShow])
const [isShowChooseApp, setIsShowChooseApp] = useState(false)
const handleSelectApp = (app: App) => {
@@ -143,22 +148,27 @@ const AppSelector: FC = ({
return (
<>
-
-
+
+
+ )}
onClick={handleTriggerClick}
+ />
+
-
-
-
{t('appSelector.label', { ns: 'app' })}
@@ -193,8 +203,8 @@ const AppSelector: FC
= ({
/>
)}
-
-
+
+
>
)
}
diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx
index 168e4f1eba..acd9a9b146 100644
--- a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx
@@ -153,8 +153,8 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
}))
// Portal components need mocking for controlled positioning in tests
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({
+vi.mock('@langgenius/dify-ui/popover', () => ({
+ Popover: ({
children,
open,
}: {
@@ -165,18 +165,20 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
{children}
),
- PortalToFollowElemTrigger: ({
+ PopoverTrigger: ({
children,
+ render,
onClick,
}: {
children: ReactNode
+ render?: ReactNode
onClick?: () => void
}) => (
- {children}
+ {render ?? children}
),
- PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
+ PopoverContent: ({ children }: { children: ReactNode }) => (
{children}
),
}))
diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx
index b5e69ce254..109b0ece4c 100644
--- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx
@@ -8,13 +8,13 @@ import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { CollectionType } from '@/app/components/tools/types'
import Link from '@/next/link'
import {
@@ -102,15 +102,21 @@ const ToolSelector: FC = ({
getSettingsValue,
} = state
- const handleTriggerClick = () => {
+ const handleTriggerClick = (event: React.MouseEvent) => {
+ event.preventDefault()
if (disabled)
return
+ if (!currentProvider || !currentTool)
+ return
setIsShow(true)
}
// Determine portal open state based on controlled vs uncontrolled mode
const portalOpen = trigger ? controlledState : isShow
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
+ const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
+ const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
+ const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
// Build error tooltip content
const renderErrorTip = () => (
@@ -134,57 +140,58 @@ const ToolSelector: FC = ({
)
return (
-
- {
- if (!currentProvider || !currentTool)
- return
- handleTriggerClick()
- }}
+
+ {trigger}
+
+ {/* Default trigger - no value */}
+ {!trigger && !value?.provider_name && (
+
+ )}
+
+ {/* Default trigger - with value */}
+ {!trigger && value?.provider_name && (
+
+ )}
+
+ )}
+ onClick={handleTriggerClick}
+ />
+
+
- {trigger}
-
- {/* Default trigger - no value */}
- {!trigger && !value?.provider_name && (
-
- )}
-
- {/* Default trigger - with value */}
- {!trigger && value?.provider_name && (
-
- )}
-
-
-
= ({
onParamsFormChange={handleParamsFormChange}
/>
-
-
+
+
)
}
diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx
index e4b698a5f8..c04012c498 100644
--- a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx
+++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx
@@ -2,17 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
- {children}
- ),
- PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
- {children}
- ),
- PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
-}))
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/cn', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
@@ -67,7 +57,7 @@ describe('CategoriesFilter', () => {
const mockOnChange = vi.fn()
render()
- const trigger = screen.getByTestId('portal-trigger')
+ const trigger = screen.getByTestId('popover-trigger')
const clearSvg = trigger.querySelector('svg')
fireEvent.click(clearSvg!)
expect(mockOnChange).toHaveBeenCalledWith([])
@@ -75,6 +65,7 @@ describe('CategoriesFilter', () => {
it('should render category options in dropdown', () => {
render()
+ fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByText('Tool'))!.toBeInTheDocument()
expect(screen.getByText('Model'))!.toBeInTheDocument()
@@ -85,6 +76,7 @@ describe('CategoriesFilter', () => {
const mockOnChange = vi.fn()
render()
+ fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByText('Tool'))
expect(mockOnChange).toHaveBeenCalledWith(['tool'])
})
@@ -93,8 +85,20 @@ describe('CategoriesFilter', () => {
const mockOnChange = vi.fn()
render()
+ fireEvent.click(screen.getByTestId('popover-trigger'))
const toolElements = screen.getAllByText('Tool')
fireEvent.click(toolElements[toolElements.length - 1]!)
expect(mockOnChange).toHaveBeenCalledWith([])
})
+
+ it('should filter categories by search text', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { target: { value: 'mod' } })
+
+ expect(screen.queryByText('Tool')).not.toBeInTheDocument()
+ expect(screen.getByText('Model')).toBeInTheDocument()
+ expect(screen.queryByText('Extension')).not.toBeInTheDocument()
+ })
})
diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx
index f30b5fb5fa..46493a87df 100644
--- a/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx
@@ -1,6 +1,7 @@
import type { Category, Tag } from '../constant'
import type { FilterState } from '../index'
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
+import { createContext, useContext } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ==================== Imports (after mocks) ====================
@@ -68,19 +69,47 @@ vi.mock('../../../hooks', () => ({
}),
}))
-// Track portal open state for testing
-let mockPortalOpenState = false
+type MockPopoverContextValue = {
+ open: boolean
+ onOpenChange?: (open: boolean) => void
+}
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
- mockPortalOpenState = open
- return {children}
- },
- PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
- {children}
+const MockPopoverContext = createContext({
+ open: false,
+})
+
+vi.mock('@langgenius/dify-ui/popover', () => ({
+ Popover: ({ children, open, onOpenChange }: {
+ children: React.ReactNode
+ open: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => (
+
+ {children}
+
),
- PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
- if (!mockPortalOpenState)
+ PopoverTrigger: ({ children, render, className }: {
+ children?: React.ReactNode
+ render?: React.ReactNode
+ className?: string
+ }) => {
+ const { open, onOpenChange } = useContext(MockPopoverContext)
+ return (
+ onOpenChange?.(!open)}
+ className={className}
+ >
+ {render ?? children}
+
+ )
+ },
+ PopoverContent: ({ children, className }: {
+ children: React.ReactNode
+ className?: string
+ }) => {
+ const { open } = useContext(MockPopoverContext)
+ if (!open)
return null
return {children}
},
@@ -457,7 +486,6 @@ describe('SearchBox Component', () => {
describe('CategoriesFilter Component', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockPortalOpenState = false
})
describe('Rendering', () => {
@@ -694,7 +722,6 @@ describe('CategoriesFilter Component', () => {
describe('TagFilter Component', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockPortalOpenState = false
})
describe('Rendering', () => {
@@ -857,7 +884,6 @@ describe('FilterManagement Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInitFilters = createFilterState()
- mockPortalOpenState = false
})
describe('Rendering', () => {
diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx
index ff3cd3d97c..f5db25bf5a 100644
--- a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx
+++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx
@@ -2,8 +2,6 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagFilter from '../tag-filter'
-let portalOpen = false
-
vi.mock('../../../hooks', () => ({
useTags: () => ({
tags: [
@@ -19,35 +17,17 @@ vi.mock('../../../hooks', () => ({
}),
}))
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({
- children,
- open,
- }: {
- children: React.ReactNode
- open: boolean
- }) => {
- portalOpen = open
- return {children}
- },
- PortalToFollowElemTrigger: ({
- children,
- onClick,
- }: {
- children: React.ReactNode
- onClick: () => void
- }) => {children},
- PortalToFollowElemContent: ({
- children,
- }: {
- children: React.ReactNode
- }) => portalOpen ? {children}
: null,
-}))
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
describe('TagFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
- portalOpen = false
+ })
+
+ it('renders the all tags placeholder when nothing is selected', () => {
+ render()
+
+ expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
})
it('renders selected tag labels and the overflow counter', () => {
@@ -61,8 +41,8 @@ describe('TagFilter', () => {
const onChange = vi.fn()
render()
- fireEvent.click(screen.getByTestId('trigger'))
- const portal = screen.getByTestId('portal-content')
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ const portal = screen.getByTestId('popover-content')
fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } })
@@ -73,4 +53,24 @@ describe('TagFilter', () => {
expect(onChange).toHaveBeenCalledWith(['agent', 'rag'])
})
+
+ it('clears all selected tags when the clear icon is clicked', () => {
+ const onChange = vi.fn()
+ render()
+
+ const trigger = screen.getByTestId('popover-trigger')
+ fireEvent.click(trigger.querySelector('svg')!)
+
+ expect(onChange).toHaveBeenCalledWith([])
+ })
+
+ it('removes a selected tag when clicking the same option again', () => {
+ const onChange = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ fireEvent.click(within(screen.getByTestId('popover-content')).getByText('Agent'))
+
+ expect(onChange).toHaveBeenCalledWith([])
+ })
})
diff --git a/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx b/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx
index 8dbef5395d..f75c63be94 100644
--- a/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx
+++ b/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx
@@ -1,6 +1,11 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import {
RiArrowDownSLine,
RiCloseCircleFill,
@@ -9,11 +14,6 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { useCategories } from '../../hooks'
type CategoriesFilterProps = {
@@ -38,61 +38,64 @@ const CategoriesFilter = ({
const selectedTagsLength = value.length
return (
-
- setOpen(v => !v)}>
-
+
+
+ {
+ !selectedTagsLength && t('allCategories', { ns: 'plugin' })
+ }
+ {
+ !!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',')
+ }
+ {
+ selectedTagsLength > 2 && (
+
+ +
+ {selectedTagsLength - 2}
+
+ )
+ }
+
{
- !selectedTagsLength && t('allCategories', { ns: 'plugin' })
+ !!selectedTagsLength && (
+ {
+ e.stopPropagation()
+ onChange([])
+ }
+ }
+ />
+ )
}
{
- !!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',')
- }
- {
- selectedTagsLength > 2 && (
-
- +
- {selectedTagsLength - 2}
-
+ !selectedTagsLength && (
+
)
}
- {
- !!selectedTagsLength && (
- {
- e.stopPropagation()
- onChange([])
- }
- }
- />
- )
- }
- {
- !selectedTagsLength && (
-
- )
- }
-
-
-
+ )}
+ />
+
-
-
+
+
)
}
diff --git a/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx b/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx
index e245895b3b..6916edd219 100644
--- a/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx
+++ b/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx
@@ -1,6 +1,11 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import {
RiArrowDownSLine,
RiCloseCircleFill,
@@ -9,11 +14,6 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { useTags } from '../../hooks'
type TagsFilterProps = {
@@ -38,56 +38,62 @@ const TagsFilter = ({
const selectedTagsLength = value.length
return (
-
- setOpen(v => !v)}>
-
+
+
+ {
+ !selectedTagsLength && t('allTags', { ns: 'pluginTags' })
+ }
+ {
+ !!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',')
+ }
+ {
+ selectedTagsLength > 2 && (
+
+ +
+ {selectedTagsLength - 2}
+
+ )
+ }
+
{
- !selectedTagsLength && t('allTags', { ns: 'pluginTags' })
+ !!selectedTagsLength && (
+ {
+ e.stopPropagation()
+ onChange([])
+ }}
+ />
+ )
}
{
- !!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',')
- }
- {
- selectedTagsLength > 2 && (
-
- +
- {selectedTagsLength - 2}
-
+ !selectedTagsLength && (
+
)
}
- {
- !!selectedTagsLength && (
- onChange([])}
- />
- )
- }
- {
- !selectedTagsLength && (
-
- )
- }
-
-
-
+ )}
+ />
+
-
-
+
+
)
}
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
index dda765b48f..cff2a5f4c2 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
@@ -192,27 +192,6 @@ vi.mock('ahooks', () => ({
useKeyPress: vi.fn(),
}))
-let portalOpenState = false
-vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
- PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: PropsWithChildren<{
- open: boolean
- onOpenChange: (open: boolean) => void
- placement?: string
- offset?: unknown
- }>) => {
- portalOpenState = open
- return
{children}
- },
- PortalToFollowElemTrigger: ({ children, onClick }: PropsWithChildren<{ onClick?: () => void }>) => (
-
{children}
- ),
- PortalToFollowElemContent: ({ children }: PropsWithChildren) => {
- if (!portalOpenState)
- return null
- return
{children}
- },
-}))
-
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
default: ({ onConfirm, onCancel }: {
onConfirm: (name: string, icon: unknown, description?: string) => void
@@ -229,7 +208,6 @@ vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
describe('RagPipelineHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
- portalOpenState = false
mockStoreState = {
pipelineId: 'test-pipeline-id',
showDebugAndPreviewPanel: false,
@@ -351,7 +329,6 @@ describe('InputFieldButton', () => {
describe('Publisher', () => {
beforeEach(() => {
vi.clearAllMocks()
- portalOpenState = false
})
describe('Rendering', () => {
@@ -367,9 +344,9 @@ describe('Publisher', () => {
expect(button)!.toHaveClass('px-2')
})
- it('should render portal trigger element', () => {
+ it('should render publish trigger button', () => {
render(
)
- expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /workflow\.common\.publish/i }))!.toBeInTheDocument()
})
})
@@ -377,7 +354,7 @@ describe('Publisher', () => {
it('should call handleSyncWorkflowDraft when opening', () => {
render(
)
- fireEvent.click(screen.getByTestId('portal-trigger'))
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
@@ -385,12 +362,14 @@ describe('Publisher', () => {
it('should toggle open state when trigger clicked', () => {
render(
)
- const portal = screen.getByTestId('portal-elem')
- expect(portal)!.toHaveAttribute('data-open', 'false')
+ const trigger = screen.getByRole('button', { name: /workflow\.common\.publish/i })
+ expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
- fireEvent.click(screen.getByTestId('portal-trigger'))
+ fireEvent.click(trigger)
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
+ expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
+ expect(screen.getByText(/workflow\.common\.publishUpdate/i))!.toBeInTheDocument()
})
})
})
@@ -978,7 +957,6 @@ describe('RunMode', () => {
describe('Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
- portalOpenState = false
mockStoreState = {
pipelineId: 'test-pipeline-id',
showDebugAndPreviewPanel: false,
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
index abb334b393..afd7c04ed1 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
@@ -6,6 +6,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import Publisher from '../index'
import Popup from '../popup'
+vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
+vi.mock('@langgenius/dify-ui/button', () => ({
+ Button: ({ children, onClick, disabled, variant, className }: Record
) => (
+ void) | undefined}
+ disabled={disabled as boolean | undefined}
+ data-variant={variant as string | undefined}
+ className={className as string | undefined}
+ >
+ {children as React.ReactNode}
+
+ ),
+}))
+vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
+ AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => (
+ open
+ ? (
+
+ {children}
+ onOpenChange?.(false)}>
+ Close
+
+
+ )
+ : null
+ ),
+ AlertDialogActions: ({ children }: { children: React.ReactNode }) => {children}
,
+ AlertDialogCancelButton: ({ children }: { children: React.ReactNode }) => {children},
+ AlertDialogConfirmButton: ({ children, onClick, disabled }: Record) => void) | undefined} disabled={disabled as boolean | undefined}>{children as React.ReactNode},
+ AlertDialogContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ AlertDialogDescription: ({ children }: { children: React.ReactNode }) => {children}
,
+ AlertDialogTitle: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
@@ -60,7 +94,8 @@ vi.mock('@/context/dataset-detail', () => ({
const mockSetShowPricingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
- useModalContextSelector: () => mockSetShowPricingModal,
+ useModalContextSelector: (selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T): T =>
+ selector({ setShowPricingModal: mockSetShowPricingModal }),
}))
const mockIsAllowPublishAsCustomKnowledgePipelineTemplate = vi.fn(() => true)
@@ -200,8 +235,7 @@ describe('publisher', () => {
it('should render portal element in closed state by default', () => {
renderWithQueryClient()
- const trigger = screen.getByText('workflow.common.publish').closest('[data-state]')
- expect(trigger).toHaveAttribute('data-state', 'closed')
+ expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
})
@@ -277,6 +311,25 @@ describe('publisher', () => {
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
})
+
+ it('should close the outer popover before opening publish-as follow-up flow', async () => {
+ mockPublishedAt.mockReturnValue(1700000000)
+ mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false)
+ renderWithQueryClient()
+
+ fireEvent.click(screen.getByText('workflow.common.publish'))
+
+ await waitFor(() => {
+ expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByText('pipeline.common.publishAs'))
+
+ await waitFor(() => {
+ expect(screen.queryByText('pipeline.common.publishAs')).not.toBeInTheDocument()
+ })
+ expect(mockSetShowPricingModal).toHaveBeenCalled()
+ })
})
})
@@ -688,7 +741,7 @@ describe('publisher', () => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
- fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+ fireEvent.click(screen.getByTestId('alert-dialog-close'))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
index 103ee53210..dab8046c43 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
@@ -3,6 +3,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Popup from '../popup'
+vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
+ AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => (
+ open
+ ? (
+
+ {children}
+ onOpenChange?.(false)}>
+ Close
+
+
+ )
+ : null
+ ),
+ AlertDialogActions: ({ children }: { children?: React.ReactNode }) => {children}
,
+ AlertDialogCancelButton: ({ children }: { children?: React.ReactNode }) => {children},
+ AlertDialogConfirmButton: ({ children, onClick, disabled }: {
+ children?: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ }) => {children},
+ AlertDialogContent: ({ children }: { children?: React.ReactNode }) => {children}
,
+ AlertDialogDescription: ({ children }: { children?: React.ReactNode }) => {children}
,
+ AlertDialogTitle: ({ children }: { children?: React.ReactNode }) => {children}
,
+}))
+
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
const toastMocks = vi.hoisted(() => ({
@@ -36,6 +61,8 @@ let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z'
let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z'
let mockPipelineId: string | undefined = 'pipeline-123'
let mockIsAllowPublishAsCustom = true
+const mockUseBoolean = vi.hoisted(() => vi.fn())
+const mockUseKeyPress = vi.hoisted(() => vi.fn())
vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'ds-123' }),
useRouter: () => ({ push: mockPush }),
@@ -48,14 +75,8 @@ vi.mock('@/next/link', () => ({
}))
vi.mock('ahooks', () => ({
- useBoolean: (initial: boolean) => {
- const state = { value: initial }
- return [state.value, {
- setFalse: vi.fn(),
- setTrue: vi.fn(),
- }]
- },
- useKeyPress: vi.fn(),
+ useBoolean: (initial: boolean) => mockUseBoolean(initial),
+ useKeyPress: (...args: unknown[]) => mockUseKeyPress(...args),
}))
vi.mock('@/app/components/workflow/store', () => ({
@@ -126,7 +147,8 @@ vi.mock('@/context/i18n', () => ({
}))
vi.mock('@/context/modal-context', () => ({
- useModalContextSelector: () => mockSetShowPricingModal,
+ useModalContextSelector: (selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T) =>
+ selector({ setShowPricingModal: mockSetShowPricingModal }),
}))
vi.mock('@/context/provider-context', () => ({
@@ -194,6 +216,11 @@ describe('Popup', () => {
mockDraftUpdatedAt = '2024-06-01T00:00:00Z'
mockPipelineId = 'pipeline-123'
mockIsAllowPublishAsCustom = true
+ mockUseBoolean.mockImplementation((initial: boolean) => [initial, {
+ setFalse: vi.fn(),
+ setTrue: vi.fn(),
+ }])
+ mockUseKeyPress.mockImplementation(() => {})
})
afterEach(() => {
@@ -289,12 +316,61 @@ describe('Popup', () => {
describe('Publish As Knowledge Pipeline', () => {
it('should show pricing modal when not allowed', () => {
mockIsAllowPublishAsCustom = false
- render()
+ const onRequestClose = vi.fn()
+ render()
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
+ expect(onRequestClose).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).toHaveBeenCalled()
})
+
+ it('should request closing the outer popover before opening publish-as modal', () => {
+ const onRequestClose = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByText('pipeline.common.publishAs'))
+
+ expect(onRequestClose).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Overlay cleanup', () => {
+ it('should close confirm dialog when alert dialog requests close', () => {
+ const hideConfirm = vi.fn()
+ mockUseBoolean
+ .mockImplementationOnce(() => [true, { setFalse: hideConfirm, setTrue: vi.fn() }])
+ .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
+ .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
+ .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
+
+ render()
+
+ fireEvent.click(screen.getByTestId('alert-dialog-close'))
+
+ expect(hideConfirm).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Publish params', () => {
+ it('should publish as template with empty pipeline id fallback', async () => {
+ mockPipelineId = undefined
+ mockUseBoolean
+ .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
+ .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
+ .mockImplementationOnce(() => [true, { setFalse: vi.fn(), setTrue: vi.fn() }])
+ .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
+ render()
+
+ fireEvent.click(screen.getByTestId('publish-as-confirm'))
+
+ expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
+ pipelineId: '',
+ name: 'My Pipeline',
+ icon_info: { icon_type: 'emoji' },
+ description: 'desc',
+ })
+ })
})
describe('Time formatting', () => {
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx
index 3ea9aa0c1f..649b06ebca 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx
@@ -1,4 +1,5 @@
import { Button } from '@langgenius/dify-ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import {
memo,
@@ -6,11 +7,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import Popup from './popup'
@@ -26,28 +22,31 @@ const Publisher = () => {
}, [handleSyncWorkflowDraft])
return (
-
- handleOpenChange(!open)}>
-
- {t('common.publish', { ns: 'workflow' })}
-
-
-
-
-
-
-
+
+ {t('common.publish', { ns: 'workflow' })}
+
+
+ )}
+ />
+
+ handleOpenChange(false)} />
+
+
)
}
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
index 9cb026dffe..31f5957029 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
@@ -39,7 +39,11 @@ import { usePublishWorkflow } from '@/service/use-workflow'
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
-const Popup = () => {
+type PopupProps = {
+ onRequestClose?: () => void
+}
+
+const Popup = ({ onRequestClose }: PopupProps) => {
const { t } = useTranslation()
const { datasetId } = useParams()
const { push } = useRouter()
@@ -70,6 +74,7 @@ const Popup = () => {
const checked = await handleCheckBeforePublish()
if (checked) {
if (!publishedAt && !confirmVisible) {
+ onRequestClose?.()
showConfirm()
return
}
@@ -114,7 +119,7 @@ const Popup = () => {
if (confirmVisible)
hideConfirm()
}
- }, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm])
+ }, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm, onRequestClose])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
if (published)
@@ -155,13 +160,14 @@ const Popup = () => {
hidePublishingAsCustomizedPipeline()
hidePublishAsKnowledgePipelineModal()
}
- }, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal])
+ }, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
+ onRequestClose?.()
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
setShowPricingModal()
else
setShowPublishAsKnowledgePipelineModal()
- }, [isAllowPublishAsCustomKnowledgePipelineTemplate, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
+ }, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
return (
diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx
index 2cb0d3e98f..8dc1e81379 100644
--- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx
@@ -86,4 +86,53 @@ describe('NodeSelector', () => {
expect(reopenedInput.value).toBe('')
expect(screen.getByText('End')).toBeInTheDocument()
})
+
+ it('does not open or emit open changes when disabled', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+
+ renderWorkflowComponent(
+
(
+
+ {open ? 'selector-open' : 'selector-closed'}
+
+ )}
+ />,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'selector-closed' }))
+
+ expect(onOpenChange).not.toHaveBeenCalled()
+ expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument()
+ })
+
+ it('preserves the child trigger click handler when rendered as child', async () => {
+ const user = userEvent.setup()
+ const onTriggerClick = vi.fn()
+
+ renderWorkflowComponent(
+ (
+
+ open-selector
+
+ )}
+ />,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'open-selector' }))
+
+ expect(onTriggerClick).toHaveBeenCalledTimes(1)
+ expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
+ })
})
diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx
index 96f5a98aed..b583170ae7 100644
--- a/web/app/components/workflow/block-selector/main.tsx
+++ b/web/app/components/workflow/block-selector/main.tsx
@@ -5,6 +5,7 @@ import type {
import type {
FC,
MouseEventHandler,
+ MouseEvent as ReactMouseEvent,
} from 'react'
import type {
CommonNodeType,
@@ -12,6 +13,12 @@ import type {
OnSelectBlock,
ToolWithProvider,
} from '../types'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
+import * as React from 'react'
import {
memo,
useCallback,
@@ -23,11 +30,6 @@ import {
Plus02,
} 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'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, isTriggerNode } from '../types'
@@ -121,6 +123,9 @@ const NodeSelector: FC = ({
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
+ if (disabled)
+ return
+
setLocalOpen(newOpen)
if (!newOpen)
@@ -128,13 +133,10 @@ const NodeSelector: FC = ({
if (onOpenChange)
onOpenChange(newOpen)
- }, [onOpenChange])
- const handleTrigger = useCallback>((e) => {
- if (disabled)
- return
+ }, [disabled, onOpenChange])
+ const handleTrigger = useCallback>((e) => {
e.stopPropagation()
- handleOpenChange(!open)
- }, [handleOpenChange, open, disabled])
+ }, [])
const handleSelect = useCallback((type, pluginDefaultValue) => {
handleOpenChange(false)
@@ -174,36 +176,61 @@ const NodeSelector: FC = ({
return ''
}, [activeTab, t])
+ const defaultTriggerElement = (
+
+ )
+ const triggerElement = trigger ? trigger(open) : defaultTriggerElement
+ const triggerElementProps = React.isValidElement(triggerElement)
+ ? (triggerElement.props as {
+ onClick?: MouseEventHandler
+ })
+ : null
+ const resolvedTriggerElement = asChild && React.isValidElement(triggerElement)
+ ? React.cloneElement(
+ triggerElement as React.ReactElement<{
+ onClick?: MouseEventHandler
+ }>,
+ {
+ onClick: (e: ReactMouseEvent) => {
+ handleTrigger(e)
+ if (typeof triggerElementProps?.onClick === 'function')
+ triggerElementProps.onClick(e)
+ },
+ },
+ )
+ : (
+
+ {triggerElement}
+
+ )
+ const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
+ const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0)
+ const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0)
+ const nativeButton = asChild
+ && React.isValidElement(triggerElement)
+ && (typeof triggerElement.type !== 'string' || triggerElement.type === 'button')
+
return (
-
-
+
- {
- trigger
- ? trigger(open)
- : (
-
- )
- }
-
-
= ({
forceShowStartContent={forceShowStartContent}
/>
-
-
+
+
)
}
diff --git a/web/app/components/workflow/header/__tests__/view-history.spec.tsx b/web/app/components/workflow/header/__tests__/view-history.spec.tsx
index 4481c72cf7..93e0b56125 100644
--- a/web/app/components/workflow/header/__tests__/view-history.spec.tsx
+++ b/web/app/components/workflow/header/__tests__/view-history.spec.tsx
@@ -10,14 +10,13 @@ const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
const mockCloseAllInputFieldPanels = vi.fn()
const mockHandleNodesCancelSelected = vi.fn()
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
+const mockHandleBackupDraft = vi.fn()
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
let mockIsChatMode = false
-vi.mock('../../hooks', async () => {
- const actual = await vi.importActual('../../hooks')
+vi.mock('../../hooks', () => {
return {
- ...actual,
useIsChatMode: () => mockIsChatMode,
useNodesInteractions: () => ({
handleNodesCancelSelected: mockHandleNodesCancelSelected,
@@ -25,6 +24,9 @@ vi.mock('../../hooks', async () => {
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
+ useWorkflowRun: () => ({
+ handleBackupDraft: mockHandleBackupDraft,
+ }),
}
})
@@ -48,38 +50,46 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => ,
}))
-vi.mock('@/app/components/base/tooltip', () => ({
- default: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+vi.mock('@langgenius/dify-ui/toast', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
}))
-vi.mock('@/app/components/base/portal-to-follow-elem', () => {
- const PortalContext = React.createContext({ open: false })
+vi.mock('@langgenius/dify-ui/button', () => ({
+ Button: ({
+ children,
+ ...props
+ }: React.ButtonHTMLAttributes) => (
+
+ {children}
+
+ ),
+}))
- return {
- PortalToFollowElem: ({
- children,
- open,
- }: {
- children?: React.ReactNode
- open: boolean
- }) => {children},
- PortalToFollowElemTrigger: ({
- children,
- onClick,
- }: {
- children?: React.ReactNode
- onClick?: () => void
- }) => {children}
,
- PortalToFollowElemContent: ({
- children,
- }: {
- children?: React.ReactNode
- }) => {
- const { open } = React.useContext(PortalContext)
- return open ? {children}
: null
- },
- }
-})
+vi.mock('@langgenius/dify-ui/tooltip', () => ({
+ Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({
+ children,
+ render,
+ }: {
+ children?: React.ReactNode
+ render?: React.ReactElement
+ }) => {
+ if (render && React.isValidElement(render)) {
+ const renderElement = render as React.ReactElement<{ children?: React.ReactNode }>
+ return React.cloneElement(renderElement, renderElement.props, children)
+ }
+
+ return <>{children}>
+ },
+ TooltipContent: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+}))
+
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('../../utils', async () => {
const actual = await vi.importActual('../../utils')
@@ -130,7 +140,7 @@ describe('ViewHistory', () => {
})
expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
- expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
@@ -165,7 +175,6 @@ describe('ViewHistory', () => {
})
it('renders workflow run history items and updates the workflow store when one is selected', () => {
- const handleBackupDraft = vi.fn()
const pausedRun = createHistoryItem({
id: 'run-paused',
status: WorkflowRunningStatus.Paused,
@@ -199,9 +208,6 @@ describe('ViewHistory', () => {
showEnvPanel: true,
controlMode: ControlMode.Pointer,
},
- hooksStoreProps: {
- handleBackupDraft,
- },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
@@ -217,7 +223,7 @@ describe('ViewHistory', () => {
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().controlMode).toBe(ControlMode.Hand)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
- expect(handleBackupDraft).toHaveBeenCalledTimes(1)
+ expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1)
expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
@@ -271,6 +277,6 @@ describe('ViewHistory', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
- expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})
diff --git a/web/app/components/workflow/header/view-history.tsx b/web/app/components/workflow/header/view-history.tsx
index 3f98f6cb6c..bde9f370c9 100644
--- a/web/app/components/workflow/header/view-history.tsx
+++ b/web/app/components/workflow/header/view-history.tsx
@@ -1,16 +1,20 @@
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 {
memo,
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 Tooltip from '@/app/components/base/tooltip'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
import {
useStore,
@@ -61,52 +65,60 @@ const ViewHistory = ({
return (
(
-
- setOpen(v => !v)}>
- {
- withText && (
-
+
+ {t('common.showRunHistory', { ns: 'workflow' })}
+
)}
- >
-
- {t('common.showRunHistory', { ns: 'workflow' })}
-
+ />
)
- }
- {
- !withText && (
-
- {
- onClearLogAndMessageModal?.()
- }}
+ : (
+
+ }
>
-
-
+ {
+ onClearLogAndMessageModal?.()
+ }}
+ >
+
+
+ )}
+ />
+
+
+ {t('common.viewRunHistory', { ns: 'workflow' })}
+
- )
- }
-
-
+ )}
+
-
-
+
+
)
)
}
diff --git a/web/app/components/workflow/header/view-workflow-history.tsx b/web/app/components/workflow/header/view-workflow-history.tsx
index 9f70187941..036f27d38d 100644
--- a/web/app/components/workflow/header/view-workflow-history.tsx
+++ b/web/app/components/workflow/header/view-workflow-history.tsx
@@ -1,5 +1,10 @@
import type { WorkflowHistoryState } from '../workflow-history-store'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import {
RiCloseLine,
RiHistoryLine,
@@ -13,11 +18,6 @@ import {
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import Divider from '../../base/divider'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import {
@@ -91,12 +91,20 @@ const ViewWorkflowHistory = () => {
}, [t])
const calculateChangeList: ChangeHistoryList = useMemo(() => {
- const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial
, index: number) => {
- const nodes = (state.nodes || store.getState().nodes) || []
- const nodeId = state?.workflowHistoryEventMeta?.nodeId
+ const filterList = (
+ list: Array | undefined>,
+ startIndex = 0,
+ reverse = false,
+ ) => list.flatMap((state, index) => {
+ if (!state)
+ return []
+
+ const nodes = state.nodes || store.getState().nodes || []
+ const nodeId = state.workflowHistoryEventMeta?.nodeId
const targetTitle = nodes.find(n => n.id === nodeId)?.data?.title ?? ''
- return {
- label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent),
+
+ return [{
+ label: state.workflowHistoryEvent ? getHistoryLabel(state.workflowHistoryEvent) : '',
index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
state: {
...state,
@@ -107,8 +115,8 @@ const ViewWorkflowHistory = () => {
}
: undefined,
},
- }
- }).filter(Boolean)
+ }]
+ })
const historyData = {
pastStates: filterList(pastStates, pastStates.length).reverse(),
@@ -132,35 +140,42 @@ const ViewWorkflowHistory = () => {
return (
(
- {
+ if (nodesReadOnly)
+ return
+ setOpen(nextOpen)
+ }}
>
- !nodesReadOnly && setOpen(v => !v)}>
-
- {
- if (nodesReadOnly)
- return
- setCurrentLogItem()
- setShowMessageLogModal(false)
- }}
- >
-
-
-
-
-
+
+ {
+ if (nodesReadOnly)
+ return
+ setCurrentLogItem()
+ setShowMessageLogModal(false)
+ }}
+ >
+
+
+ )}
+ />
+
+
@@ -293,8 +308,8 @@ const ViewWorkflowHistory = () => {
{t('changeHistory.hintText', { ns: 'workflow' })}
-
-
+
+
)
)
}
diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx
index 056ebf4795..fe288899bd 100644
--- a/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx
+++ b/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx
@@ -21,42 +21,7 @@ vi.mock('@langgenius/dify-ui/button', () => ({
},
}))
-vi.mock('@/app/components/base/portal-to-follow-elem', () => {
- const OpenContext = React.createContext(false)
-
- return {
- PortalToFollowElem: ({
- open,
- children,
- }: {
- open: boolean
- children?: React.ReactNode
- }) => (
-
- {children}
-
- ),
- PortalToFollowElemTrigger: ({
- children,
- onClick,
- }: {
- children?: React.ReactNode
- onClick?: () => void
- }) => (
-
- {children}
-
- ),
- PortalToFollowElemContent: ({
- children,
- }: {
- children?: React.ReactNode
- }) => {
- const open = React.use(OpenContext)
- return open ?
{children}
: null
- },
- }
-})
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
describe('ButtonStyleDropdown', () => {
const onChange = vi.fn()
@@ -80,10 +45,10 @@ describe('ButtonStyleDropdown', () => {
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'ghost',
}))
- expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'false')
+ expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false')
- fireEvent.click(screen.getByTestId('portal-trigger'))
- expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'true')
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'true')
expect(screen.getByText('nodes.humanInput.userActions.chooseStyle'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement)
@@ -111,10 +76,10 @@ describe('ButtonStyleDropdown', () => {
variant: 'secondary',
}))
- fireEvent.click(screen.getByTestId('portal-trigger'))
+ fireEvent.click(screen.getByTestId('popover-trigger'))
- expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'false')
- expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false')
+ expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
diff --git a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx
index 44ddbbfa34..688f7a62f6 100644
--- a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx
+++ b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx
@@ -1,17 +1,17 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import {
RiFontSize,
} from '@remixicon/react'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { UserActionButtonType } from '../types'
const i18nPrefix = 'nodes.humanInput'
@@ -45,23 +45,29 @@ const ButtonStyleDropdown: FC
= ({
}, [data])
return (
- {
+ if (readonly)
+ return
+ setOpen(nextOpen)
}}
>
- !readonly && setOpen(v => !v)}>
-
-
-
-
-
-
-
+
+
+
+
+
+ )}
+ />
+
{t(`${i18nPrefix}.userActions.chooseStyle`, { ns: 'workflow' })}
@@ -103,8 +109,8 @@ const ButtonStyleDropdown: FC
= ({
-
-
+
+
)
}
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx
index dd530ae679..da17b4b2b3 100644
--- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx
+++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx
@@ -1,16 +1,16 @@
import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import { Button } from '@langgenius/dify-ui/button'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { RiFilter3Line } from '@remixicon/react'
import {
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import MetadataPanel from './metadata-panel'
const MetadataTrigger = ({
@@ -40,25 +40,29 @@ const MetadataTrigger = ({
}, [metadataFilteringConditions, metadataList, handleRemoveCondition, selectedDatasetsLoaded])
return (
-
- setOpen(!open)}>
-
-
- {t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })}
-
- {metadataFilteringConditions?.conditions.length || 0}
-
-
-
-
+
+
+ {t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })}
+
+ {metadataFilteringConditions?.conditions.length || 0}
+
+
+ )}
+ />
+
setOpen(false)}
@@ -66,8 +70,8 @@ const MetadataTrigger = ({
handleRemoveCondition={handleRemoveCondition}
{...restProps}
/>
-
-
+
+
)
}
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx
index da71682b35..953e8474e4 100644
--- a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx
+++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx
@@ -7,16 +7,16 @@ import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { RiEqualizer2Line } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ConfigRetrievalContent from '@/app/components/app/configuration/dataset-config/params-config/config-content'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { DATASET_DEFAULT } from '@/config'
import { RETRIEVE_TYPE } from '@/types/app'
@@ -114,32 +114,33 @@ const RetrievalConfig: FC = ({
}, [onMultipleRetrievalConfigChange, retrieval_mode, onRetrievalModeChange])
return (
- {
+ if (readonly)
+ return
+ handleOpen(nextOpen)
}}
>
- {
- if (readonly)
- return
- handleOpen(!rerankModalOpen)
- }}
+
+
+ {t('retrievalSettings', { ns: 'dataset' })}
+
+ )}
+ />
+
-
-
- {t('retrievalSettings', { ns: 'dataset' })}
-
-
-
= ({
onSingleRetrievalModelParamsChange={onSingleRetrievalModelParamsChange}
/>
-
-
+
+
)
}
export default React.memo(RetrievalConfig)
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx
index c949b89adb..bf15761d9d 100644
--- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx
+++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx
@@ -3,14 +3,14 @@ import type { SchemaRoot } from '../../../types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CompletionParams, Model } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
-import { useCallback, useEffect, useState } from 'react'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
+import { useCallback, useState } from 'react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import useTheme from '@/hooks/use-theme'
@@ -27,61 +27,68 @@ type JsonSchemaGeneratorProps = {
crossAxisOffset?: number
}
-enum GeneratorView {
- promptEditor = 'promptEditor',
- result = 'result',
+const GENERATOR_VIEWS = {
+ promptEditor: 'promptEditor',
+ result: 'result',
+} as const
+
+type GeneratorView = typeof GENERATOR_VIEWS[keyof typeof GENERATOR_VIEWS]
+
+const createEmptyModel = (): Model => ({
+ name: '',
+ provider: '',
+ mode: ModelModeType.completion,
+ completion_params: {} as CompletionParams,
+})
+
+const getStoredModel = (): Model | null => {
+ if (typeof window === 'undefined')
+ return null
+
+ const savedModel = window.localStorage.getItem('auto-gen-model')
+
+ if (!savedModel)
+ return null
+
+ return JSON.parse(savedModel) as Model
}
const JsonSchemaGenerator: FC = ({
onApply,
crossAxisOffset,
}) => {
- const localModel = localStorage.getItem('auto-gen-model')
- ? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
- : null
const [open, setOpen] = useState(false)
- const [view, setView] = useState(GeneratorView.promptEditor)
- const [model, setModel] = useState(localModel || {
- name: '',
- provider: '',
- mode: ModelModeType.completion,
- completion_params: {} as CompletionParams,
- })
+ const [view, setView] = useState(GENERATOR_VIEWS.promptEditor)
+ const [model, setModel] = useState(() => getStoredModel())
const [instruction, setInstruction] = useState('')
const [schema, setSchema] = useState(null)
const { theme } = useTheme()
const {
defaultModel,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
+ const resolvedModel = React.useMemo(() => {
+ if (model)
+ return model
+
+ if (!defaultModel)
+ return createEmptyModel()
+
+ return {
+ ...createEmptyModel(),
+ name: defaultModel.model,
+ provider: defaultModel.provider.provider,
+ }
+ }, [defaultModel, model])
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const { emit } = useMittContext()
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
- useEffect(() => {
- if (defaultModel) {
- const localModel = localStorage.getItem('auto-gen-model')
- ? JSON.parse(localStorage.getItem('auto-gen-model') || '')
- : null
- if (localModel) {
- setModel(localModel)
- }
- else {
- setModel(prev => ({
- ...prev,
- name: defaultModel.model,
- provider: defaultModel.provider.provider,
- }))
- }
- }
- }, [defaultModel])
-
const handleTrigger = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
if (advancedEditing || isAddingNewField)
emit('quitEditing', {})
- setOpen(!open)
- }, [open, advancedEditing, isAddingNewField, emit])
+ }, [advancedEditing, isAddingNewField, emit])
const onClose = useCallback(() => {
setOpen(false)
@@ -89,39 +96,39 @@ const JsonSchemaGenerator: FC = ({
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
const newModel = {
- ...model,
+ ...resolvedModel,
provider: newValue.provider,
name: newValue.modelId,
mode: newValue.mode as ModelModeType,
}
setModel(newModel)
- localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
- }, [model, setModel])
+ window.localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
+ }, [resolvedModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
const newModel = {
- ...model,
+ ...resolvedModel,
completion_params: newParams as CompletionParams,
}
setModel(newModel)
- localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
- }, [model, setModel])
+ window.localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
+ }, [resolvedModel])
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()
const generateSchema = useCallback(async () => {
- const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! })
+ const { output, error } = await generateStructuredOutputRules({ instruction, model_config: resolvedModel })
if (error) {
toast.error(error)
setSchema(null)
- setView(GeneratorView.promptEditor)
+ setView(GENERATOR_VIEWS.promptEditor)
return
}
return output
- }, [instruction, model, generateStructuredOutputRules])
+ }, [generateStructuredOutputRules, instruction, resolvedModel])
const handleGenerate = useCallback(async () => {
- setView(GeneratorView.result)
+ setView(GENERATOR_VIEWS.result)
const output = await generateSchema()
if (output === undefined)
return
@@ -129,7 +136,7 @@ const JsonSchemaGenerator: FC = ({
}, [generateSchema])
const goBackToPromptEditor = () => {
- setView(GeneratorView.promptEditor)
+ setView(GENERATOR_VIEWS.promptEditor)
}
const handleRegenerate = useCallback(async () => {
@@ -145,31 +152,34 @@ const JsonSchemaGenerator: FC = ({
}
return (
-
-
-
-
-
-
-
- {view === GeneratorView.promptEditor && (
+
+
+
+ )}
+ />
+
+ {view === GENERATOR_VIEWS.promptEditor && (
= ({
onModelChange={handleModelChange}
/>
)}
- {view === GeneratorView.result && (
+ {view === GENERATOR_VIEWS.result && (
= ({
onClose={onClose}
/>
)}
-
-
+
+
)
}
diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx
index 9f36b4a7ac..b70559aa6b 100644
--- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx
+++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx
@@ -1,32 +1,32 @@
-import { fireEvent, render, waitFor } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NoteTheme } from '../../../types'
-import ColorPicker, { COLOR_LIST } from '../color-picker'
+import ColorPicker from '../color-picker'
+
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
describe('NoteEditor ColorPicker', () => {
it('should open the palette and apply the selected theme', async () => {
const onThemeChange = vi.fn()
- const { container } = render(
+ render(
,
)
- const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
+ fireEvent.click(screen.getByTestId('popover-trigger'))
- fireEvent.click(trigger)
-
- const popup = document.body.querySelector('[role="tooltip"]')
+ const popup = screen.getByTestId('popover-content')
expect(popup).toBeInTheDocument()
- const options = popup?.querySelectorAll('.group.relative')
+ const options = popup.querySelectorAll('.group.relative')
- expect(options).toHaveLength(COLOR_LIST.length)
+ expect(options).toHaveLength(6)
- fireEvent.click(options?.[COLOR_LIST.length - 1] as Element)
+ fireEvent.click(options[5] as Element)
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
await waitFor(() => {
- expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})
})
diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx
index e94b66e695..bce7bb326d 100644
--- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx
+++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx
@@ -1,6 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import FontSizeSelector from '../font-size-selector'
+vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
+
const {
mockHandleFontSize,
mockHandleOpenFontSizeSelector,
@@ -52,4 +54,12 @@ describe('NoteEditor FontSizeSelector', () => {
expect(mockHandleFontSize).toHaveBeenCalledWith('16px')
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false)
})
+
+ it('should fall back to the small label when current font size is unknown', () => {
+ mockFontSize = '18px'
+
+ render()
+
+ expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument()
+ })
})
diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx
index cee2b7fd40..cb50f641b4 100644
--- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx
@@ -76,11 +76,14 @@ describe('NoteEditor Toolbar', () => {
expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument()
- const triggers = container.querySelectorAll('[data-state="closed"]')
+ const buttons = container.querySelectorAll('button[type="button"]')
+ fireEvent.click(buttons[0] as HTMLElement)
- fireEvent.click(triggers[0] as HTMLElement)
+ await waitFor(() => {
+ expect(document.body.querySelectorAll('.group.relative').length).toBeGreaterThan(0)
+ })
- const colorOptions = document.body.querySelectorAll('[role="tooltip"] .group.relative')
+ const colorOptions = document.body.querySelectorAll('.group.relative')
fireEvent.click(colorOptions[colorOptions.length - 1] as Element)
diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx
index e8c5055962..2b12a947ea 100644
--- a/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx
+++ b/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx
@@ -1,17 +1,17 @@
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import {
memo,
useState,
} from 'react'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { THEME_MAP } from '../../constants'
import { NoteTheme } from '../../types'
-export const COLOR_LIST = [
+const COLOR_LIST = [
{
key: NoteTheme.blue,
inner: THEME_MAP[NoteTheme.blue]!.title,
@@ -55,28 +55,35 @@ const ColorPicker = ({
const [open, setOpen] = useState(false)
return (
-
- setOpen(!open)}>
-
-
-
+
+
+
+ )}
+ />
+
{
COLOR_LIST.map(color => (
@@ -107,8 +114,8 @@ const ColorPicker = ({
))
}
-
-
+
+
)
}
diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx
index a217d7de72..13da51deb7 100644
--- a/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx
+++ b/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx
@@ -1,13 +1,13 @@
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { RiFontSize } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { useFontSize } from './hooks'
const FontSizeSelector = () => {
@@ -34,23 +34,30 @@ const FontSizeSelector = () => {
} = useFontSize()
return (
-
- handleOpenFontSizeSelector(!fontSizeSelectorShow)}>
-
+
+ {FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('nodes.note.editor.small', { ns: 'workflow' })}
+
)}
- >
-
- {FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('nodes.note.editor.small', { ns: 'workflow' })}
-
-
-
+ />
+
{
FONT_SIZE_LIST.map(font => (
@@ -77,8 +84,8 @@ const FontSizeSelector = () => {
))
}
-
-
+
+
)
}
diff --git a/web/app/components/workflow/panel/version-history-panel/filter/index.tsx b/web/app/components/workflow/panel/version-history-panel/filter/index.tsx
index f48804d921..bb94e3727a 100644
--- a/web/app/components/workflow/panel/version-history-panel/filter/index.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/filter/index.tsx
@@ -1,14 +1,14 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { RiFilter3Line } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import Divider from '@/app/components/base/divider'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { WorkflowVersionFilterOptions } from '../../../types'
import FilterItem from './filter-item'
import FilterSwitch from './filter-switch'
@@ -37,26 +37,28 @@ const Filter: FC = ({
const isFiltering = filterValue !== WorkflowVersionFilterOptions.all || isOnlyShowNamedVersions
return (
-
- setOpen(v => !v)}>
-
-
-
-
-
+
+
+
+ )}
+ />
+
{
@@ -75,8 +77,8 @@ const Filter: FC
= ({
-
-
+
+
)
}
diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md
index 0fa584ecfc..73c0f02d9d 100644
--- a/web/docs/overlay-migration.md
+++ b/web/docs/overlay-migration.md
@@ -55,7 +55,7 @@ pnpm -C web lint:fix --prune-suppressions
## z-index strategy
-All new overlay primitives in `@langgenius/dify-ui` share a single z-index value:
+All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index value:
**`z-1002`**, except Toast which stays one layer above at **`z-1003`**.
### Why z-[1002]?
@@ -94,7 +94,7 @@ back to `z-9999`.
Once all legacy overlays are removed:
-1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui` primitives.
+1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui/*` primitives.
1. Reduce Toast from `z-1003` to `z-51`.
1. Remove this section from the migration guide.
diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs
index 5a2330c00e..1c09cbcb23 100644
--- a/web/eslint.constants.mjs
+++ b/web/eslint.constants.mjs
@@ -64,20 +64,13 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx',
'app/components/base/chat/chat-with-history/header/operation.tsx',
- 'app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx',
'app/components/base/chat/chat-with-history/sidebar/operation.tsx',
'app/components/base/chat/chat/citation/popup.tsx',
'app/components/base/chat/chat/citation/progress-tooltip.tsx',
'app/components/base/chat/chat/citation/tooltip.tsx',
- 'app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx',
'app/components/base/chip/index.tsx',
'app/components/base/date-and-time-picker/date-picker/index.tsx',
'app/components/base/date-and-time-picker/time-picker/index.tsx',
- 'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx',
- 'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx',
- 'app/components/base/file-uploader/file-from-link-or-local/index.tsx',
- 'app/components/base/image-uploader/chat-image-uploader.tsx',
- 'app/components/base/image-uploader/text-generation-image-uploader.tsx',
'app/components/base/modal/modal.tsx',
'app/components/base/prompt-editor/plugins/context-block/component.tsx',
'app/components/base/prompt-editor/plugins/history-block/component.tsx',