refactor(web): replace Dropdown component with DropdownMenu in various components

- Updated DebugItem, MobileOperationDropdown, and Operation components to utilize DropdownMenu for improved dropdown functionality.
- Removed deprecated Dropdown component references and adjusted related tests accordingly.
- Enhanced user interactions and accessibility across dropdown menus.
This commit is contained in:
CodingOnStar
2026-04-16 16:40:26 +08:00
parent a13996dba1
commit 79d87e6000
27 changed files with 835 additions and 1018 deletions

View File

@ -137,14 +137,6 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({
},
}))
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 data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('Dropdown callback coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -159,7 +151,7 @@ describe('Dropdown callback coverage', () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.edit'))
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
@ -175,7 +167,7 @@ describe('Dropdown callback coverage', () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.edit'))
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
@ -190,7 +182,7 @@ describe('Dropdown callback coverage', () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {
@ -210,7 +202,7 @@ describe('Dropdown callback coverage', () => {
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {
@ -224,7 +216,7 @@ describe('Dropdown callback coverage', () => {
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
await waitFor(() => {
@ -232,6 +224,27 @@ describe('Dropdown callback coverage', () => {
})
})
it('should not attempt export when the dataset has no pipeline id', async () => {
const user = userEvent.setup()
mockDataset = createDataset({ pipeline_id: '' })
render(<Dropdown expand={false} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
expect(mockExportPipeline).not.toHaveBeenCalled()
})
it('should render and open correctly when collapsed', async () => {
const user = userEvent.setup()
render(<Dropdown expand={false} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
})
it('should surface the backend message when checking app usage fails', async () => {
const user = userEvent.setup()
mockCheckIsUsedInApp.mockRejectedValueOnce({
@ -240,7 +253,7 @@ describe('Dropdown callback coverage', () => {
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {

View File

@ -1,5 +1,4 @@
import type { DataSet } from '@/models/datasets'
import { RiEditLine } from '@remixicon/react'
import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -22,6 +21,7 @@ const mockInvalidDatasetDetail = vi.fn()
const mockExportPipeline = vi.fn()
const mockCheckIsUsedInApp = vi.fn()
const mockDeleteDataset = vi.fn()
const TestEditIcon = () => <span aria-hidden className="i-ri-edit-line" />
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
@ -210,7 +210,7 @@ describe('MenuItem', () => {
const user = userEvent.setup()
const handleClick = vi.fn()
// Arrange
render(<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />)
render(<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />)
// Act
await user.click(screen.getByText('Edit'))
@ -225,7 +225,7 @@ describe('MenuItem', () => {
render(
<div onClick={parentClick}>
<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />
<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />
</div>,
)
@ -236,7 +236,7 @@ describe('MenuItem', () => {
})
it('should not crash when no click handler is provided', () => {
render(<MenuItem name="Edit" Icon={RiEditLine} />)
render(<MenuItem name="Edit" Icon={TestEditIcon} />)
const event = createEvent.click(screen.getByText('Edit'))
fireEvent(screen.getByText('Edit'), event)

View File

@ -1,6 +1,5 @@
import type { DataSet } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -14,7 +13,6 @@ import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { downloadBlob } from '@/utils/download'
import ActionButton from '../../base/action-button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import {
AlertDialog,
AlertDialogActions,
@ -24,6 +22,11 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '../../base/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '../../base/ui/dropdown-menu'
import RenameDatasetModal from '../../datasets/rename-modal'
import Menu from './menu'
@ -44,10 +47,6 @@ const DropDown = ({
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const invalidDatasetList = useInvalidDatasetList()
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
@ -57,9 +56,11 @@ const DropDown = ({
}, [invalidDatasetDetail, invalidDatasetList])
const openRenameModal = useCallback(() => {
setShowRenameModal(true)
handleTrigger()
}, [handleTrigger])
setOpen(false)
queueMicrotask(() => {
setShowRenameModal(true)
})
}, [])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
@ -67,7 +68,7 @@ const DropDown = ({
const { pipeline_id, name } = dataset
if (!pipeline_id)
return
handleTrigger()
setOpen(false)
try {
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
@ -79,9 +80,10 @@ const DropDown = ({
catch {
toast(t('exportFailed', { ns: 'app' }), { type: 'error' })
}
}, [dataset, exportPipelineConfig, handleTrigger, t])
}, [dataset, exportPipelineConfig, t])
const detectIsUsedByApp = useCallback(async () => {
setOpen(false)
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('datasetUsedByApp', { ns: 'dataset' })! : t('deleteDatasetConfirmContent', { ns: 'dataset' })!)
@ -91,10 +93,7 @@ const DropDown = ({
const res = await e.json()
toast(res?.message || 'Unknown error', { type: 'error' })
}
finally {
handleTrigger()
}
}, [dataset.id, handleTrigger, t])
}, [dataset.id, t])
const onConfirmDelete = useCallback(async () => {
try {
@ -109,32 +108,27 @@ const DropDown = ({
}, [dataset.id, replace, invalidDatasetList, t])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement={expand ? 'bottom-end' : 'right'}
offset={expand
? {
mainAxis: 4,
crossAxis: 10,
}
: {
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md')}>
<RiMoreFill className="size-4" />
<DropdownMenuTrigger render={<div />}>
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md', open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-more-fill size-4" />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-60">
</DropdownMenuTrigger>
<DropdownMenuContent
placement={expand ? 'bottom-end' : 'right-start'}
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<Menu
showDelete={!isCurrentWorkspaceDatasetOperator}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
</PortalToFollowElemContent>
</DropdownMenuContent>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
@ -163,7 +157,7 @@ const DropDown = ({
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</PortalToFollowElem>
</DropdownMenu>
)
}

View File

@ -1,7 +1,7 @@
import type { CSSProperties } from 'react'
import type { ModelAndParameter } from '../../types'
import type { Item } from '@/app/components/base/dropdown'
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { AppModeEnum } from '@/types/app'
import DebugItem from '../debug-item'
@ -10,12 +10,6 @@ const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockUseProviderContext = vi.fn()
let capturedDropdownProps: {
onSelect: (item: Item) => void
items: Item[]
secondItems?: Item[]
} | null = null
let capturedModelParameterTriggerProps: {
modelAndParameter: ModelAndParameter
} | null = null
@ -51,34 +45,6 @@ vi.mock('../model-parameter-trigger', () => ({
},
}))
vi.mock('@/app/components/base/dropdown', () => ({
default: (props: { onSelect: (item: Item) => void, items: Item[], secondItems?: Item[] }) => {
capturedDropdownProps = props
return (
<div data-testid="dropdown">
{props.items.map(item => (
<button
key={item.value}
data-testid={`dropdown-item-${item.value}`}
onClick={() => props.onSelect(item)}
>
{item.text}
</button>
))}
{props.secondItems?.map(item => (
<button
key={item.value}
data-testid={`dropdown-second-item-${item.value}`}
onClick={() => props.onSelect(item)}
>
{item.text}
</button>
))}
</div>
)
},
}))
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: 'model-1',
model: 'gpt-3.5-turbo',
@ -117,7 +83,6 @@ const renderComponent = (props: Partial<DebugItemProps> = {}) => {
describe('DebugItem', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedDropdownProps = null
capturedModelParameterTriggerProps = null
mockUseDebugConfigurationContext.mockReturnValue({
@ -137,12 +102,18 @@ describe('DebugItem', () => {
})
})
const openMenu = async () => {
const user = userEvent.setup()
await user.click(screen.getByRole('button'))
return user
}
describe('rendering', () => {
it('should render with basic props', () => {
renderComponent()
expect(screen.getByTestId('model-parameter-trigger')).toBeInTheDocument()
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should display correct index number', () => {
@ -280,7 +251,7 @@ describe('DebugItem', () => {
})
describe('dropdown menu', () => {
it('should show duplicate option when less than 4 models', () => {
it('should show duplicate option when less than 4 models', async () => {
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
@ -288,13 +259,12 @@ describe('DebugItem', () => {
})
renderComponent()
await openMenu()
expect(capturedDropdownProps?.items).toContainEqual(
expect.objectContaining({ value: 'duplicate' }),
)
expect(screen.getByText('appDebug.duplicateModel')).toBeInTheDocument()
})
it('should hide duplicate option when 4 or more models', () => {
it('should hide duplicate option when 4 or more models', async () => {
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
createModelAndParameter({ id: '1' }),
@ -307,52 +277,48 @@ describe('DebugItem', () => {
})
renderComponent()
await openMenu()
expect(capturedDropdownProps?.items).not.toContainEqual(
expect.objectContaining({ value: 'duplicate' }),
)
expect(screen.queryByText('appDebug.duplicateModel')).not.toBeInTheDocument()
})
it('should show debug-as-single-model option when provider and model are set', () => {
it('should show debug-as-single-model option when provider and model are set', async () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: 'openai',
model: 'gpt-3.5-turbo',
}),
})
await openMenu()
expect(capturedDropdownProps?.items).toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
expect(screen.getByText('appDebug.debugAsSingleModel')).toBeInTheDocument()
})
it('should hide debug-as-single-model option when provider is missing', () => {
it('should hide debug-as-single-model option when provider is missing', async () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: '',
model: 'gpt-3.5-turbo',
}),
})
await openMenu()
expect(capturedDropdownProps?.items).not.toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
expect(screen.queryByText('appDebug.debugAsSingleModel')).not.toBeInTheDocument()
})
it('should hide debug-as-single-model option when model is missing', () => {
it('should hide debug-as-single-model option when model is missing', async () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: 'openai',
model: '',
}),
})
await openMenu()
expect(capturedDropdownProps?.items).not.toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
expect(screen.queryByText('appDebug.debugAsSingleModel')).not.toBeInTheDocument()
})
it('should show remove option in secondItems when more than 2 models', () => {
it('should show remove option in secondItems when more than 2 models', async () => {
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
createModelAndParameter({ id: '1' }),
@ -364,13 +330,12 @@ describe('DebugItem', () => {
})
renderComponent()
await openMenu()
expect(capturedDropdownProps?.secondItems).toContainEqual(
expect.objectContaining({ value: 'remove' }),
)
expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
})
it('should not show remove option when 2 or fewer models', () => {
it('should not show remove option when 2 or fewer models', async () => {
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
createModelAndParameter({ id: '1' }),
@ -381,13 +346,14 @@ describe('DebugItem', () => {
})
renderComponent()
await openMenu()
expect(capturedDropdownProps?.secondItems).toBeUndefined()
expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
})
})
describe('dropdown actions', () => {
it('should duplicate model when duplicate is selected', () => {
it('should duplicate model when duplicate is selected', async () => {
const onMultipleModelConfigsChange = vi.fn()
const originalModel = createModelAndParameter({ id: 'original' })
@ -399,7 +365,8 @@ describe('DebugItem', () => {
renderComponent({ modelAndParameter: originalModel })
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
const user = await openMenu()
await user.click(screen.getByText('appDebug.duplicateModel'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
@ -414,7 +381,7 @@ describe('DebugItem', () => {
)
})
it('should not duplicate when already at 4 models', () => {
it('should not duplicate when already at 4 models', async () => {
const onMultipleModelConfigsChange = vi.fn()
const models = [
createModelAndParameter({ id: '1' }),
@ -430,14 +397,13 @@ describe('DebugItem', () => {
})
renderComponent({ modelAndParameter: models[0] })
// Since duplicate is not shown when >= 4 models, we need to manually call handleSelect
capturedDropdownProps?.onSelect({ value: 'duplicate', text: 'Duplicate' })
await openMenu()
expect(onMultipleModelConfigsChange).not.toHaveBeenCalled()
expect(screen.queryByText('appDebug.duplicateModel')).not.toBeInTheDocument()
})
it('should call onDebugWithMultipleModelChange when debug-as-single-model is selected', () => {
it('should call onDebugWithMultipleModelChange when debug-as-single-model is selected', async () => {
const onDebugWithMultipleModelChange = vi.fn()
const modelAndParameter = createModelAndParameter()
@ -449,12 +415,13 @@ describe('DebugItem', () => {
renderComponent({ modelAndParameter })
fireEvent.click(screen.getByTestId('dropdown-item-debug-as-single-model'))
const user = await openMenu()
await user.click(screen.getByText('appDebug.debugAsSingleModel'))
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
})
it('should remove model when remove is selected', () => {
it('should remove model when remove is selected', async () => {
const onMultipleModelConfigsChange = vi.fn()
const models = [
createModelAndParameter({ id: '1' }),
@ -470,7 +437,8 @@ describe('DebugItem', () => {
renderComponent({ modelAndParameter: models[1] })
fireEvent.click(screen.getByTestId('dropdown-second-item-remove'))
const user = await openMenu()
await user.click(screen.getByText('common.operation.remove'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
@ -478,7 +446,7 @@ describe('DebugItem', () => {
)
})
it('should insert duplicated model at correct position', () => {
it('should insert duplicated model at correct position', async () => {
const onMultipleModelConfigsChange = vi.fn()
const models = [
createModelAndParameter({ id: '1' }),
@ -495,7 +463,8 @@ describe('DebugItem', () => {
// Duplicate the second model
renderComponent({ modelAndParameter: models[1] })
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
const user = await openMenu()
await user.click(screen.getByText('appDebug.duplicateModel'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,

View File

@ -1,9 +1,15 @@
import type { CSSProperties, FC } from 'react'
import type { ModelAndParameter } from '../types'
import type { Item } from '@/app/components/base/dropdown'
import { memo } from 'react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Dropdown from '@/app/components/base/dropdown'
import ActionButton from '@/app/components/base/action-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useProviderContext } from '@/context/provider-context'
@ -35,34 +41,43 @@ const DebugItem: FC<DebugItemProps> = ({
const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id)
const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider)
const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model)
const [open, setOpen] = useState(false)
const handleSelect = (item: Item) => {
if (item.value === 'duplicate') {
if (multipleModelConfigs.length >= 4)
return
const handleDuplicate = () => {
setOpen(false)
if (multipleModelConfigs.length >= 4)
return
onMultipleModelConfigsChange(
true,
[
...multipleModelConfigs.slice(0, index + 1),
{
...modelAndParameter,
id: `${Date.now()}`,
},
...multipleModelConfigs.slice(index + 1),
],
)
}
if (item.value === 'debug-as-single-model')
onDebugWithMultipleModelChange(modelAndParameter)
if (item.value === 'remove') {
onMultipleModelConfigsChange(
true,
multipleModelConfigs.filter(item => item.id !== modelAndParameter.id),
)
}
onMultipleModelConfigsChange(
true,
[
...multipleModelConfigs.slice(0, index + 1),
{
...modelAndParameter,
id: `${Date.now()}`,
},
...multipleModelConfigs.slice(index + 1),
],
)
}
const handleDebugAsSingleModel = () => {
setOpen(false)
onDebugWithMultipleModelChange(modelAndParameter)
}
const handleRemove = () => {
setOpen(false)
onMultipleModelConfigsChange(
true,
multipleModelConfigs.filter(item => item.id !== modelAndParameter.id),
)
}
const showDuplicate = multipleModelConfigs.length <= 3
const showDebugAsSingleModel = !!(modelAndParameter.provider && modelAndParameter.model)
const showRemove = multipleModelConfigs.length > 2
return (
<div
className={`flex min-w-[320px] flex-col rounded-xl bg-background-section-burn ${className}`}
@ -76,41 +91,37 @@ const DebugItem: FC<DebugItemProps> = ({
<ModelParameterTrigger
modelAndParameter={modelAndParameter}
/>
<Dropdown
onSelect={handleSelect}
items={[
...(
multipleModelConfigs.length <= 3
? [
{
value: 'duplicate',
text: t('duplicateModel', { ns: 'appDebug' }),
},
]
: []
),
...(
(modelAndParameter.provider && modelAndParameter.model)
? [
{
value: 'debug-as-single-model',
text: t('debugAsSingleModel', { ns: 'appDebug' }),
},
]
: []
),
]}
secondItems={
multipleModelConfigs.length > 2
? [
{
value: 'remove',
text: t('operation.remove', { ns: 'common' }) as string,
},
]
: undefined
}
/>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger render={<div />}>
<ActionButton className={open ? 'bg-state-base-hover' : ''}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[160px]"
>
{showDuplicate && (
<DropdownMenuItem className="system-md-regular" onClick={handleDuplicate}>
{t('duplicateModel', { ns: 'appDebug' })}
</DropdownMenuItem>
)}
{showDebugAsSingleModel && (
<DropdownMenuItem className="system-md-regular" onClick={handleDebugAsSingleModel}>
{t('debugAsSingleModel', { ns: 'appDebug' })}
</DropdownMenuItem>
)}
{showRemove && (
<>
{(showDuplicate || showDebugAsSingleModel) && <DropdownMenuSeparator />}
<DropdownMenuItem destructive className="system-md-regular" onClick={handleRemove}>
{t('operation.remove', { ns: 'common' })}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div style={{ height: 'calc(100% - 40px)' }}>
{

View File

@ -30,7 +30,7 @@ const actionButtonVariants = cva(
},
)
export type ActionButtonProps = {
type ActionButtonProps = {
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
state?: ActionButtonState
styleCss?: CSSProperties
@ -73,4 +73,4 @@ const ActionButton = ({ className, size, state = ActionButtonState.Default, styl
ActionButton.displayName = 'ActionButton'
export default ActionButton
export { ActionButton, ActionButtonState, actionButtonVariants }
export { ActionButton, ActionButtonState }

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -53,11 +53,16 @@ describe('MobileOperationDropdown Component', () => {
// Reset Chat
await user.click(screen.getByText('share.chat.resetChat'))
expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1)
})
await user.click(screen.getByRole('button'))
// View Chat Settings
await user.click(screen.getByText('share.chat.viewChatSettings'))
expect(defaultProps.handleViewChatSettings).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(defaultProps.handleViewChatSettings).toHaveBeenCalledTimes(1)
})
})
it('applies hover state to ActionButton when open', async () => {
@ -72,4 +77,16 @@ describe('MobileOperationDropdown Component', () => {
await user.click(trigger)
expect(trigger).toHaveClass('action-btn-hover')
})
it('closes the menu after clicking an action', async () => {
const user = userEvent.setup()
render(<MobileOperationDropdown {...defaultProps} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('share.chat.resetChat'))
await waitFor(() => {
expect(screen.queryByText('share.chat.resetChat')).not.toBeInTheDocument()
})
})
})

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -74,12 +74,18 @@ describe('Operation Component', () => {
expect(defaultProps.togglePin).toHaveBeenCalledTimes(1)
// Rename
await user.click(screen.getByText('Chat Title'))
await user.click(screen.getByText('explore.sidebar.action.rename'))
expect(defaultProps.onRenameConversation).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(defaultProps.onRenameConversation).toHaveBeenCalledTimes(1)
})
// Delete
await user.click(screen.getByText('Chat Title'))
await user.click(screen.getByText('explore.sidebar.action.delete'))
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
})
})
it('applies hover background when open', async () => {

View File

@ -1,7 +1,12 @@
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
type Props = {
handleResetChat: () => void
@ -16,40 +21,45 @@ const MobileOperationDropdown = ({
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleMenuAction = useCallback((callback: () => void) => {
setOpen(false)
queueMicrotask(callback)
}, [])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
<DropdownMenuTrigger
render={<div />}
data-testid="mobile-more-btn"
>
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<div className="i-ri-more-fill h-[18px] w-[18px]" />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-40">
<div
className="min-w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[160px]"
>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleMenuAction(handleResetChat)}
>
<div className="flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover" onClick={handleResetChat}>
<span className="grow">{t('chat.resetChat', { ns: 'share' })}</span>
</div>
{!hideViewChatSettings && (
<div className="flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover" onClick={handleViewChatSettings}>
<span className="grow">{t('chat.viewChatSettings', { ns: 'share' })}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<span className="grow">{t('chat.resetChat', { ns: 'share' })}</span>
</DropdownMenuItem>
{!hideViewChatSettings && (
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleMenuAction(handleViewChatSettings)}
>
<span className="grow">{t('chat.viewChatSettings', { ns: 'share' })}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,14 +1,16 @@
'use client'
import type { Placement } from '@floating-ui/react'
import type { FC } from 'react'
import type { Placement } from '@/app/components/base/ui/placement'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import { useCallback, 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'
type Props = {
title: string
@ -33,42 +35,51 @@ const Operation: FC<Props> = ({
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleDeferredAction = useCallback((action: () => void) => {
setOpen(false)
queueMicrotask(action)
}, [])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement={placement}
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
<DropdownMenuTrigger
render={<div />}
>
<div className={cn('flex cursor-pointer items-center rounded-lg p-1.5 pl-2 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<div className="system-md-semibold">{title}</div>
<RiArrowDownSLine className="h-4 w-4" />
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div
className="min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
>
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</div>
{isShowRenameConversation && (
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</div>
)}
{isShowDelete && (
<div className={cn('group flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete}>
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuTrigger>
<DropdownMenuContent
placement={placement}
sideOffset={4}
popupClassName="min-w-[120px]"
>
<DropdownMenuItem className="system-md-regular" onClick={togglePin}>
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</DropdownMenuItem>
{isShowRenameConversation && (
<DropdownMenuItem
className="system-md-regular"
onClick={() => onRenameConversation && handleDeferredAction(onRenameConversation)}
>
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</DropdownMenuItem>
)}
{isShowDelete && (
<DropdownMenuItem
destructive
className="system-md-regular"
onClick={() => handleDeferredAction(onDelete)}
>
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(Operation)

View File

@ -1,16 +1,9 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Operation from '../operation'
// Mock PortalToFollowElem components to render children in place
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => <div data-open={open}>{children}</div>,
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => <div onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
}))
describe('Operation', () => {
const defaultProps = {
isActive: false,
@ -72,7 +65,9 @@ describe('Operation', () => {
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('explore.sidebar.action.rename'))
expect(defaultProps.onRenameConversation).toHaveBeenCalled()
await waitFor(() => {
expect(defaultProps.onRenameConversation).toHaveBeenCalled()
})
})
it('should call onDelete when delete is clicked', async () => {
@ -82,7 +77,9 @@ describe('Operation', () => {
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('explore.sidebar.action.delete'))
expect(defaultProps.onDelete).toHaveBeenCalled()
await waitFor(() => {
expect(defaultProps.onDelete).toHaveBeenCalled()
})
})
it('should respect visibility props', async () => {
@ -108,8 +105,7 @@ describe('Operation', () => {
await user.click(screen.getByRole('button'))
const portalContent = screen.getByTestId('portal-content')
expect(portalContent).toBeInTheDocument()
expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument()
})
it('should close dropdown when item hovering stops', async () => {
@ -120,5 +116,60 @@ describe('Operation', () => {
expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument()
rerender(<Operation {...defaultProps} isItemHovering={false} />)
await waitFor(() => {
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
})
})
it('should keep the trigger mounted while visually hidden', () => {
render(<Operation {...defaultProps} isItemHovering={false} />)
const trigger = screen.getByRole('button')
expect(trigger).toHaveClass('pointer-events-none')
expect(trigger).toHaveClass('opacity-0')
})
it('should safely ignore rename clicks when callback is missing', async () => {
const user = userEvent.setup()
render(<Operation {...defaultProps} onRenameConversation={undefined} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('explore.sidebar.action.rename'))
await waitFor(() => {
expect(screen.queryByText('explore.sidebar.action.rename')).not.toBeInTheDocument()
})
})
it('should not bubble trigger clicks to the parent container', async () => {
const user = userEvent.setup()
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Operation {...defaultProps} />
</div>,
)
await user.click(screen.getByRole('button'))
expect(parentClick).not.toHaveBeenCalled()
})
it('should not bubble popup clicks to the parent container', async () => {
const user = userEvent.setup()
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Operation {...defaultProps} isItemHovering={true} />
</div>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('menu'))
expect(parentClick).not.toHaveBeenCalled()
})
})

View File

@ -1,19 +1,17 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiDeleteBinLine,
RiEditLine,
RiMoreFill,
RiPushpinLine,
RiUnpinLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
type Props = {
isActive?: boolean
@ -38,24 +36,29 @@ const Operation: FC<Props> = ({
}) => {
const { t } = useTranslation()
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])
const handleDeferredAction = useCallback((action?: () => void) => {
if (!action)
return
setOpen(false)
queueMicrotask(action)
}, [])
return (
<PortalToFollowElem
<DropdownMenu
modal={false}
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
<DropdownMenuTrigger
render={<div />}
onClick={e => e.stopPropagation()}
>
<ActionButton
className={cn((isItemHovering || open) ? 'opacity-100' : 'opacity-0')}
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
state={
isActive
? ActionButtonState.Active
@ -64,39 +67,57 @@ const Operation: FC<Props> = ({
: ActionButtonState.Default
}
>
<RiMoreFill className="h-4 w-4" />
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div
ref={ref}
className="min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
onMouseEnter={setIsHovering}
onMouseLeave={setNotHovering}
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[120px]"
popupProps={{
onMouseEnter: setIsHovering,
onMouseLeave: setNotHovering,
onClick: e => e.stopPropagation(),
}}
>
<DropdownMenuItem
className="gap-2 px-2 system-md-regular"
onClick={(e) => {
e.stopPropagation()
togglePin()
}}
>
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
{isPinned && <RiUnpinLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
{!isPinned && <RiPushpinLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</div>
{isShowRenameConversation && (
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
<RiEditLine className="h-4 w-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</div>
)}
{isShowDelete && (
<div className={cn('group flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete}>
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover:text-text-destructive')} />
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{isPinned && <span aria-hidden className="i-ri-unpin-line h-4 w-4 shrink-0 text-text-tertiary" />}
{!isPinned && <span aria-hidden className="i-ri-pushpin-line h-4 w-4 shrink-0 text-text-tertiary" />}
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</DropdownMenuItem>
{isShowRenameConversation && (
<DropdownMenuItem
className="gap-2 px-2 system-md-regular"
onClick={(e) => {
e.stopPropagation()
handleDeferredAction(onRenameConversation)
}}
>
<span aria-hidden className="i-ri-edit-line h-4 w-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</DropdownMenuItem>
)}
{isShowDelete && (
<DropdownMenuItem
destructive
className="gap-2 px-2 system-md-regular"
onClick={(e) => {
e.stopPropagation()
handleDeferredAction(onDelete)
}}
>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4 shrink-0" />
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(Operation)

View File

@ -1,225 +0,0 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import Dropdown from '../index'
describe('Dropdown Component', () => {
const mockItems = [
{ value: 'option1', text: 'Option 1' },
{ value: 'option2', text: 'Option 2' },
]
const mockSecondItems = [
{ value: 'option3', text: 'Option 3' },
]
const onSelect = vi.fn()
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders default trigger properly', () => {
const { container } = render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = container.querySelector('button')
expect(trigger).toBeInTheDocument()
})
it('renders custom trigger when provided', () => {
render(
<Dropdown
items={mockItems}
onSelect={onSelect}
renderTrigger={open => <button data-testid="custom-trigger">{open ? 'Open' : 'Closed'}</button>}
/>,
)
const trigger = screen.getByTestId('custom-trigger')
expect(trigger).toBeInTheDocument()
expect(trigger).toHaveTextContent('Closed')
})
it('opens dropdown menu on trigger click and shows items', async () => {
render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
// Dropdown items are rendered in a portal (document.body)
expect(screen.getByText('Option 1')).toBeInTheDocument()
expect(screen.getByText('Option 2')).toBeInTheDocument()
})
it('calls onSelect and closes dropdown when an item is clicked', async () => {
render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
const option1 = screen.getByText('Option 1')
await act(async () => {
fireEvent.click(option1)
})
expect(onSelect).toHaveBeenCalledWith(mockItems[0])
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
})
it('calls onSelect and closes dropdown when a second item is clicked', async () => {
render(
<Dropdown items={mockItems} secondItems={mockSecondItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const option3 = screen.getByText('Option 3')
await act(async () => {
fireEvent.click(option3)
})
expect(onSelect).toHaveBeenCalledWith(mockSecondItems[0])
expect(screen.queryByText('Option 3')).not.toBeInTheDocument()
})
it('renders second items and divider when provided', async () => {
render(
<Dropdown
items={mockItems}
secondItems={mockSecondItems}
onSelect={onSelect}
/>,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
expect(screen.getByText('Option 1')).toBeInTheDocument()
expect(screen.getByText('Option 3')).toBeInTheDocument()
// Check for divider (h-px bg-divider-regular)
const divider = document.body.querySelector('.bg-divider-regular.h-px')
expect(divider).toBeInTheDocument()
})
it('applies custom classNames', async () => {
const popupClass = 'custom-popup'
const itemClass = 'custom-item'
const secondItemClass = 'custom-second-item'
render(
<Dropdown
items={mockItems}
secondItems={mockSecondItems}
onSelect={onSelect}
popupClassName={popupClass}
itemClassName={itemClass}
secondItemClassName={secondItemClass}
/>,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const popup = document.body.querySelector(`.${popupClass}`)
expect(popup).toBeInTheDocument()
const items = screen.getAllByText('Option 1')
expect(items[0]).toHaveClass(itemClass)
const secondItems = screen.getAllByText('Option 3')
expect(secondItems[0]).toHaveClass(secondItemClass)
})
it('applies open class to trigger when menu is open', async () => {
render(<Dropdown items={mockItems} onSelect={onSelect} />)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
expect(trigger).toHaveClass('bg-divider-regular')
})
it('handles JSX elements as item text', async () => {
const itemsWithJSX = [
{ value: 'jsx', text: <span data-testid="jsx-item">JSX Content</span> },
]
render(
<Dropdown items={itemsWithJSX} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
expect(screen.getByTestId('jsx-item')).toBeInTheDocument()
expect(screen.getByText('JSX Content')).toBeInTheDocument()
})
it('does not render items section if items list is empty', async () => {
render(
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const p1Divs = document.body.querySelectorAll('.p-1')
expect(p1Divs.length).toBe(1)
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
expect(screen.getByText('Option 3')).toBeInTheDocument()
})
it('does not render divider if only one section is provided', async () => {
const { rerender } = render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
await act(async () => {
rerender(
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
)
})
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
})
it('renders nothing if both item lists are empty', async () => {
render(<Dropdown items={[]} secondItems={[]} onSelect={onSelect} />)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const popup = document.body.querySelector('.bg-components-panel-bg')
expect(popup?.children.length).toBe(0)
})
it('passes triggerProps to ActionButton and applies custom className', () => {
render(
<Dropdown
items={mockItems}
onSelect={onSelect}
triggerProps={{
'disabled': true,
'aria-label': 'dropdown-trigger',
'className': 'custom-trigger-class',
}}
/>,
)
const trigger = screen.getByLabelText('dropdown-trigger')
expect(trigger).toBeDisabled()
expect(trigger).toHaveClass('custom-trigger-class')
})
})

View File

@ -1,88 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { Item } from '.'
import { useState } from 'react'
import { fn } from 'storybook/test'
import Dropdown from '.'
const PRIMARY_ITEMS: Item[] = [
{ value: 'rename', text: 'Rename' },
{ value: 'duplicate', text: 'Duplicate' },
]
const SECONDARY_ITEMS: Item[] = [
{ value: 'archive', text: <span className="text-text-destructive">Archive</span> },
{ value: 'delete', text: <span className="text-text-destructive">Delete</span> },
]
const meta = {
title: 'Base/Navigation/Dropdown',
component: Dropdown,
parameters: {
docs: {
description: {
component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.',
},
},
},
tags: ['autodocs'],
args: {
items: PRIMARY_ITEMS,
secondItems: SECONDARY_ITEMS,
},
} satisfies Meta<typeof Dropdown>
export default meta
type Story = StoryObj<typeof meta>
const DropdownDemo = (props: React.ComponentProps<typeof Dropdown>) => {
const [lastAction, setLastAction] = useState<string>('None')
return (
<div className="flex h-[200px] flex-col items-center justify-center gap-4">
<Dropdown
{...props}
onSelect={(item) => {
setLastAction(String(item.value))
props.onSelect?.(item)
}}
/>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-secondary">
Last action:
{' '}
<span className="font-mono text-text-primary">{lastAction}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: args => <DropdownDemo {...args} />,
args: {
items: PRIMARY_ITEMS,
secondItems: SECONDARY_ITEMS,
onSelect: fn(),
},
}
export const CustomTrigger: Story = {
render: args => (
<DropdownDemo
{...args}
renderTrigger={open => (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-state-base-hover-alt"
>
Actions
<span className={`transition-transform ${open ? 'rotate-180' : ''}`}>
</span>
</button>
)}
/>
),
args: {
items: PRIMARY_ITEMS,
onSelect: fn(),
},
}

View File

@ -1,122 +0,0 @@
import type { FC } from 'react'
import type { ActionButtonProps } from '@/app/components/base/action-button'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiMoreFill,
} from '@remixicon/react'
import { useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
export type Item = {
value: string | number
text: string | React.JSX.Element
}
type DropdownProps = {
items: Item[]
secondItems?: Item[]
onSelect: (item: Item) => void
renderTrigger?: (open: boolean) => React.ReactNode
triggerProps?: ActionButtonProps
popupClassName?: string
itemClassName?: string
secondItemClassName?: string
}
const Dropdown: FC<DropdownProps> = ({
items,
onSelect,
secondItems,
renderTrigger,
triggerProps,
popupClassName,
itemClassName,
secondItemClassName,
}) => {
const [open, setOpen] = useState(false)
const handleSelect = (item: Item) => {
setOpen(false)
onSelect(item)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
{
renderTrigger
? renderTrigger(open)
: (
<ActionButton
{...triggerProps}
className={cn(
open && 'bg-divider-regular',
triggerProps?.className,
)}
>
<RiMoreFill className="h-4 w-4 text-text-tertiary" />
</ActionButton>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={popupClassName}>
<div className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg text-sm text-text-secondary shadow-lg">
{
!!items.length && (
<div className="p-1">
{
items.map(item => (
<div
key={item.value}
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
itemClassName,
)}
onClick={() => handleSelect(item)}
>
{item.text}
</div>
))
}
</div>
)
}
{
(!!items.length && !!secondItems?.length) && (
<div className="h-px bg-divider-regular" />
)
}
{
!!secondItems?.length && (
<div className="p-1">
{
secondItems.map(item => (
<div
key={item.value}
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
secondItemClassName,
)}
onClick={() => handleSelect(item)}
>
{item.text}
</div>
))
}
</div>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Dropdown

View File

@ -31,10 +31,10 @@ describe('Dropdown', () => {
const { container } = render(<Dropdown {...props} />)
// Assert - Button should have RiMoreFill icon (rendered as svg)
// Assert - Button should have the more icon
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.querySelector('.i-ri-more-fill')).toBeInTheDocument()
})
it('should render separator after dropdown', () => {

View File

@ -1,12 +1,11 @@
import { cn } from '@langgenius/dify-ui/cn'
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import Menu from './menu'
type DropdownProps = {
@ -22,26 +21,17 @@ const Dropdown = ({
}: DropdownProps) => {
const [open, setOpen] = useState(false)
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const handleBreadCrumbClick = useCallback((index: number) => {
onBreadcrumbClick(index)
setOpen(false)
}, [onBreadcrumbClick])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -13,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<DropdownMenuTrigger render={<div />}>
<button
type="button"
className={cn(
@ -49,18 +39,22 @@ const Dropdown = ({
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<RiMoreFill className="size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<Menu
breadcrumbs={breadcrumbs}
startIndex={startIndex}
onBreadcrumbClick={handleBreadCrumbClick}
/>
</PortalToFollowElemContent>
</DropdownMenuContent>
<span className="system-xs-regular text-divider-deep">/</span>
</PortalToFollowElem>
</DropdownMenu>
)
}

View File

@ -1,5 +1,6 @@
import type { DataSourceCredential } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Operator from '../operator'
@ -9,10 +10,6 @@ import Operator from '../operator'
*/
// Helper to open dropdown
const openDropdown = () => {
fireEvent.click(screen.getByRole('button'))
}
describe('Operator Component', () => {
const mockOnAction = vi.fn()
const mockOnRename = vi.fn()
@ -37,7 +34,7 @@ describe('Operator Component', () => {
// Act
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
// Assert
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
@ -53,7 +50,7 @@ describe('Operator Component', () => {
// Act
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
// Assert
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
@ -71,11 +68,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('common.operation.rename'))
// Assert
expect(mockOnRename).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(mockOnRename).toHaveBeenCalledTimes(1)
})
expect(mockOnAction).not.toHaveBeenCalled()
})
@ -85,7 +84,7 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} />)
// Act & Assert
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
const renameBtn = await screen.findByText('common.operation.rename')
expect(() => fireEvent.click(renameBtn)).not.toThrow()
})
@ -96,11 +95,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('plugin.auth.setDefault'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential)
await waitFor(() => {
expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential)
})
})
it('should call onAction for "edit" action', async () => {
@ -109,11 +110,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('common.operation.edit'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('edit', credential)
await waitFor(() => {
expect(mockOnAction).toHaveBeenCalledWith('edit', credential)
})
})
it('should call onAction for "change" action', async () => {
@ -122,11 +125,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('common.dataSource.notion.changeAuthorizedPages'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('change', credential)
await waitFor(() => {
expect(mockOnAction).toHaveBeenCalledWith('change', credential)
})
})
it('should call onAction for "delete" action', async () => {
@ -135,11 +140,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('common.operation.remove'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('delete', credential)
await waitFor(() => {
expect(mockOnAction).toHaveBeenCalledWith('delete', credential)
})
})
})
})

View File

@ -1,21 +1,20 @@
import type {
DataSourceCredential,
} from './types'
import type { Item } from '@/app/components/base/dropdown'
import {
RiDeleteBinLine,
RiEditLine,
RiEqualizer2Line,
RiHome9Line,
RiStickyNoteAddLine,
} from '@remixicon/react'
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Dropdown from '@/app/components/base/dropdown'
import ActionButton from '@/app/components/base/action-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
type OperatorProps = {
@ -29,106 +28,60 @@ const Operator = ({
onRename,
}: OperatorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const {
type,
} = credentialItem
const items = useMemo(() => {
const commonItems = [
{
value: 'setDefault',
text: (
<div className="flex items-center">
<RiHome9Line className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('auth.setDefault', { ns: 'plugin' })}</div>
</div>
),
},
...(
type === CredentialTypeEnum.OAUTH2
? [
{
value: 'rename',
text: (
<div className="flex items-center">
<RiEditLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('operation.rename', { ns: 'common' })}</div>
</div>
),
},
]
: []
),
...(
type === CredentialTypeEnum.API_KEY
? [
{
value: 'edit',
text: (
<div className="flex items-center">
<RiEqualizer2Line className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('operation.edit', { ns: 'common' })}</div>
</div>
),
},
]
: []
),
]
if (type === CredentialTypeEnum.OAUTH2) {
const oAuthItems = [
{
value: 'change',
text: (
<div className="flex items-center">
<RiStickyNoteAddLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="mb-1 text-text-secondary system-sm-semibold">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
</div>
),
},
]
commonItems.push(...oAuthItems)
}
return commonItems
}, [t, type])
const secondItems = useMemo(() => {
return [
{
value: 'delete',
text: (
<div className="flex items-center">
<RiDeleteBinLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">
{t('operation.remove', { ns: 'common' })}
</div>
</div>
),
},
]
}, [])
const handleSelect = useCallback((item: Item) => {
if (item.value === 'rename') {
onRename?.()
return
}
onAction(
item.value as string,
credentialItem,
)
}, [onAction, credentialItem, onRename])
const handleAction = useCallback((action: string) => {
setOpen(false)
queueMicrotask(() => {
if (action === 'rename') {
onRename?.()
return
}
onAction(action, credentialItem)
})
}, [credentialItem, onAction, onRename])
return (
<Dropdown
items={items}
secondItems={secondItems}
onSelect={handleSelect}
popupClassName="z-1002"
triggerProps={{
size: 'l',
}}
itemClassName="py-2 h-auto hover:bg-state-base-hover"
secondItemClassName="py-2 h-auto hover:bg-state-base-hover"
/>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger render={<div />}>
<ActionButton size="l" className={open ? 'bg-state-base-hover' : ''}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[200px]">
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('setDefault')}>
<span aria-hidden className="i-ri-home-9-line h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('auth.setDefault', { ns: 'plugin' })}</div>
</DropdownMenuItem>
{type === CredentialTypeEnum.OAUTH2 && (
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('rename')}>
<span aria-hidden className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('operation.rename', { ns: 'common' })}</div>
</DropdownMenuItem>
)}
{type === CredentialTypeEnum.API_KEY && (
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('edit')}>
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('operation.edit', { ns: 'common' })}</div>
</DropdownMenuItem>
)}
{type === CredentialTypeEnum.OAUTH2 && (
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('change')}>
<span aria-hidden className="i-ri-sticky-note-add-line h-4 w-4 text-text-tertiary" />
<div className="mb-1 text-text-secondary system-sm-semibold">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem destructive className="h-auto gap-2 py-2" onClick={() => handleAction('delete')}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
<div className="system-sm-semibold">
{t('operation.remove', { ns: 'common' })}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -680,6 +680,26 @@ describe('PluginTasks Component', () => {
})
})
it('should close the menu after clearing the last non-running plugins', async () => {
setupMocks([
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
])
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i }))
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).not.toBeInTheDocument()
})
})
it('should clear only error plugins when onClearErrors is called', async () => {
const { mockMutateAsync } = setupMocks([
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
@ -792,6 +812,30 @@ describe('PluginTasks Component', () => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
it('should open for installing-with-success state', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }),
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
])
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
it('should open for installing-with-error state', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }),
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'failed-1' }),
])
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
})
})

View File

@ -5,10 +5,10 @@ import {
} 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 useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import PluginTaskList from './components/plugin-task-list'
import TaskStatusIndicator from './components/task-status-indicator'
@ -96,16 +96,14 @@ const PluginTasks = () => {
return (
<div className="flex items-center">
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 79,
}}
>
<PortalToFollowElemTrigger onClick={handleTriggerClick}>
<DropdownMenuTrigger
render={<div />}
onClick={handleTriggerClick}
>
<TaskStatusIndicator
tip={tip}
isInstalling={isInstalling}
@ -118,8 +116,12 @@ const PluginTasks = () => {
totalPluginsLength={totalPluginsLength}
onClick={() => {}}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none overflow-visible"
>
<PluginTaskList
runningPlugins={runningPlugins}
successPlugins={successPlugins}
@ -129,8 +131,8 @@ const PluginTasks = () => {
onClearErrors={handleClearErrors}
onClearSingle={handleClearSingle}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@ -3,6 +3,27 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MenuDropdown from '../menu-dropdown'
vi.mock('../info-modal', () => ({
default: ({
isShow,
onClose,
data,
}: {
isShow: boolean
onClose: () => void
data?: SiteInfo
}) => {
if (!isShow)
return null
return (
<div data-testid="info-modal">
<span>{data?.title}</span>
<button type="button" onClick={onClose}>Close Info</button>
</div>
)
},
}))
const mockReplace = vi.fn()
const mockPathname = '/test-path'
vi.mock('@/next/navigation', () => ({
@ -191,6 +212,25 @@ describe('MenuDropdown', () => {
expect(screen.getByText('Test App')).toBeInTheDocument()
})
})
it('should close InfoModal when the close handler runs', async () => {
render(<MenuDropdown data={baseSiteInfo} />)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('common.userProfile.about')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('common.userProfile.about'))
await waitFor(() => {
expect(screen.getByTestId('info-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Close Info'))
await waitFor(() => {
expect(screen.queryByTestId('info-modal')).not.toBeInTheDocument()
})
})
})
describe('forceClose prop', () => {

View File

@ -1,26 +1,25 @@
'use client'
import type { Placement } from '@floating-ui/react'
import type { FC } from 'react'
import type { Placement } from '@/app/components/base/ui/placement'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiEqualizer2Line,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, 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'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
import { webAppLogout } from '@/service/webapp-auth'
import Divider from '../../base/divider'
import InfoModal from './info-modal'
type Props = {
@ -40,24 +39,22 @@ const MenuDropdown: FC<Props> = ({
const router = useRouter()
const pathname = usePathname()
const { t } = useTranslation()
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 shareCode = useWebAppStore(s => s.shareCode)
const handleLogout = useCallback(async () => {
setOpen(false)
await webAppLogout(shareCode!)
router.replace(`/webapp-signin?redirect_url=${pathname}`)
}, [router, pathname, webAppLogout, shareCode])
}, [pathname, router, setOpen, shareCode])
const [show, setShow] = useState(false)
const handleOpenInfoModal = useCallback(() => {
setOpen(false)
queueMicrotask(() => {
setShow(true)
})
}, [])
useEffect(() => {
if (forceClose)
@ -66,60 +63,56 @@ const MenuDropdown: FC<Props> = ({
return (
<>
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement={placement || 'bottom-end'}
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<RiEqualizer2Line className="h-[18px] w-[18px]" />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-1">
<div className={cn('flex cursor-pointer items-center rounded-lg py-1.5 pr-2 pl-3 system-md-regular text-text-secondary')}>
<div className="grow">{t('theme.theme', { ns: 'common' })}</div>
<ThemeSwitcher />
</div>
<DropdownMenuTrigger
render={<div />}
aria-label={t('operation.more', { ns: 'common' })}
>
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent
placement={placement || 'bottom-end'}
sideOffset={4}
popupClassName="w-[224px]"
>
<div className="px-3 py-1.5 system-md-regular text-text-secondary">
<div className="flex items-center gap-2">
<div className="grow">{t('theme.theme', { ns: 'common' })}</div>
<ThemeSwitcher />
</div>
<Divider type="horizontal" className="my-0" />
<div className="p-1">
{data?.privacy_policy && (
<a href={data.privacy_policy} target="_blank" className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover">
<span className="grow">{t('chat.privacyPolicyMiddle', { ns: 'share' })}</span>
</a>
)}
<div
onClick={() => {
handleTrigger()
setShow(true)
}}
className="cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
>
{t('userProfile.about', { ns: 'common' })}
</div>
</div>
{!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && (
<div className="p-1">
<div
onClick={handleLogout}
className="cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
>
{t('userProfile.logout', { ns: 'common' })}
</div>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<DropdownMenuSeparator className="my-0" />
{data?.privacy_policy && (
<DropdownMenuLinkItem
className="px-3 system-md-regular"
href={data.privacy_policy}
target="_blank"
rel="noreferrer"
>
<span className="grow">{t('chat.privacyPolicyMiddle', { ns: 'share' })}</span>
</DropdownMenuLinkItem>
)}
<DropdownMenuItem
className="px-3 system-md-regular"
onClick={handleOpenInfoModal}
>
{t('userProfile.about', { ns: 'common' })}
</DropdownMenuItem>
{!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && (
<DropdownMenuItem
className="px-3 system-md-regular"
onClick={handleLogout}
>
{t('userProfile.logout', { ns: 'common' })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{show && (
<InfoModal
isShow={show}

View File

@ -0,0 +1,124 @@
import type { ComponentProps } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDownloadPlugin } from '@/service/use-plugins'
import OperationDropdown from '../action'
const mockDownloadBlob = vi.fn()
const mockRemoveQueries = vi.fn()
vi.mock('next-themes', () => ({
useTheme: () => ({
theme: 'light',
}),
}))
vi.mock('@/service/use-plugins', () => ({
useDownloadPlugin: vi.fn(),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.example${path}`,
}))
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderComponent = (props?: Partial<ComponentProps<typeof OperationDropdown>>) => {
const queryClient = createQueryClient()
vi.spyOn(queryClient, 'removeQueries').mockImplementation(((...args) => {
return mockRemoveQueries(...args)
}) as typeof queryClient.removeQueries)
return render(
<QueryClientProvider client={queryClient}>
<OperationDropdown
open={false}
onOpenChange={vi.fn()}
author="langgenius"
name="test-plugin"
version="1.0.0"
{...props}
/>
</QueryClientProvider>,
)
}
describe('OperationDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({
data: enabled ? null : null,
isLoading: false,
}) as unknown as ReturnType<typeof useDownloadPlugin>)
})
it('should render download and view details actions when opened', async () => {
renderComponent({ open: true })
expect(screen.getByText('common.operation.download')).toBeInTheDocument()
expect(screen.getByText('common.operation.viewDetails')).toBeInTheDocument()
})
it('should request a download when download is clicked', async () => {
const onOpenChange = vi.fn()
renderComponent({ open: true, onOpenChange })
await userEvent.setup().click(screen.getByText('common.operation.download'))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(mockRemoveQueries).toHaveBeenCalled()
})
it('should skip download when already loading', async () => {
vi.mocked(useDownloadPlugin).mockReturnValue({
data: null,
isLoading: true,
} as unknown as ReturnType<typeof useDownloadPlugin>)
renderComponent({ open: true })
await userEvent.setup().click(screen.getByText('common.operation.download'))
expect(mockRemoveQueries).not.toHaveBeenCalled()
})
it('should download the blob when the hook returns data', async () => {
vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({
data: enabled ? new Blob(['plugin zip'], { type: 'application/zip' }) : null,
isLoading: false,
}) as unknown as ReturnType<typeof useDownloadPlugin>)
renderComponent({ open: true })
await userEvent.setup().click(screen.getByText('common.operation.download'))
await waitFor(() => {
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: 'langgenius-test-plugin_1.0.0.zip',
})
})
expect(mockRemoveQueries).toHaveBeenCalled()
})
it('should link to the marketplace detail page', () => {
renderComponent({ open: true })
expect(screen.getByRole('menuitem', { name: 'common.operation.viewDetails' })).toHaveAttribute(
'href',
'https://marketplace.example/plugins/langgenius/test-plugin',
)
})
})

View File

@ -1,19 +1,19 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiMoreFill } from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
// import { Button } from '@/app/components/base/ui/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useDownloadPlugin } from '@/service/use-plugins'
import { downloadBlob } from '@/utils/download'
import { getMarketplaceUrl } from '@/utils/var'
@ -36,16 +36,10 @@ const OperationDropdown: FC<Props> = ({
const { t } = useTranslation()
const { theme } = useTheme()
const queryClient = useQueryClient()
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
onOpenChange(v)
openRef.current = v
const setOpen = useCallback((value: boolean) => {
onOpenChange(value)
}, [onOpenChange])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const [needDownload, setNeedDownload] = useState(false)
const downloadInfo = useMemo(() => ({
organization: author,
@ -56,12 +50,13 @@ const OperationDropdown: FC<Props> = ({
const handleDownload = useCallback(() => {
if (isLoading)
return
setOpen(false)
queryClient.removeQueries({
queryKey: ['plugins', 'downloadPlugin', downloadInfo],
exact: true,
})
setNeedDownload(true)
}, [downloadInfo, isLoading, queryClient])
}, [downloadInfo, isLoading, queryClient, setOpen])
useEffect(() => {
if (!needDownload || !blob)
@ -75,27 +70,33 @@ const OperationDropdown: FC<Props> = ({
})
}, [author, blob, downloadInfo, name, needDownload, queryClient, version])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 0,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<DropdownMenuTrigger render={<div />}>
<ActionButton className={cn(open && 'bg-state-base-hover')}>
<RiMoreFill className="h-4 w-4 text-components-button-secondary-accent-text" />
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-accent-text" />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-9999">
<div className="min-w-[176px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
<div onClick={handleDownload} className="cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover">{t('operation.download', { ns: 'common' })}</div>
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="block cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover">{t('operation.viewDetails', { ns: 'common' })}</a>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[176px]"
>
<DropdownMenuItem className="system-md-regular" onClick={handleDownload}>
{t('operation.download', { ns: 'common' })}
</DropdownMenuItem>
<DropdownMenuLinkItem
className="system-md-regular"
href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })}
target="_blank"
rel="noreferrer"
>
{t('operation.viewDetails', { ns: 'common' })}
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(OperationDropdown)

View File

@ -9,7 +9,6 @@ This document tracks the migration away from legacy overlay APIs.
- `@/app/components/base/tooltip`
- `@/app/components/base/modal`
- `@/app/components/base/select` (including `custom` / `pure`)
- `@/app/components/base/dropdown`
- `@/app/components/base/dialog`
- `@/app/components/base/toast` (including `context`)
- Replacement primitives:

View File

@ -52,13 +52,6 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
],
message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.',
},
{
group: [
'**/base/dropdown',
'**/base/dropdown/index',
],
message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.',
},
{
group: [
'**/base/dialog',
@ -80,7 +73,6 @@ export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
'app/components/base/chip/index.tsx',
'app/components/base/date-and-time-picker/date-picker/index.tsx',
'app/components/base/date-and-time-picker/time-picker/index.tsx',
'app/components/base/dropdown/index.tsx',
'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx',
'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx',
'app/components/base/file-uploader/file-from-link-or-local/index.tsx',