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.
This commit is contained in:
CodingOnStar
2026-04-16 15:30:27 +08:00
parent c3eff6abdc
commit a13996dba1
27 changed files with 1545 additions and 760 deletions

View File

@ -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 }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
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 }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-trigger"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown-content">{children}</div>,
}
})
vi.mock('../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
@ -128,11 +151,11 @@ describe('AppSidebarDropdown', () => {
const user = userEvent.setup()
render(<AppSidebarDropdown navigation={navigation} />)
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', () => {

View File

@ -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 }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
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 }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-trigger"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown-content">{children}</div>,
}
})
vi.mock('../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
@ -173,10 +196,10 @@ describe('DatasetSidebarDropdown', () => {
const user = userEvent.setup()
render(<DatasetSidebarDropdown navigation={navigation} />)
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', () => {

View File

@ -30,17 +30,67 @@ vi.mock('../../../base/ui/button', () => ({
),
}))
vi.mock('../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
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 }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
onClick,
render,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLElement>
render?: React.ReactElement
}) => {
const { isOpen, setOpen } = useDropdownMenuContext()
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e)
setOpen(!isOpen)
}
if (render)
return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children)
return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button>
},
DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen)
return null
return <div data-testid="dropdown-content" className={popupClassName}>{children}</div>
},
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-item"
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
DropdownMenuSeparator: () => <hr data-testid="dropdown-separator" />,
}
})
const createOperation = (id: string, title: string, type?: 'divider'): Operation => ({
id,
@ -169,7 +219,7 @@ describe('AppOperations', () => {
render(<AppOperations gap={4} primaryOperations={ops} secondaryOperations={secondary} />)
const trigger = screen.queryByTestId('portal-trigger')
const trigger = screen.queryByTestId('dropdown-trigger')
if (trigger)
await user.click(trigger)

View File

@ -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<Operation[]>([])
const [showMore, setShowMore] = useState(false)
const navRef = useRef<HTMLDivElement>(null)
const handleTriggerMore = useCallback(() => {
setShowMore(true)
}, [setShowMore])
const primaryOps = useMemo(() => {
if (operations)
@ -169,43 +172,44 @@ const AppOperations = ({
</Button>
))}
{shouldShowMoreButton && (
<PortalToFollowElem
open={showMore}
onOpenChange={setShowMore}
placement="bottom-end"
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger onClick={handleTriggerMore}>
<Button
size="small"
variant="secondary"
className="gap-px"
>
<DropdownMenu open={showMore} onOpenChange={setShowMore}>
<DropdownMenuTrigger
render={(
<Button
size="small"
variant="secondary"
className="gap-px"
/>
)}
>
<>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('operation.more', { ns: 'common' })}
</span>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-30">
<div className="flex min-w-[264px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
{moreOperations.map(item => item.type === 'divider'
? (
<div key={item.id} className="my-1 h-px bg-divider-subtle" />
)
: (
<div
key={item.id}
className="flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover"
onClick={item.onClick}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[264px]"
>
{moreOperations.map(item => item.type === 'divider'
? (
<DropdownMenuSeparator key={item.id} />
)
: (
<DropdownMenuItem
key={item.id}
className="gap-x-1 px-1.5"
onClick={item.onClick}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>

View File

@ -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 (
<>
<div className="fixed top-2 left-2 z-20">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div className={cn('flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover', open && 'bg-background-default-hover')}>
<AppIcon
size="small"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<RiMenuLine className="h-4 w-4 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size="small"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<RiMenuLine className="h-4 w-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className={cn('w-[305px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg')}>
<div className="p-2">
<div
@ -114,8 +106,8 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
})}
</nav>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="z-20">
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />

View File

@ -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 (
<>
<div className="fixed top-2 left-2 z-20">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size="small"
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<RiMenuLine className="size-4 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size="small"
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<RiMenuLine className="size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg">
<Effect className="top-[-22px] -left-5 opacity-15" />
<div className="flex flex-col gap-y-2 p-4">
@ -155,8 +143,8 @@ const DatasetSidebarDropdown = ({
documentCount={dataset.document_count}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)

View File

@ -22,24 +22,57 @@ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <span data-testid="model-icon">{modelName}</span>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('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 }) => (
<OpenContext.Provider value={open}>
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<OpenContext.Provider value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="portal-root">{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
<div className={className} onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const open = ReactModule.useContext(OpenContext)
return open ? <div className={className}>{children}</div> : 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<string, unknown>, children)
}
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
},
DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => {
const context = useOpenContext()
return context.open ? <div className={popupClassName}>{children}</div> : null
},
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { setOpen } = useOpenContext()
return (
<button
type="button"
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})

View File

@ -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<PublishWithMultipleModelProps> = ({
}
})
const handleToggle = () => {
if (validModelConfigs.length)
setOpen(v => !v)
}
const handleSelect = (item: ModelAndParameter) => {
onSelect(item)
setOpen(false)
}
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-end"
>
<PortalToFollowElemTrigger className="w-full" onClick={handleToggle}>
<Button
variant="primary"
disabled={!validModelConfigs.length}
className="mt-3 w-full"
>
<DropdownMenuTrigger
disabled={!validModelConfigs.length}
render={(
<Button
variant="primary"
disabled={!validModelConfigs.length}
className="mt-3 w-full"
/>
)}
>
<>
{t('operation.applyConfig', { ns: 'appDebug' })}
<RiArrowDownSLine className="ml-0.5 h-3 w-3" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50 mt-1 w-[288px]">
<div className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
{t('publishAs', { ns: 'appDebug' })}
</div>
{
validModelConfigs.map((item, index) => (
<div
key={item.id}
className="flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-tertiary hover:bg-state-base-hover"
onClick={() => handleSelect(item)}
>
<span className="min-w-[18px] italic">
#
{index + 1}
</span>
<ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" />
<div
className="ml-1 truncate text-text-secondary"
title={item.modelItem.label[language]}
>
{item.modelItem.label[language]}
</div>
</div>
))
}
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[288px] p-1"
>
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
{t('publishAs', { ns: 'appDebug' })}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{
validModelConfigs.map((item, index) => (
<DropdownMenuItem
key={item.id}
className="gap-0 px-3"
onClick={() => onSelect(item)}
>
<span className="min-w-[18px] italic">
#
{index + 1}
</span>
<ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" />
<div
className="ml-1 truncate text-text-secondary"
title={item.modelItem.label[language]}
>
{item.modelItem.label[language]}
</div>
</DropdownMenuItem>
))
}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -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 }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
{...props}
>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
popupProps,
}: {
children: React.ReactNode
popupProps?: React.HTMLAttributes<HTMLDivElement>
}) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen)
return null
return <div data-testid="dropdown-content" {...popupProps}>{children}</div>
},
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
className?: string
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
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(
<div onClick={onParentClick}>
<ItemOperation
isPinned={false}
isShowDelete
togglePin={vi.fn()}
onDelete={vi.fn()}
/>
</div>,
)
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByTestId('dropdown-content'))
expect(onParentClick).not.toHaveBeenCalled()
})
})
})

View File

@ -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<IItemOperationProps> = ({
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 (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
<DropdownMenuTrigger
data-testid="item-operation-trigger"
className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} bg-components-actionbar-bg! shadow-none!`)}
onClick={(e) => {
e.stopPropagation()
}}
>
<div
className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} bg-components-actionbar-bg! shadow-none!`)}
data-testid="item-operation-trigger"
>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
className="z-50"
<span className="sr-only">{tCommon('operation.more')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[120px]"
popupProps={{
onMouseEnter: setIsHovering,
onMouseLeave: setNotHovering,
onClick: e => e.stopPropagation(),
}}
>
<div
ref={ref}
className="min-w-[120px] rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]"
onMouseEnter={setIsHovering}
onMouseLeave={setNotHovering}
<DropdownMenuItem
className={cn(s.actionItem, 'gap-2 px-3')}
onClick={(e) => {
e.stopPropagation()
togglePin()
}}
>
<div className={cn(s.actionItem, 'group hover:bg-state-base-hover')} onClick={togglePin}>
<Pin02 className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</div>
{isShowRenameConversation && (
<div className={cn(s.actionItem, 'group hover:bg-state-base-hover')} onClick={onRenameConversation}>
<RiEditLine className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{t('sidebar.action.rename', { ns: 'explore' })}</span>
</div>
)}
{isShowDelete && (
<div className={cn(s.actionItem, s.deleteActionItem, 'group hover:bg-state-base-hover')} onClick={onDelete}>
<RiDeleteBinLine className={cn(s.deleteActionItemChild, 'h-4 w-4 shrink-0 stroke-current stroke-2 text-text-secondary')} />
<span className={cn(s.actionName, s.deleteActionItemChild)}>{t('sidebar.action.delete', { ns: 'explore' })}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<Pin02 className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{isPinned ? t('sidebar.action.unpin') : t('sidebar.action.pin')}</span>
</DropdownMenuItem>
{isShowRenameConversation && (
<DropdownMenuItem
className={cn(s.actionItem, 'gap-2 px-3')}
onClick={(e) => {
e.stopPropagation()
onRenameConversation?.()
}}
>
<RiEditLine className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{t('sidebar.action.rename')}</span>
</DropdownMenuItem>
)}
{isShowDelete && (
<DropdownMenuItem
className={cn(s.actionItem, s.deleteActionItem, 'gap-2 px-3 data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive')}
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<RiDeleteBinLine className={cn(s.deleteActionItemChild, 'h-4 w-4 shrink-0 stroke-current stroke-2 text-inherit')} />
<span className={cn(s.actionName, s.deleteActionItemChild, 'text-inherit')}>{t('sidebar.action.delete')}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(ItemOperation)

View File

@ -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 }) => <span data-testid="button-content">{children}</span>,
Button: ({ children, onClick, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" data-testid="button-content" className={className} onClick={onClick} {...props}>{children}</button>
),
}))
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 <div>{children}</div>
return (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
)
},
PortalToFollowElemTrigger: ({
DropdownMenuTrigger: ({
children,
onClick,
render,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="dropdown-trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({
onClick?: React.MouseEventHandler<HTMLElement>
render?: React.ReactElement
}) => {
const { isOpen, setOpen } = useDropdownMenuContext()
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e)
setOpen(!isOpen)
}
if (render)
return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children)
return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button>
},
DropdownMenuContent: ({
children,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="dropdown-content">{children}</div> : null,
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-item"
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
@ -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(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
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(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
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(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
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(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
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()
})
})

View File

@ -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 (
<PortalToFollowElem
open={isMenuOpen}
onOpenChange={setIsMenuOpen}
placement="bottom-start"
offset={4}
>
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<div className="relative">
<PortalToFollowElemTrigger onClick={() => setIsMenuOpen(v => !v)}>
<Button
className={cn('h-full w-full p-2 text-components-button-secondary-text', isMenuOpen && 'bg-state-base-hover')}
>
<DropdownMenuTrigger
render={(
<Button
className={cn('h-full w-full p-2 text-components-button-secondary-text', isMenuOpen && 'bg-state-base-hover')}
/>
)}
>
<>
<RiAddLine className="h-4 w-4" />
<span className="pl-1">{t('installPlugin', { ns: 'plugin' })}</span>
<RiArrowDownSLine className="ml-1 h-4 w-4" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className="shadows-shadow-lg flex w-[200px] flex-col items-start rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1 pb-2">
<span className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-2 system-xs-medium-uppercase text-text-tertiary">
{t('installFrom', { ns: 'plugin' })}
</span>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
/>
<div className="w-full">
{installMethods.map(({ icon: Icon, text, action }) => (
<div
key={action}
className="flex w-full cursor-pointer! items-center gap-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => {
if (action === 'local') {
fileInputRef.current?.click()
}
else if (action === 'marketplace') {
onSwitchToMarketplaceTab()
setIsMenuOpen(false)
}
else {
setSelectedAction(action)
setIsMenuOpen(false)
}
}}
>
<Icon className="h-4 w-4 text-text-tertiary" />
<span className="px-1 system-md-regular text-text-secondary">{text}</span>
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[200px] pb-2"
>
<span className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-3 system-xs-medium-uppercase text-text-tertiary">
{t('installFrom', { ns: 'plugin' })}
</span>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
/>
{installMethods.map(({ icon: Icon, text, action }) => (
<DropdownMenuItem
key={action}
className="gap-1 px-2"
onClick={() => handleInstallMethodSelect(action)}
>
<div className="flex items-center gap-1">
<Icon className="h-4 w-4 text-text-tertiary" />
<span className="px-1 system-md-regular text-text-secondary">{text}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</div>
{selectedAction === 'github' && (
<InstallFromGitHub
@ -150,7 +156,7 @@ const InstallPluginDropdown = ({
{/* {pluginLists.map((item: any) => (
<div key={item.id} onClick={() => handleUninstall(item.id)}>{item.name} 卸载</div>
))} */}
</PortalToFollowElem>
</DropdownMenu>
)
}

View File

@ -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 }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
render,
onClick,
}: {
children: React.ReactNode
render?: React.ReactElement
onClick?: React.MouseEventHandler<HTMLElement>
}) => {
const { isOpen, setOpen } = useDropdownMenuContext()
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e)
setOpen(!isOpen)
}
if (render)
return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children)
return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button>
},
DropdownMenuContent: ({
children,
className,
popupClassName,
}: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
const { isOpen } = useDropdownMenuContext()
return isOpen ? <div data-testid="dropdown-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div> : null
},
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
className?: string
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-item"
className={className}
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
describe('OperationDropdown', () => {
const defaultProps = {
onEdit: vi.fn(),
@ -16,7 +92,7 @@ describe('OperationDropdown', () => {
it('should render trigger button with more icon', () => {
render(<OperationDropdown {...defaultProps} />)
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(<OperationDropdown {...defaultProps} />)
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(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
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(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
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(<OperationDropdown {...defaultProps} onEdit={onEdit} />)
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(<OperationDropdown {...defaultProps} onRemove={onRemove} />)
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(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
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(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
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(<OperationDropdown {...defaultProps} />)
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(<OperationDropdown {...defaultProps} />)
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(<OperationDropdown {...defaultProps} />)
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')
})
})

View File

@ -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<Props> = ({
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 (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: !inCard ? -12 : 0,
crossAxis: !inCard ? 36 : 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton size={inCard ? 'l' : 'm'} className={cn(open && 'bg-state-base-hover')}>
<RiMoreFill className={cn('h-4 w-4', inCard && 'h-5 w-5')} />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className="w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs">
<div
className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 hover:bg-state-base-hover"
onClick={() => {
onEdit()
handleTrigger()
}}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
<div className="ml-2 system-md-regular text-text-secondary">{t('mcp.operation.edit', { ns: 'tools' })}</div>
</div>
<div
className="group flex cursor-pointer items-center rounded-lg px-3 py-1.5 hover:bg-state-destructive-hover"
onClick={() => {
onRemove()
handleTrigger()
}}
>
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover:text-text-destructive-secondary" />
<div className="ml-2 system-md-regular text-text-secondary group-hover:text-text-destructive">{t('mcp.operation.remove', { ns: 'tools' })}</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
render={<ActionButton size={inCard ? 'l' : 'm'} className={cn(open && 'bg-state-base-hover')} />}
>
<RiMoreFill className={cn('h-4 w-4', inCard && 'h-5 w-5')} />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[160px]"
>
<DropdownMenuItem onClick={onEdit}>
<RiEditLine className="h-4 w-4 shrink-0 text-text-tertiary" />
<div className="ml-2 system-md-regular text-text-secondary">{t('mcp.operation.edit', { ns: 'tools' })}</div>
</DropdownMenuItem>
<DropdownMenuItem
className="data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive"
onClick={onRemove}
>
<RiDeleteBinLine className="h-4 w-4 shrink-0 text-inherit" />
<div className="ml-2 system-md-regular text-inherit">{t('mcp.operation.remove', { ns: 'tools' })}</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(OperationDropdown)

View File

@ -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 }) => (
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div>{children}</div>
</DropdownMenuContext>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
const { open } = useDropdownMenuContext()
return open ? <div>{children}</div> : null
},
DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement>, className?: string }) => (
<button type="button" className={className} onClick={onClick}>{children}</button>
),
}
})
vi.mock('../shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
}))

View File

@ -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
}) => <div>{children}</div>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => <div onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({
children,
}: {
children: React.ReactNode
}) => <div>{children}</div>,
}))
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 }) => (
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
render,
}: {
children: React.ReactNode
render?: React.ReactElement
}) => {
const { open, setOpen } = useDropdownMenuContext()
if (render)
return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record<string, unknown>, children)
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
const { open } = useDropdownMenuContext()
return open ? <div>{children}</div> : null
},
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuGroupLabel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>,
DropdownMenuSeparator: ({ className }: { className?: string }) => <div className={className} data-testid="dropdown-separator" />,
DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement>, className?: string }) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
vi.mock('../shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
@ -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 () => {

View File

@ -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 (
<div
key={option.id}
className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
<DropdownMenuItem
className="h-auto px-3 py-1.5 system-md-regular"
onClick={() => onSelect(option)}
>
<div className="flex min-w-0 flex-1 items-center">
@ -41,7 +41,7 @@ export const OptionRow = ({
{shortcutKey && (
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
)}
</div>
</DropdownMenuItem>
)
}

View File

@ -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<TestRunMenuRef, TestRunMenuProps>(({
}), [hasSingleEnabledOption, runSoleOption])
const renderOption = (option: TriggerOption) => {
return <OptionRow option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} />
return <OptionRow key={option.id} option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} />
}
const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options])
@ -141,27 +141,28 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
}
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{ mainAxis: 8, crossAxis: -4 }}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}>
<div style={{ userSelect: 'none' }}>
{children}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-12">
<div className="w-[284px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
<div className="mb-2 px-3 pt-2 text-sm font-medium text-text-primary">
<DropdownMenuTrigger render={<div style={{ userSelect: 'none' }} />}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={8}
alignOffset={-4}
popupClassName="w-[284px] p-1"
>
<DropdownMenuGroup>
<DropdownMenuGroupLabel className="mb-1 px-3 pt-2 text-sm font-medium text-text-primary">
{t('common.chooseStartNodeToRun', { ns: 'workflow' })}
</div>
</DropdownMenuGroupLabel>
<div>
{hasUserInput && renderOption(options.userInput!)}
{(hasTriggers || hasRunAll) && hasUserInput && (
<div className="mx-3 my-1 h-px bg-divider-subtle" />
<DropdownMenuSeparator className="mx-3" />
)}
{hasRunAll && renderOption(options.runAll!)}
@ -170,9 +171,9 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
.filter(trigger => trigger.enabled !== false)
.map(trigger => renderOption(trigger))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
})

View File

@ -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 }) => (
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
className,
disabled,
}: {
children: React.ReactNode
className?: string
disabled?: boolean
}) => {
const { open, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
disabled={disabled}
onClick={() => !disabled && setOpen(!open)}
>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
const { open } = useDropdownMenuContext()
return open ? <div>{children}</div> : null
},
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuGroupLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
describe('assigner/operation-selector', () => {
it('shows numeric write modes and emits the selected operation', async () => {
const user = userEvent.setup()

View File

@ -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<OperationSelectorProps> = ({
const selectedItem = items.find(item => item.value === value)
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => !disabled && setOpen(v => !v)}
<DropdownMenuTrigger
disabled={disabled}
className={cn('flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1', disabled ? 'cursor-not-allowed bg-components-input-bg-disabled!' : 'cursor-pointer hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', className)}
>
<div
className={cn('flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1', disabled ? 'cursor-not-allowed bg-components-input-bg-disabled!' : 'cursor-pointer hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', className)}
>
<div className="flex items-center p-1">
<span
className={`truncate overflow-hidden system-sm-regular text-ellipsis
<div className="flex items-center p-1">
<span
className={`truncate overflow-hidden system-sm-regular text-ellipsis
${selectedItem ? 'text-components-input-text-filled' : 'text-components-input-text-disabled'}`}
>
{selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })}
</span>
</div>
<RiArrowDownSLine className={`h-4 w-4 text-text-quaternary ${disabled && 'text-components-input-text-placeholder'} ${open && 'text-text-secondary'}`} />
>
{selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })}
</span>
</div>
</PortalToFollowElemTrigger>
<RiArrowDownSLine className={`h-4 w-4 text-text-quaternary ${disabled && 'text-components-input-text-placeholder'} ${open && 'text-text-secondary'}`} />
</DropdownMenuTrigger>
<PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
<div className="flex w-[140px] flex-col items-start rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="flex flex-col items-start self-stretch p-1">
<div className="flex items-start self-stretch px-3 pt-1 pb-0.5">
<div className="flex grow system-xs-medium-uppercase text-text-tertiary">{t('nodes.assigner.operations.title', { ns: 'workflow' })}</div>
</div>
{items.map(item => (
!isOperationItem(item)
? (
<Divider key="divider" className="my-1" />
)
: (
<div
key={item.value}
className={cn('flex items-center gap-1 self-stretch rounded-lg px-2 py-1', 'cursor-pointer hover:bg-state-base-hover')}
onClick={() => {
onSelect(item)
setOpen(false)
}}
>
<div className="flex min-h-5 grow items-center gap-1 px-1">
<span className="flex grow system-sm-medium text-text-secondary">{t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}</span>
</div>
{item.value === value && (
<div className="flex items-center justify-center">
<RiCheckLine className="h-4 w-4 text-text-accent" />
</div>
)}
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName={cn('w-[140px]', popupClassName)}
>
<DropdownMenuGroup>
<DropdownMenuGroupLabel>{t('nodes.assigner.operations.title', { ns: 'workflow' })}</DropdownMenuGroupLabel>
{items.map(item => (
!isOperationItem(item)
? (
<DropdownMenuSeparator key="divider" />
)
: (
<DropdownMenuItem
key={item.value}
className="gap-1 px-2 py-1"
onClick={() => onSelect(item)}
>
<div className="flex min-h-5 grow items-center gap-1 px-1">
<span className="flex grow system-sm-medium text-text-secondary">{t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}</span>
</div>
)
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{item.value === value && (
<div className="flex items-center justify-center">
<RiCheckLine className="h-4 w-4 text-text-accent" />
</div>
)}
</DropdownMenuItem>
)
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -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 }) => (
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const { open, setOpen } = useDropdownMenuContext()
return (
<button type="button" className={className} onClick={() => setOpen(!open)}>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
const { open } = useDropdownMenuContext()
return open ? <div>{children}</div> : null
},
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
className?: string
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
DropdownMenuSeparator: ({ className }: { className?: string }) => <div className={className} data-testid="dropdown-separator" />,
}
})
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 }) => (
<div data-testid="image-preview">
<span>{title}</span>
<button type="button" onClick={onCancel}>close-preview</button>
</div>
),
}))
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(<MoreActions />)
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(<MoreActions />)
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(<MoreActions />)
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(<MoreActions />)
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(<MoreActions />)
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(<MoreActions />)
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(<MoreActions />)
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(<MoreActions />)
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(<MoreActions />)
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()
})
})

View File

@ -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 (
<>
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: -200,
crossAxis: crossAxisOffset,
onOpenChange={(nextOpen) => {
if (isReadOnly) {
setOpen(false)
return
}
setOpen(nextOpen)
}}
>
<PortalToFollowElemTrigger>
<DropdownMenuTrigger
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
isReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
>
<TipPopup title={t('common.moreActions', { ns: 'workflow' })}>
<div
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
`${getNodesReadOnly() && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
)}
onClick={handleTrigger}
>
<RiMoreFill className="h-4 w-4" />
</div>
<RiMoreFill className="h-4 w-4" />
</TipPopup>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className="min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg">
<div className="p-1">
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary">
<RiExportLine className="h-3 w-3" />
{t('common.exportImage', { ns: 'workflow' })}
</div>
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
{t('common.currentView', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
onClick={() => handleExportImage('png')}
>
{t('common.exportPNG', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
onClick={() => handleExportImage('jpeg')}
>
{t('common.exportJPEG', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
onClick={() => handleExportImage('svg')}
>
{t('common.exportSVG', { ns: 'workflow' })}
</div>
<div className="border-border-divider mx-2 my-1 border-t" />
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
{t('common.currentWorkflow', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
onClick={() => handleExportImage('png', true)}
>
{t('common.exportPNG', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
onClick={() => handleExportImage('jpeg', true)}
>
{t('common.exportJPEG', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
onClick={() => handleExportImage('svg', true)}
>
{t('common.exportSVG', { ns: 'workflow' })}
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={-200}
alignOffset={crossAxisOffset}
popupClassName="min-w-[180px]"
>
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary">
<RiExportLine className="h-3 w-3" />
{t('common.exportImage', { ns: 'workflow' })}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
{t('common.currentView', { ns: 'workflow' })}
</div>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('png')}>
{t('common.exportPNG', { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('jpeg')}>
{t('common.exportJPEG', { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('svg')}>
{t('common.exportSVG', { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-2" />
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
{t('common.currentWorkflow', { ns: 'workflow' })}
</div>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('png', true)}>
{t('common.exportPNG', { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('jpeg', true)}>
{t('common.exportJPEG', { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('svg', true)}>
{t('common.exportSVG', { ns: 'workflow' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{previewUrl && (
<ImagePreview

View File

@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DropdownMenu, DropdownMenuContent } from '@/app/components/base/ui/dropdown-menu'
import { VersionHistoryContextMenuOptions } from '../../../../types'
import MenuItem from '../menu-item'
@ -9,14 +10,18 @@ describe('MenuItem', () => {
const onClick = vi.fn()
render(
<MenuItem
item={{
key: VersionHistoryContextMenuOptions.delete,
name: 'Delete',
}}
isDestructive
onClick={onClick}
/>,
<DropdownMenu open onOpenChange={vi.fn()}>
<DropdownMenuContent>
<MenuItem
item={{
key: VersionHistoryContextMenuOptions.delete,
name: 'Delete',
}}
isDestructive
onClick={onClick}
/>
</DropdownMenuContent>
</DropdownMenu>,
)
await user.click(screen.getByText('Delete'))

View File

@ -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<ContextMenuProps> = (props: ContextMenuProps) => {
options,
} = useContextMenu(props)
const handleClickTrigger = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
setOpen(v => !v)
}, [setOpen])
return (
<PortalToFollowElem
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger>
<Button size="small" className="px-1" onClick={handleClickTrigger}>
<RiMoreFill className="h-4 w-4" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className="flex w-[184px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
<div className="flex flex-col p-1">
{
options.map((option) => {
return (
<MenuItem
key={option.key}
item={option}
onClick={handleClickMenuItem.bind(null, option.key)}
/>
)
})
}
</div>
{
isShowDelete && (
<>
<Divider type="horizontal" className="my-0 h-px bg-divider-subtle" />
<div className="p-1">
<MenuItem
item={deleteOperation}
isDestructive
onClick={handleClickMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)}
/>
</div>
</>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<DropdownMenuTrigger
render={<Button size="small" className="px-1" onClick={e => e.stopPropagation()} />}
>
<RiMoreFill className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[184px] shadow-shadow-shadow-5"
>
{
options.map(option => (
<MenuItem
key={option.key}
item={option}
onClick={handleClickMenuItem.bind(null, option.key)}
/>
))
}
{
isShowDelete && (
<>
<DropdownMenuSeparator className="my-0" />
<MenuItem
item={deleteOperation}
isDestructive
onClick={handleClickMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)}
/>
</>
)
}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -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<MenuItemProps> = ({
isDestructive = false,
}) => {
return (
<div
<DropdownMenuItem
className={cn(
'flex cursor-pointer items-center justify-between rounded-lg px-2 py-1.5',
isDestructive ? 'hover:bg-state-destructive-hover' : 'hover:bg-state-base-hover',
'justify-between px-2 py-1.5',
isDestructive && 'data-highlighted:bg-state-destructive-hover',
)}
onClick={() => {
destructive={isDestructive}
onClick={(event) => {
event.stopPropagation()
onClick(item.key)
}}
>
<div className={cn(
'flex-1 system-md-regular text-text-primary',
isDestructive && 'hover:text-text-destructive',
isDestructive && 'text-inherit',
)}
>
{item.name}
</div>
</div>
</DropdownMenuItem>
)
}

View File

@ -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 }) => (
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, render }: { children: React.ReactNode, render?: React.ReactElement }) => {
const { open, setOpen } = useDropdownMenuContext()
if (render)
return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record<string, unknown>, children)
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
const { open } = useDropdownMenuContext()
return open ? <div>{children}</div> : null
},
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',

View File

@ -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 (
<PortalToFollowElem
placement="bottom-start"
offset={{
mainAxis: 2,
crossAxis: -54,
}}
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
className="h-6 w-6"
variant="ghost-accent"
>
<RiMoreLine className="h-4 w-4" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<div className="w-[136px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
options.map(option => (
<div
key={option.message_id}
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular text-text-secondary hover:bg-state-base-hover"
onClick={() => {
onShowAgentOrToolLog(option)
setOpen(false)
}}
>
{option.label}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<DropdownMenuTrigger
render={(
<Button
className="h-6 w-6"
variant="ghost-accent"
/>
)}
>
<RiMoreLine className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={2}
alignOffset={-54}
popupClassName="w-[136px] p-1"
>
{
options.map(option => (
<DropdownMenuItem
key={option.message_id}
className="system-md-regular"
onClick={() => onShowAgentOrToolLog(option)}
>
{option.label}
</DropdownMenuItem>
))
}
</DropdownMenuContent>
</DropdownMenu>
)
}