mirror of
https://github.com/langgenius/dify.git
synced 2026-04-24 04:45:51 +08:00
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:
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>,
|
||||
}))
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user