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