diff --git a/web/AGENTS.md b/web/AGENTS.md index 4a705bf4b8..4ec7515155 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -5,7 +5,7 @@ ## Overlay Components (Mandatory) - `./docs/overlay-migration.md` is the source of truth for overlay-related work. -- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`. +- In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`. - Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding). ## Query & Mutation (Mandatory) diff --git a/web/__mocks__/base-ui-popover.tsx b/web/__mocks__/base-ui-popover.tsx new file mode 100644 index 0000000000..81d10e947e --- /dev/null +++ b/web/__mocks__/base-ui-popover.tsx @@ -0,0 +1,119 @@ +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 + 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) => { + return ( + {}), + }} + > +
+ {children} +
+
+ ) +} + +export const PopoverTrigger = ({ + children, + render, + onClick, + ...props +}: PopoverTriggerProps) => { + const { open, onOpenChange } = React.useContext(PopoverContext) + const node = render ?? children + + if (React.isValidElement(node)) { + const triggerElement = node as React.ReactElement & { 'data-testid'?: string }> + const childProps = triggerElement.props ?? {} + + return React.cloneElement(triggerElement, { + ...props, + ...childProps, + 'data-testid': childProps['data-testid'] ?? 'popover-trigger', + 'onClick': (event: React.MouseEvent) => { + childProps.onClick?.(event) + onClick?.(event) + onOpenChange(!open) + }, + }) + } + + return ( +
{ + onClick?.(event) + 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/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/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 + }) => ( + , ) - const portal = screen.getAllByTestId('voice-settings-portal') - .find(item => item.hasAttribute('data-main-axis')) - - expect(portal).toBeDefined() - expect(portal)!.toHaveAttribute('data-placement', 'top') - expect(portal)!.toHaveAttribute('data-main-axis', '4') + const content = screen.getByTestId('popover-content') + expect(content).toHaveAttribute('data-placement', 'top') + expect(content).toHaveAttribute('data-side-offset', '4') }) }) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx index 3717c76352..7375733299 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.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 ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' type VoiceSettingsProps = { open: boolean - onOpen: (state: any) => void + onOpen: (state: boolean) => void onChange?: OnFeaturesChange disabled?: boolean children?: React.ReactNode @@ -25,23 +25,32 @@ const VoiceSettings = ({ placementLeft = true, }: VoiceSettingsProps) => { return ( - { + if (disabled) + return + onOpen(nextOpen) }} > - !disabled && onOpen((open: boolean) => !open)}> - {children} - - + + {children} + + )} + /> +
onOpen(false)} onChange={onChange} />
-
-
+ + ) } export default memo(VoiceSettings) diff --git a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx index 912c2d3c48..27c6c36f6c 100644 --- a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx +++ b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx @@ -1,17 +1,17 @@ import type { FileUpload } from '@/app/components/base/features/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiUploadCloud2Line } from '@remixicon/react' import { memo, useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { FILE_URL_REGEX } from '../constants' import FileInput from '../file-input' import { useFile } from '../hooks' @@ -54,16 +54,16 @@ const FileFromLinkOrLocal = ({ } return ( - - setOpen(v => !v)} asChild> - {trigger(open)} - - + +
{ showFromLink && ( @@ -126,8 +126,8 @@ const FileFromLinkOrLocal = ({ ) }
-
-
+ + ) } diff --git a/web/app/components/base/image-uploader/chat-image-uploader.tsx b/web/app/components/base/image-uploader/chat-image-uploader.tsx index b2f6a106c6..5093b33bd0 100644 --- a/web/app/components/base/image-uploader/chat-image-uploader.tsx +++ b/web/app/components/base/image-uploader/chat-image-uploader.tsx @@ -1,13 +1,13 @@ import type { FC } from 'react' import type { ImageFile, VisionSettings } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' +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 { TransferMethod } from '@/types/app' import ImageLinkInput from './image-link-input' import Uploader from './uploader' @@ -63,29 +63,31 @@ const UploaderButton: FC = ({ const closePopover = () => setOpen(false) - const handleToggle = () => { - if (disabled) - return - - setOpen(v => !v) - } - return ( - { + if (disabled) + return + setOpen(nextOpen) + }} > - - - - + + + + )} + /> +
{!!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 48099c7020..b6fedd22c4 100644 --- a/web/app/components/base/portal-to-follow-elem/index.tsx +++ b/web/app/components/base/portal-to-follow-elem/index.tsx @@ -1,6 +1,6 @@ 'use client' /** - * @deprecated Use semantic overlay primitives from `@/app/components/base/ui/` instead. + * @deprecated Use semantic overlay primitives from `@langgenius/dify-ui/` instead. * This component will be removed after migration is complete. * See: https://github.com/langgenius/dify/issues/32767 * @@ -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/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/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/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 }) => ( + + ), +})) + /** * 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' })} + + )} + /> +
{ !!canOAuth && ( @@ -122,8 +120,8 @@ const Configure = ({ ) }
-
-
+ + ) } 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 bb5d8e734c..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 - }) => , - 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-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__/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 - }) => , - 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/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 96f5a98aed..1a25726a76 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,58 @@ 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) + 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) => ( + + ), +})) - 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' })} - + /> ) - } - { - !withText && ( - - + { + 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 - }) => ( - - ), - 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} +
+ + )} + /> + 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' })} + + )} + /> + - - -
= ({ 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/color-picker.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx index e8c5055962..5669c92f50 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..71ffe5351e 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 0016f34e12..04a23688ef 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -31,7 +31,7 @@ This document tracks the migration away from legacy overlay APIs. ## Migration phases 1. Business/UI features outside `app/components/base/**` - - Migrate old calls to semantic primitives from `@/app/components/base/ui/**`. + - Migrate old calls to semantic primitives from `@langgenius/dify-ui/**`. - Keep deprecated imports out of newly touched files. 1. Legacy base components in allowlist - Migrate allowlisted base callers gradually. @@ -53,7 +53,7 @@ pnpm -C web lint:fix --prune-suppressions ## z-index strategy -All new overlay primitives in `base/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]? @@ -61,13 +61,13 @@ All new overlay primitives in `base/ui/` share a single z-index value: During the migration period, legacy and new overlays coexist. Legacy overlays portal to `document.body` with explicit z-index values: -| Layer | z-index | Components | -| --------------------------------- | -------------- | -------------------------------------------- | -| Legacy Drawer | `z-30` | `base/drawer` | -| Legacy Modal | `z-60` | `base/modal` (default) | -| Legacy PortalToFollowElem callers | up to `z-1001` | various business components | -| **New UI primitives** | **`z-1002`** | `base/ui/*` (Popover, Dialog, Tooltip, etc.) | -| Toast | `z-1003` | `base/ui/toast` | +| Layer | z-index | Components | +| --------------------------------- | -------------- | -------------------------------------------------------- | +| Legacy Drawer | `z-30` | `base/drawer` | +| Legacy Modal | `z-60` | `base/modal` (default) | +| Legacy PortalToFollowElem callers | up to `z-1001` | various business components | +| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Tooltip, etc.) | +| Toast | `z-1003` | `@langgenius/dify-ui/toast` | `z-1002` sits above all common legacy overlays, so new primitives always render on top without needing per-call-site z-index hacks. Among themselves, @@ -81,7 +81,7 @@ back to `z-9999`. ### Rules - **Do NOT add z-index overrides** (e.g. `className="z-1003"`) on new - `base/ui/*` components. If you find yourself needing one, the parent legacy + `@langgenius/dify-ui/*` components. If you find yourself needing one, the parent legacy overlay should be migrated instead. - When migrating a legacy overlay that has a high z-index, remove the z-index entirely — the new primitive's default `z-1002` handles it. @@ -92,12 +92,12 @@ back to `z-9999`. Once all legacy overlays are removed: -1. Reduce `z-1002` back to `z-50` across all `base/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. -## React Refresh policy for base UI primitives +## React Refresh policy for dify-ui primitives - We keep primitive aliases (for example `DropdownMenu = Menu.Root`) in the same module. -- For `app/components/base/ui/**/*.tsx`, `react-refresh/only-export-components` is currently set to `off` in ESLint to avoid false positives and IDE noise during migration. +- For `../packages/dify-ui/src/**/*.tsx`, `react-refresh/only-export-components` is currently set to `off` in ESLint to avoid false positives and IDE noise during migration. - Do not use file-level `eslint-disable` comments for this policy; keep control in the scoped ESLint override. diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index eab02ec664..2cff98e1de 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -161,8 +161,8 @@ export default antfu( }, }, { - name: 'dify/base-ui-primitives', - files: ['app/components/base/ui/**/*.tsx'], + name: 'dify/dify-ui-primitives', + files: ['../packages/dify-ui/src/**/*.tsx'], rules: { 'react-refresh/only-export-components': 'off', }, diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index 46690035fb..5a036a8b88 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -26,7 +26,7 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ '**/portal-to-follow-elem', '**/portal-to-follow-elem/index', ], - message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.', + message: 'Deprecated: use semantic overlay primitives from @langgenius/dify-ui instead. See issue #32767.', }, { group: [ @@ -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',