From a13996dba15330eec6a1479a9c4c63c4fabcae38 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Thu, 16 Apr 2026 15:30:27 +0800 Subject: [PATCH] refactor(web): replace PortalToFollowElem with DropdownMenu in various components - Updated PublishWithMultipleModel, AppSidebarDropdown, DatasetSidebarDropdown, and others to use DropdownMenu for dropdown functionality. - Adjusted related tests to reflect the new DropdownMenu structure. - Enhanced the user interface by improving dropdown interactions and accessibility. --- .../__tests__/app-sidebar-dropdown.spec.tsx | 51 ++- .../dataset-sidebar-dropdown.spec.tsx | 49 ++- .../__tests__/app-operations.spec.tsx | 74 ++++- .../app-sidebar/app-info/app-operations.tsx | 80 ++--- .../app-sidebar/app-sidebar-dropdown.tsx | 68 ++-- .../app-sidebar/dataset-sidebar-dropdown.tsx | 72 ++-- .../publish-with-multiple-model.spec.tsx | 57 +++- .../publish-with-multiple-model.tsx | 103 +++--- .../item-operation/__tests__/index.spec.tsx | 112 ++++++- .../explore/item-operation/index.tsx | 101 +++--- .../install-plugin-dropdown.spec.tsx | 107 +++++- .../plugin-page/install-plugin-dropdown.tsx | 118 +++---- .../__tests__/operation-dropdown.spec.tsx | 192 ++++++----- .../tools/mcp/detail/operation-dropdown.tsx | 92 ++---- .../__tests__/test-run-menu-helpers.spec.tsx | 29 +- .../header/__tests__/test-run-menu.spec.tsx | 77 +++-- .../workflow/header/test-run-menu-helpers.tsx | 8 +- .../workflow/header/test-run-menu.tsx | 37 ++- .../__tests__/operation-selector.spec.tsx | 68 ++++ .../components/operation-selector.tsx | 107 +++--- .../operator/__tests__/more-actions.spec.tsx | 309 ++++++++++++++++++ .../workflow/operator/more-actions.tsx | 143 ++++---- .../context-menu/__tests__/menu-item.spec.tsx | 21 +- .../context-menu/index.tsx | 95 +++--- .../context-menu/menu-item.tsx | 15 +- .../__tests__/agent-log-nav-more.spec.tsx | 46 +++ .../run/agent-log/agent-log-nav-more.tsx | 74 ++--- 27 files changed, 1545 insertions(+), 760 deletions(-) create mode 100644 web/app/components/workflow/operator/__tests__/more-actions.spec.tsx diff --git a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx index 5018709da1..5e18bbc343 100644 --- a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx @@ -19,17 +19,40 @@ vi.mock('@/context/app-context', () => ({ }), })) -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('@/app/components/base/ui/dropdown-menu', () => { + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + } +}) vi.mock('../../base/app-icon', () => ({ default: ({ size, icon }: { size: string, icon: string }) => ( @@ -128,11 +151,11 @@ describe('AppSidebarDropdown', () => { const user = userEvent.setup() render() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('dropdown-trigger') await user.click(trigger) - const portal = screen.getByTestId('portal-elem') - expect(portal).toHaveAttribute('data-open', 'true') + const dropdown = screen.getByTestId('dropdown-menu') + expect(dropdown).toHaveAttribute('data-open', 'true') }) it('should render divider between app info and navigation', () => { diff --git a/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx index 1f3a5f9ad8..5060987cda 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx @@ -21,17 +21,40 @@ vi.mock('@/hooks/use-knowledge', () => ({ }), })) -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('@/app/components/base/ui/dropdown-menu', () => { + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + } +}) vi.mock('../../base/app-icon', () => ({ default: ({ size, icon }: { size: string, icon: string }) => ( @@ -173,10 +196,10 @@ describe('DatasetSidebarDropdown', () => { const user = userEvent.setup() render() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('dropdown-trigger') await user.click(trigger) - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-open', 'true') }) it('should render divider', () => { diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx index 2c5b133a74..461cedc20c 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx @@ -30,17 +30,67 @@ vi.mock('../../../base/ui/button', () => ({ ), })) -vi.mock('../../../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, className }: { children: React.ReactNode, className?: string }) => ( -
{children}
- ), -})) +vi.mock('../../../base/ui/dropdown-menu', () => { + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + onClick, + render, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + render?: React.ReactElement + }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + const handleClick = (e: React.MouseEvent) => { + onClick?.(e) + setOpen(!isOpen) + } + + if (render) + return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record, children) + + return + }, + DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => { + const { isOpen } = useDropdownMenuContext() + if (!isOpen) + return null + + return
{children}
+ }, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuSeparator: () =>
, + } +}) const createOperation = (id: string, title: string, type?: 'divider'): Operation => ({ id, @@ -169,7 +219,7 @@ describe('AppOperations', () => { render() - const trigger = screen.queryByTestId('portal-trigger') + const trigger = screen.queryByTestId('dropdown-trigger') if (trigger) await user.click(trigger) diff --git a/web/app/components/app-sidebar/app-info/app-operations.tsx b/web/app/components/app-sidebar/app-info/app-operations.tsx index a3e67c8a59..095fb31206 100644 --- a/web/app/components/app-sidebar/app-info/app-operations.tsx +++ b/web/app/components/app-sidebar/app-info/app-operations.tsx @@ -1,9 +1,15 @@ import type { JSX } from 'react' import { RiMoreLine } from '@remixicon/react' -import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cloneElement, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/app/components/base/ui/button' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../../base/ui/dropdown-menu' export type Operation = { id: string @@ -33,9 +39,6 @@ const AppOperations = ({ const [moreOperations, setMoreOperations] = useState([]) const [showMore, setShowMore] = useState(false) const navRef = useRef(null) - const handleTriggerMore = useCallback(() => { - setShowMore(true) - }, [setShowMore]) const primaryOps = useMemo(() => { if (operations) @@ -169,43 +172,44 @@ const AppOperations = ({ ))} {shouldShowMoreButton && ( - - - - - -
- {moreOperations.map(item => item.type === 'divider' - ? ( -
- ) - : ( -
- {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} - {item.title} -
- ))} -
- - + + + + {moreOperations.map(item => item.type === 'divider' + ? ( + + ) + : ( + + {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + {item.title} + + ))} + + )}
diff --git a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx index 361fc94d69..617d14f426 100644 --- a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx @@ -5,14 +5,14 @@ import { RiMenuLine, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useAppContext } from '@/context/app-context' import AppIcon from '../base/app-icon' import Divider from '../base/divider' @@ -34,16 +34,7 @@ const AppSidebarDropdown = ({ navigation }: Props) => { const { isCurrentWorkspaceEditor } = useAppContext() const appDetail = useAppStore(state => state.appDetail) const [detailExpand, setDetailExpand] = useState(false) - - 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 (!appDetail) return null @@ -51,27 +42,28 @@ const AppSidebarDropdown = ({ navigation }: Props) => { return ( <>
- - -
- - -
-
- + + + + + +
{ })}
- - + +
diff --git a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx index de2563b377..3968a0df6f 100644 --- a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx @@ -5,13 +5,13 @@ import { RiMenuLine, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useKnowledge } from '@/hooks/use-knowledge' import { DOC_FORM_TEXT } from '@/models/datasets' @@ -41,15 +41,7 @@ const DatasetSidebarDropdown = ({ const { data: relatedApps } = useDatasetRelatedApps(dataset.id) - 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) const iconInfo = dataset.icon_info || { icon: '📙', @@ -66,32 +58,28 @@ const DatasetSidebarDropdown = ({ return ( <>
- - -
- - -
-
- + + + + + +
@@ -155,8 +143,8 @@ const DatasetSidebarDropdown = ({ documentCount={dataset.document_count} />
- - + +
) diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx index f476d8b188..465252c6c4 100644 --- a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -22,24 +22,57 @@ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({ default: ({ modelName }: { modelName: string }) => {modelName}, })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { const ReactModule = await vi.importActual('react') - const OpenContext = ReactModule.createContext(false) + const OpenContext = ReactModule.createContext<{ open: boolean, setOpen: (nextOpen: boolean) => void } | null>(null) + + const useOpenContext = () => { + const context = ReactModule.use(OpenContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } return { - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( - + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( +
{children}
), - PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => ( -
- {children} -
- ), - PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { - const open = ReactModule.useContext(OpenContext) - return open ?
{children}
: null + DropdownMenuTrigger: ({ + children, + render, + }: { + children: React.ReactNode + render?: React.ReactElement + }) => { + const { open, setOpen } = useOpenContext() + + if (render) { + return ReactModule.cloneElement(render, { + onClick: () => setOpen(!open), + } as Record, children) + } + + return + }, + DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => { + const context = useOpenContext() + return context.open ?
{children}
: null + }, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { setOpen } = useOpenContext() + return ( + + ) }, } }) diff --git a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx index b4ad0423b0..fbff371577 100644 --- a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx +++ b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx @@ -4,12 +4,13 @@ import type { Model, ModelItem } from '@/app/components/header/account-setting/m import { RiArrowDownSLine } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { useProviderContext } from '@/context/provider-context' import ModelIcon from '../../header/account-setting/model-provider-page/model-icon' @@ -50,61 +51,57 @@ const PublishWithMultipleModel: FC = ({ } }) - const handleToggle = () => { - if (validModelConfigs.length) - setOpen(v => !v) - } - - const handleSelect = (item: ModelAndParameter) => { - onSelect(item) - setOpen(false) - } - return ( - - - - - -
-
- {t('publishAs', { ns: 'appDebug' })} -
- { - validModelConfigs.map((item, index) => ( -
handleSelect(item)} - > - - # - {index + 1} - - -
- {item.modelItem.label[language]} -
-
- )) - } + + + +
+ {t('publishAs', { ns: 'appDebug' })}
- - + { + validModelConfigs.map((item, index) => ( + onSelect(item)} + > + + # + {index + 1} + + +
+ {item.modelItem.label[language]} +
+
+ )) + } +
+ ) } diff --git a/web/app/components/explore/item-operation/__tests__/index.spec.tsx b/web/app/components/explore/item-operation/__tests__/index.spec.tsx index f7f9b44a84..d54c644ab0 100644 --- a/web/app/components/explore/item-operation/__tests__/index.spec.tsx +++ b/web/app/components/explore/item-operation/__tests__/index.spec.tsx @@ -1,6 +1,81 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' import ItemOperation from '../index' +vi.mock('@/app/components/base/ui/dropdown-menu', () => { + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ + children, + popupProps, + }: { + children: React.ReactNode + popupProps?: React.HTMLAttributes + }) => { + const { isOpen } = useDropdownMenuContext() + if (!isOpen) + return null + + return
{children}
+ }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + describe('ItemOperation', () => { beforeEach(() => { vi.clearAllMocks() @@ -67,14 +142,27 @@ describe('ItemOperation', () => { expect(props.onDelete).toHaveBeenCalledTimes(1) }) + + it('should call onRenameConversation when clicking rename action', async () => { + const onRenameConversation = vi.fn() + renderComponent({ + isShowRenameConversation: true, + onRenameConversation, + }) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.rename')) + + expect(onRenameConversation).toHaveBeenCalledTimes(1) + }) }) describe('Edge Cases', () => { it('should close the menu when mouse leaves the panel and item is not hovering', async () => { renderComponent() fireEvent.click(screen.getByTestId('item-operation-trigger')) - const pinText = await screen.findByText('explore.sidebar.action.pin') - const menu = pinText.closest('div')?.parentElement as HTMLElement + await screen.findByText('explore.sidebar.action.pin') + const menu = screen.getByTestId('dropdown-content') fireEvent.mouseEnter(menu) fireEvent.mouseLeave(menu) @@ -83,5 +171,25 @@ describe('ItemOperation', () => { expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument() }) }) + + it('should stop propagation when clicking inside the dropdown content', async () => { + const onParentClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByTestId('dropdown-content')) + + expect(onParentClick).not.toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx index 94eed731fa..72bd00fa6e 100644 --- a/web/app/components/explore/item-operation/index.tsx +++ b/web/app/components/explore/item-operation/index.tsx @@ -7,10 +7,14 @@ import { } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' - -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { Pin02 } from '../../base/icons/src/vender/line/general' import s from './style.module.css' @@ -35,61 +39,74 @@ const ItemOperation: FC = ({ isShowDelete, onDelete, }) => { - const { t } = useTranslation() + const { t } = useTranslation('explore') + const { t: tCommon } = useTranslation('common') const [open, setOpen] = useState(false) - const ref = useRef(null) const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false) useEffect(() => { if (!isItemHovering && !isHovering) setOpen(false) }, [isItemHovering, isHovering]) return ( - - setOpen(v => !v)} + { + e.stopPropagation() + }} > -
-
-
- {tCommon('operation.more')} + + e.stopPropagation(), + }} > -
{ e.stopPropagation() + togglePin() }} > -
- - {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} -
- {isShowRenameConversation && ( -
- - {t('sidebar.action.rename', { ns: 'explore' })} -
- )} - {isShowDelete && ( -
- - {t('sidebar.action.delete', { ns: 'explore' })} -
- )} -
-
-
+ + {isPinned ? t('sidebar.action.unpin') : t('sidebar.action.pin')} + + {isShowRenameConversation && ( + { + e.stopPropagation() + onRenameConversation?.() + }} + > + + {t('sidebar.action.rename')} + + )} + {isShowDelete && ( + { + e.stopPropagation() + onDelete() + }} + > + + {t('sidebar.action.delete')} + + )} + + ) } export default React.memo(ItemOperation) diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx index 28aec206f1..1f249b16c6 100644 --- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -36,34 +36,85 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({ })) vi.mock('@/app/components/base/ui/button', () => ({ - Button: ({ children }: { children: React.ReactNode }) => {children}, + Button: ({ children, onClick, className, ...props }: React.ButtonHTMLAttributes) => ( + + ), })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { const React = await import('react') + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + return { - PortalToFollowElem: ({ + DropdownMenu: ({ open, + onOpenChange, children, }: { open: boolean + onOpenChange?: (open: boolean) => void children: React.ReactNode }) => { portalOpen = open - return
{children}
+ return ( + +
{children}
+
+ ) }, - PortalToFollowElemTrigger: ({ + DropdownMenuTrigger: ({ children, onClick, + render, }: { children: React.ReactNode - onClick: () => void - }) => , - PortalToFollowElemContent: ({ + onClick?: React.MouseEventHandler + render?: React.ReactElement + }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + const handleClick = (e: React.MouseEvent) => { + onClick?.(e) + setOpen(!isOpen) + } + + if (render) + return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record, children) + + return + }, + DropdownMenuContent: ({ children, }: { children: React.ReactNode }) => portalOpen ?
{children}
: null, + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, } }) @@ -131,13 +182,13 @@ describe('InstallPluginDropdown', () => { expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1) }) - it('opens the github installer when github is selected', () => { + it('opens the github installer when github is selected', async () => { render() fireEvent.click(screen.getByTestId('dropdown-trigger')) fireEvent.click(screen.getByText('plugin.source.github')) - expect(screen.getByTestId('github-modal')).toBeInTheDocument() + expect(await screen.findByTestId('github-modal')).toBeInTheDocument() }) it('opens the local package installer when a file is selected', () => { @@ -153,4 +204,40 @@ describe('InstallPluginDropdown', () => { expect(screen.getByTestId('local-modal')).toBeInTheDocument() expect(screen.getByText('plugin.difypkg')).toBeInTheDocument() }) + + it('triggers the hidden file input when local is selected from the menu', () => { + const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click') + + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.local')) + + expect(clickSpy).toHaveBeenCalledTimes(1) + clickSpy.mockRestore() + }) + + it('closes the github installer when the modal requests close', async () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.github')) + fireEvent.click(await screen.findByTestId('close-github-modal')) + + expect(screen.queryByTestId('github-modal')).not.toBeInTheDocument() + }) + + it('closes the local package installer when the modal requests close', () => { + const { container } = render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.change(container.querySelector('input[type="file"]')!, { + target: { + files: [new File(['content'], 'plugin.difypkg')], + }, + }) + fireEvent.click(screen.getByTestId('close-local-modal')) + + expect(screen.queryByTestId('local-modal')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index 4bfe495b93..9b98d16410 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -8,12 +8,13 @@ import { useTranslation } from 'react-i18next' import { FileZip } from '@/app/components/base/icons/src/vender/solid/files' import { Github } from '@/app/components/base/icons/src/vender/solid/general' import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github' import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package' import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' @@ -77,61 +78,66 @@ const InstallPluginDropdown = ({ } }, [plugin_installation_permission, enable_marketplace, t]) + const handleInstallMethodSelect = (action: string) => { + if (action === 'local') { + fileInputRef.current?.click() + return + } + + if (action === 'marketplace') { + onSwitchToMarketplaceTab() + return + } + + queueMicrotask(() => { + setSelectedAction(action) + }) + } + return ( - +
- setIsMenuOpen(v => !v)}> - - - -
- - {t('installFrom', { ns: 'plugin' })} - - -
- {installMethods.map(({ icon: Icon, text, action }) => ( -
{ - if (action === 'local') { - fileInputRef.current?.click() - } - else if (action === 'marketplace') { - onSwitchToMarketplaceTab() - setIsMenuOpen(false) - } - else { - setSelectedAction(action) - setIsMenuOpen(false) - } - }} - > - - {text} -
- ))} -
-
-
+ + + + + {t('installFrom', { ns: 'plugin' })} + + + {installMethods.map(({ icon: Icon, text, action }) => ( + handleInstallMethodSelect(action)} + > +
+ + {text} +
+
+ ))} +
{selectedAction === 'github' && ( (
handleUninstall(item.id)}>{item.name} 卸载
))} */} -
+ ) } diff --git a/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx index 0b4773f796..962f39c326 100644 --- a/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx @@ -2,6 +2,82 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import OperationDropdown from '../operation-dropdown' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + render, + onClick, + }: { + children: React.ReactNode + render?: React.ReactElement + onClick?: React.MouseEventHandler + }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + const handleClick = (e: React.MouseEvent) => { + onClick?.(e) + setOpen(!isOpen) + } + + if (render) + return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record, children) + + return + }, + DropdownMenuContent: ({ + children, + className, + popupClassName, + }: { + children: React.ReactNode + className?: string + popupClassName?: string + }) => { + const { isOpen } = useDropdownMenuContext() + return isOpen ?
{children}
: null + }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + describe('OperationDropdown', () => { const defaultProps = { onEdit: vi.fn(), @@ -16,7 +92,7 @@ describe('OperationDropdown', () => { it('should render trigger button with more icon', () => { render() - const button = document.querySelector('button') + const button = screen.getByTestId('dropdown-trigger') expect(button).toBeInTheDocument() const svg = button?.querySelector('svg') expect(svg).toBeInTheDocument() @@ -39,37 +115,27 @@ describe('OperationDropdown', () => { it('should open dropdown when trigger is clicked', async () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) + fireEvent.click(screen.getByTestId('dropdown-trigger')) - // Dropdown content should be rendered - expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument() - expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument() - } + expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument() + expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument() }) it('should call onOpenChange when opened', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - expect(onOpenChange).toHaveBeenCalledWith(true) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(onOpenChange).toHaveBeenCalledWith(true) }) it('should close dropdown when trigger is clicked again', async () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - fireEvent.click(trigger) - expect(onOpenChange).toHaveBeenLastCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(onOpenChange).toHaveBeenLastCalledWith(false) }) }) @@ -78,62 +144,38 @@ describe('OperationDropdown', () => { const onEdit = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const editOption = screen.getByText('tools.mcp.operation.edit') - fireEvent.click(editOption) - - expect(onEdit).toHaveBeenCalledTimes(1) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('tools.mcp.operation.edit')) + expect(onEdit).toHaveBeenCalledTimes(1) }) it('should call onRemove when remove option is clicked', () => { const onRemove = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const removeOption = screen.getByText('tools.mcp.operation.remove') - fireEvent.click(removeOption) - - expect(onRemove).toHaveBeenCalledTimes(1) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('tools.mcp.operation.remove')) + expect(onRemove).toHaveBeenCalledTimes(1) }) it('should close dropdown after edit is clicked', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - onOpenChange.mockClear() - - const editOption = screen.getByText('tools.mcp.operation.edit') - fireEvent.click(editOption) - - expect(onOpenChange).toHaveBeenCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + onOpenChange.mockClear() + fireEvent.click(screen.getByText('tools.mcp.operation.edit')) + expect(onOpenChange).toHaveBeenCalledWith(false) }) it('should close dropdown after remove is clicked', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - onOpenChange.mockClear() - - const removeOption = screen.getByText('tools.mcp.operation.remove') - fireEvent.click(removeOption) - - expect(onOpenChange).toHaveBeenCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + onOpenChange.mockClear() + fireEvent.click(screen.getByText('tools.mcp.operation.remove')) + expect(onOpenChange).toHaveBeenCalledWith(false) }) }) @@ -141,39 +183,25 @@ describe('OperationDropdown', () => { it('should have correct dropdown width', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const dropdown = document.querySelector('.w-\\[160px\\]') - expect(dropdown).toBeInTheDocument() - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + const dropdown = document.querySelector('.w-\\[160px\\]') + expect(dropdown).toBeInTheDocument() }) - it('should have rounded-xl on dropdown', () => { + it('should render dropdown content through the shared popup shell', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]') - expect(dropdown).toBeInTheDocument() - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(screen.getByTestId('dropdown-content')).toBeInTheDocument() }) - it('should show destructive hover style on remove option', () => { + it('should apply destructive highlighted styles on remove option', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - // The text is in a div, and the hover style is on the parent div with group class - const removeOptionText = screen.getByText('tools.mcp.operation.remove') - const removeOptionContainer = removeOptionText.closest('.group') - expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover') - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + const removeOptionText = screen.getByText('tools.mcp.operation.remove') + const removeOptionContainer = removeOptionText.closest('button') + expect(removeOptionContainer).toHaveClass('data-highlighted:bg-state-destructive-hover') }) }) diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.tsx b/web/app/components/tools/mcp/detail/operation-dropdown.tsx index 4f5468aebc..9a7ee67051 100644 --- a/web/app/components/tools/mcp/detail/operation-dropdown.tsx +++ b/web/app/components/tools/mcp/detail/operation-dropdown.tsx @@ -7,14 +7,15 @@ import { RiMoreFill, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' type Props = { inCard?: boolean @@ -30,60 +31,37 @@ const OperationDropdown: FC = ({ onRemove, }) => { const { t } = useTranslation() - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - onOpenChange?.(v) - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) + const [open, setOpen] = useState(false) + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + onOpenChange?.(nextOpen) + }, [onOpenChange]) return ( - - -
- - - -
-
- -
-
{ - onEdit() - handleTrigger() - }} - > - -
{t('mcp.operation.edit', { ns: 'tools' })}
-
-
{ - onRemove() - handleTrigger() - }} - > - -
{t('mcp.operation.remove', { ns: 'tools' })}
-
-
-
-
+ + } + > + + + + + +
{t('mcp.operation.edit', { ns: 'tools' })}
+
+ + +
{t('mcp.operation.remove', { ns: 'tools' })}
+
+
+
) } export default React.memo(OperationDropdown) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx index cce4c070a1..7df9cd091f 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx @@ -1,7 +1,7 @@ +import type * as React from 'react' import type { TriggerOption } from '../test-run-menu' import { fireEvent, render, renderHook, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' import { TriggerType } from '../test-run-menu' import { getNormalizedShortcutKey, @@ -10,6 +10,33 @@ import { useShortcutMenu, } from '../test-run-menu-helpers' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string }) => ( + + ), + } +}) + vi.mock('../shortcuts-name', () => ({ default: ({ keys }: { keys: string[] }) => {keys.join('+')}, })) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx index 2e3384b61e..40387d1e0e 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -5,25 +5,61 @@ import { act } from 'react' import * as React from 'react' import TestRunMenu, { TriggerType } from '../test-run-menu' -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ - children, - }: { - children: React.ReactNode - }) =>
{children}
, - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick?: () => void - }) =>
{children}
, - PortalToFollowElemContent: ({ - children, - }: { - children: React.ReactNode - }) =>
{children}
, -})) +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + render, + }: { + children: React.ReactNode + render?: React.ReactElement + }) => { + const { open, setOpen } = useDropdownMenuContext() + + if (render) + return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record, children) + + return + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuGroupLabel: ({ children, className }: { children: React.ReactNode, className?: string }) =>
{children}
, + DropdownMenuSeparator: ({ className }: { className?: string }) =>
, + DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) vi.mock('../shortcuts-name', () => ({ default: ({ keys }: { keys: string[] }) => {keys.join('+')}, @@ -95,10 +131,11 @@ describe('TestRunMenu', () => { act(() => { fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' })) }) + expect(screen.getByText('~')).toBeInTheDocument() + fireEvent.keyDown(window, { key: '0' }) expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' })) - expect(screen.getByText('~')).toBeInTheDocument() }) it('should ignore disabled options in the rendered menu', async () => { diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx index dbe6b616a0..4a25cd87a6 100644 --- a/web/app/components/workflow/header/test-run-menu-helpers.tsx +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -6,6 +6,7 @@ import { isValidElement, useEffect, } from 'react' +import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu' import ShortcutsName from '../shortcuts-name' export type ShortcutMapping = { @@ -27,9 +28,8 @@ export const OptionRow = ({ onSelect: (option: TriggerOption) => void }) => { return ( -
onSelect(option)} >
@@ -41,7 +41,7 @@ export const OptionRow = ({ {shortcutKey && ( )} -
+ ) } diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index 1d496e4332..5b86c3c3f5 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -1,7 +1,7 @@ import type { ShortcutMapping } from './test-run-menu-helpers' import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuGroupLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers' export enum TriggerType { @@ -127,7 +127,7 @@ const TestRunMenu = forwardRef(({ }), [hasSingleEnabledOption, runSoleOption]) const renderOption = (option: TriggerOption) => { - return + return } const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options]) @@ -141,27 +141,28 @@ const TestRunMenu = forwardRef(({ } return ( - - setOpen(!open)}> -
- {children} -
-
- -
-
+ }> + {children} + + + + {t('common.chooseStartNodeToRun', { ns: 'workflow' })} -
+
{hasUserInput && renderOption(options.userInput!)} {(hasTriggers || hasRunAll) && hasUserInput && ( -
+ )} {hasRunAll && renderOption(options.runAll!)} @@ -170,9 +171,9 @@ const TestRunMenu = forwardRef(({ .filter(trigger => trigger.enabled !== false) .map(trigger => renderOption(trigger))}
-
- - + + + ) }) diff --git a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx index 63813c8a46..f59de9e874 100644 --- a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx @@ -4,6 +4,74 @@ import { VarType } from '@/app/components/workflow/types' import { WriteMode } from '../../types' import OperationSelector from '../operation-selector' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + className, + disabled, + }: { + children: React.ReactNode + className?: string + disabled?: boolean + }) => { + const { open, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuGroupLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + describe('assigner/operation-selector', () => { it('shows numeric write modes and emits the selected operation', async () => { const user = userEvent.setup() diff --git a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx index 8bce904b74..333aa5b2cd 100644 --- a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx +++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx @@ -9,12 +9,15 @@ import { } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Divider from '@/app/components/base/divider' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuGroupLabel, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { getOperationItems, isOperationItem } from '../utils' type OperationSelectorProps = { @@ -49,65 +52,57 @@ const OperationSelector: FC = ({ const selectedItem = items.find(item => item.value === value) return ( - - !disabled && setOpen(v => !v)} + -
-
- + - {selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })} - -
- + > + {selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })} +
-
+ + - -
-
-
-
{t('nodes.assigner.operations.title', { ns: 'workflow' })}
-
- {items.map(item => ( - !isOperationItem(item) - ? ( - - ) - : ( -
{ - onSelect(item) - setOpen(false) - }} - > -
- {t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })} -
- {item.value === value && ( -
- -
- )} + + + {t('nodes.assigner.operations.title', { ns: 'workflow' })} + {items.map(item => ( + !isOperationItem(item) + ? ( + + ) + : ( + onSelect(item)} + > +
+ {t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}
- ) - ))} -
-
- - + {item.value === value && ( +
+ +
+ )} + + ) + ))} + + + ) } diff --git a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx new file mode 100644 index 0000000000..c5aeabbaaa --- /dev/null +++ b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx @@ -0,0 +1,309 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { act } from 'react' +import MoreActions from '../more-actions' + +const mockToPng = vi.fn() +const mockToJpeg = vi.fn() +const mockToSvg = vi.fn() +const mockDownloadUrl = vi.fn() +const mockSetViewport = vi.fn() +const mockGetNodesReadOnly = vi.fn() +const { + mockAppStoreState, + mockWorkflowState, +} = vi.hoisted(() => ({ + mockAppStoreState: { + appSidebarExpand: 'collapse', + }, + mockWorkflowState: { + knowledgeName: '', + appName: 'Demo App', + maximizeCanvas: false, + }, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => { + const { open, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuSeparator: ({ className }: { className?: string }) =>
, + } +}) + +vi.mock('html-to-image', () => ({ + toPng: (...args: unknown[]) => mockToPng(...args), + toJpeg: (...args: unknown[]) => mockToJpeg(...args), + toSvg: (...args: unknown[]) => mockToSvg(...args), +})) + +vi.mock('reactflow', () => ({ + getNodesBounds: () => ({ x: 0, y: 0, width: 240, height: 120 }), + useReactFlow: () => ({ + getNodes: () => [{ id: 'node-1' }], + getViewport: () => ({ x: 0, y: 0, zoom: 1 }), + setViewport: mockSetViewport, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof mockAppStoreState) => unknown) => selector(mockAppStoreState), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: typeof mockWorkflowState) => unknown) => selector(mockWorkflowState), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ + getNodesReadOnly: mockGetNodesReadOnly, + }), +})) + +vi.mock('@/utils/download', () => ({ + downloadUrl: (...args: unknown[]) => mockDownloadUrl(...args), +})) + +vi.mock('../tip-popup', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/base/image-uploader/image-preview', () => ({ + default: ({ title, onCancel }: { title: string, onCancel: () => void }) => ( +
+ {title} + +
+ ), +})) + +describe('MoreActions', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + mockGetNodesReadOnly.mockReturnValue(false) + mockToPng.mockResolvedValue('data:image/png;base64,current') + mockToJpeg.mockResolvedValue('data:image/jpeg;base64,current') + mockToSvg.mockResolvedValue('data:image/svg+xml;base64,current') + mockAppStoreState.appSidebarExpand = 'collapse' + mockWorkflowState.knowledgeName = '' + mockWorkflowState.appName = 'Demo App' + mockWorkflowState.maximizeCanvas = false + + document.body.innerHTML = '' + const viewport = document.createElement('div') + viewport.className = 'react-flow__viewport' + document.body.appendChild(viewport) + }) + + it('opens the menu and exports the current view as png', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText('workflow.common.exportPNG')[0]) + + await waitFor(() => { + expect(mockToPng).toHaveBeenCalledTimes(1) + }) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: 'data:image/png;base64,current', + fileName: 'Demo App.png', + }) + }) + + it('does not open the menu when the workflow is read only', async () => { + const user = userEvent.setup() + mockGetNodesReadOnly.mockReturnValue(true) + + render() + + await user.click(screen.getByRole('button')) + + expect(screen.queryByText('workflow.common.exportImage')).not.toBeInTheDocument() + }) + + it('shows a preview when exporting the whole workflow', async () => { + vi.useFakeTimers() + + render() + + fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1]) + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + + expect(screen.getByTestId('image-preview')).toHaveTextContent('Demo App-whole-workflow.png') + await act(async () => { + await vi.runAllTimersAsync() + }) + expect(mockSetViewport).toHaveBeenCalledTimes(2) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: 'data:image/png;base64,current', + fileName: 'Demo App-whole-workflow.png', + }) + }) + + it.each([ + ['workflow.common.exportJPEG', mockToJpeg, 'Demo App.jpeg'], + ['workflow.common.exportSVG', mockToSvg, 'Demo App.svg'], + ])('exports the current view with %s', async (label, exporter, fileName) => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText(label)[0]) + + await waitFor(() => { + expect(exporter).toHaveBeenCalledTimes(1) + }) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: expect.any(String), + fileName, + }) + }) + + it('exports the whole workflow as svg when the canvas is maximized', async () => { + vi.useFakeTimers() + mockWorkflowState.maximizeCanvas = true + + render() + + fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getAllByText('workflow.common.exportSVG')[1]) + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + + expect(mockToSvg).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.runAllTimersAsync() + }) + expect(mockSetViewport).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('image-preview')).toHaveTextContent('Demo App-whole-workflow.svg') + }) + + it('returns early when there is no app or knowledge name', async () => { + const user = userEvent.setup() + mockWorkflowState.appName = '' + mockWorkflowState.knowledgeName = '' + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText('workflow.common.exportPNG')[0]) + + expect(mockToPng).not.toHaveBeenCalled() + expect(mockDownloadUrl).not.toHaveBeenCalled() + }) + + it('returns early when the viewport element is missing', async () => { + const user = userEvent.setup() + document.querySelector('.react-flow__viewport')?.remove() + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText('workflow.common.exportPNG')[0]) + + expect(mockToPng).not.toHaveBeenCalled() + expect(mockDownloadUrl).not.toHaveBeenCalled() + }) + + it('returns early when the workflow becomes read only before exporting', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button')) + mockGetNodesReadOnly.mockReturnValue(true) + await user.click(screen.getAllByText('workflow.common.exportJPEG')[0]) + + expect(mockToJpeg).not.toHaveBeenCalled() + expect(mockDownloadUrl).not.toHaveBeenCalled() + }) + + it('logs export failures and lets the preview close', async () => { + const user = userEvent.setup() + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockToJpeg.mockRejectedValueOnce(new Error('boom')) + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getAllByText('workflow.common.exportJPEG')[0]) + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Export image failed:', expect.any(Error)) + }) + expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument() + + mockToPng.mockResolvedValueOnce('data:image/png;base64,current') + fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1]) + await waitFor(() => { + expect(screen.getByTestId('image-preview')).toBeInTheDocument() + }) + await user.click(screen.getByText('close-preview')) + expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument() + + consoleErrorSpy.mockRestore() + }) +}) diff --git a/web/app/components/workflow/operator/more-actions.tsx b/web/app/components/workflow/operator/more-actions.tsx index 5e71cc658b..66dbed1a91 100644 --- a/web/app/components/workflow/operator/more-actions.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -14,10 +14,12 @@ import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import ImagePreview from '@/app/components/base/image-uploader/image-preview' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useStore } from '@/app/components/workflow/store' import { downloadUrl } from '@/utils/download' import { useNodesReadOnly } from '../hooks' @@ -37,6 +39,7 @@ const MoreActions: FC = () => { const { appSidebarExpand } = useAppStore(useShallow(state => ({ appSidebarExpand: state.appSidebarExpand, }))) + const isReadOnly = getNodesReadOnly() const crossAxisOffset = useMemo(() => { if (maximizeCanvas) @@ -161,93 +164,67 @@ const MoreActions: FC = () => { } }, [getNodesReadOnly, appName, reactFlow, knowledgeName]) - const handleTrigger = useCallback(() => { - if (getNodesReadOnly()) - return - - setOpen(v => !v) - }, [getNodesReadOnly]) - return ( <> - { + if (isReadOnly) { + setOpen(false) + return + } + setOpen(nextOpen) }} > - + -
- -
+
-
- -
-
-
- - {t('common.exportImage', { ns: 'workflow' })} -
-
- {t('common.currentView', { ns: 'workflow' })} -
-
handleExportImage('png')} - > - {t('common.exportPNG', { ns: 'workflow' })} -
-
handleExportImage('jpeg')} - > - {t('common.exportJPEG', { ns: 'workflow' })} -
-
handleExportImage('svg')} - > - {t('common.exportSVG', { ns: 'workflow' })} -
- -
- -
- {t('common.currentWorkflow', { ns: 'workflow' })} -
-
handleExportImage('png', true)} - > - {t('common.exportPNG', { ns: 'workflow' })} -
-
handleExportImage('jpeg', true)} - > - {t('common.exportJPEG', { ns: 'workflow' })} -
-
handleExportImage('svg', true)} - > - {t('common.exportSVG', { ns: 'workflow' })} -
-
+ + +
+ + {t('common.exportImage', { ns: 'workflow' })}
- - +
+ {t('common.currentView', { ns: 'workflow' })} +
+ handleExportImage('png')}> + {t('common.exportPNG', { ns: 'workflow' })} + + handleExportImage('jpeg')}> + {t('common.exportJPEG', { ns: 'workflow' })} + + handleExportImage('svg')}> + {t('common.exportSVG', { ns: 'workflow' })} + + + + +
+ {t('common.currentWorkflow', { ns: 'workflow' })} +
+ handleExportImage('png', true)}> + {t('common.exportPNG', { ns: 'workflow' })} + + handleExportImage('jpeg', true)}> + {t('common.exportJPEG', { ns: 'workflow' })} + + handleExportImage('svg', true)}> + {t('common.exportSVG', { ns: 'workflow' })} + +
+ {previewUrl && ( { const onClick = vi.fn() render( - , + + + + + , ) await user.click(screen.getByText('Delete')) diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx index a635e80bab..f063902753 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx @@ -1,14 +1,13 @@ import type { FC } from 'react' import { RiMoreFill } from '@remixicon/react' import * as React from 'react' -import { useCallback } from 'react' -import Divider from '@/app/components/base/divider' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { VersionHistoryContextMenuOptions } from '../../../types' import MenuItem from './menu-item' import useContextMenu from './use-context-menu' @@ -28,58 +27,44 @@ const ContextMenu: FC = (props: ContextMenuProps) => { options, } = useContextMenu(props) - const handleClickTrigger = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - setOpen(v => !v) - }, [setOpen]) - return ( - - - - - -
-
- { - options.map((option) => { - return ( - - ) - }) - } -
- { - isShowDelete && ( - <> - -
- -
- - ) - } -
-
-
+ e.stopPropagation()} />} + > + + + + { + options.map(option => ( + + )) + } + { + isShowDelete && ( + <> + + + + ) + } + + ) } diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx index 5a3f21272f..2c393dea77 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { VersionHistoryContextMenuOptions } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' +import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu' type MenuItemProps = { item: { @@ -18,23 +19,25 @@ const MenuItem: FC = ({ isDestructive = false, }) => { return ( -
{ + destructive={isDestructive} + onClick={(event) => { + event.stopPropagation() onClick(item.key) }} >
{item.name}
-
+ ) } diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx index d109635af4..35b32a5ce6 100644 --- a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx @@ -3,6 +3,52 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import AgentLogNavMore from '../agent-log-nav-more' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, render }: { children: React.ReactNode, render?: React.ReactElement }) => { + const { open, setOpen } = useDropdownMenuContext() + + if (render) + return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record, children) + + return + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + const createLogItem = (overrides: Partial = {}): AgentLogItemWithChildren => ({ message_id: 'message-1', label: 'Planner', diff --git a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx index 8bdb6ad227..77f3c778a6 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx @@ -1,12 +1,13 @@ import type { AgentLogItemWithChildren } from '@/types/workflow' import { RiMoreLine } from '@remixicon/react' import { useState } from 'react' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' type AgentLogNavMoreProps = { options: AgentLogItemWithChildren[] @@ -19,42 +20,39 @@ const AgentLogNavMore = ({ const [open, setOpen] = useState(false) return ( - - setOpen(v => !v)}> - - - -
- { - options.map(option => ( -
{ - onShowAgentOrToolLog(option) - setOpen(false) - }} - > - {option.label} -
- )) - } -
-
-
+ + )} + > + + + + { + options.map(option => ( + onShowAgentOrToolLog(option)} + > + {option.label} + + )) + } + + ) }