fix(web): migrate metadata picker to combobox (#36255)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zhangxuhe1
2026-05-17 11:35:20 +08:00
committed by GitHub
parent 9d0906c684
commit cd4d6f8a22
22 changed files with 728 additions and 1377 deletions

View File

@@ -2100,16 +2100,6 @@
"count": 2
}
},
"web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
"react/static-components": {
"count": 2
}
},
"web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx": {
"react/static-components": {
"count": 2
}
},
"web/app/components/datasets/metadata/types.ts": {
"erasable-syntax-only/enums": {
"count": 2

View File

@@ -1,92 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AddedMetadataButton from '../add-metadata-button'
describe('AddedMetadataButton', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddedMetadataButton />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with translated text', () => {
render(<AddedMetadataButton />)
// The button should contain text from i18n
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<AddedMetadataButton />)
// Check if there's an SVG element (the RiAddLine icon)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<AddedMetadataButton className="custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('should apply default classes', () => {
render(<AddedMetadataButton />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center')
})
it('should merge custom className with default classes', () => {
render(<AddedMetadataButton className="my-custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center', 'my-custom-class')
})
})
describe('User Interactions', () => {
it('should call onClick when button is clicked', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should not throw when onClick is not provided and button is clicked', () => {
render(<AddedMetadataButton />)
expect(() => {
fireEvent.click(screen.getByRole('button'))
}).not.toThrow()
})
it('should call onClick multiple times on multiple clicks', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(3)
})
})
describe('Edge Cases', () => {
it('should render with undefined className', () => {
render(<AddedMetadataButton className={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with empty className', () => {
render(<AddedMetadataButton className="" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with undefined onClick', () => {
render(<AddedMetadataButton onClick={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@@ -1,31 +0,0 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type Props = {
className?: string
onClick?: () => void
}
const AddedMetadataButton: FC<Props> = ({
className,
onClick,
}) => {
const { t } = useTranslation()
return (
<Button
className={cn('flex w-full items-center', className)}
size="small"
variant="tertiary"
onClick={onClick}
>
<RiAddLine className="mr-1 size-3.5" />
<div>{t('metadata.addMetadata', { ns: 'dataset' })}</div>
</Button>
)
}
export default React.memo(AddedMetadataButton)

View File

@@ -56,42 +56,44 @@ type AddRowProps = {
onRemove: () => void
}
type SelectModalProps = {
trigger: React.ReactNode
onSelect: (item: MetadataItemInBatchEdit) => void
onSave: (data: { name: string, type: DataType }) => Promise<void>
onManage: () => void
}
// Mock child components with callback exposure
// Mock row components to exercise parent state transitions with accessible controls.
vi.mock('../edit-row', () => ({
default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => (
<div data-testid="edit-row" data-id={payload.id}>
<span data-testid="edit-row-name">{payload.name}</span>
<button data-testid={`change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}>Change</button>
<button data-testid={`remove-${payload.id}`} onClick={() => onRemove(payload.id)}>Remove</button>
<button data-testid={`reset-${payload.id}`} onClick={() => onReset(payload.id)}>Reset</button>
<div role="group" aria-label={`Edit metadata ${payload.name}`}>
<span>{payload.name}</span>
<button type="button" onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}>
Change
{' '}
{payload.name}
</button>
<button type="button" onClick={() => onRemove(payload.id)}>
Remove
{' '}
{payload.name}
</button>
<button type="button" onClick={() => onReset(payload.id)}>
Reset
{' '}
{payload.name}
</button>
</div>
),
}))
vi.mock('../add-row', () => ({
default: ({ payload, onChange, onRemove }: AddRowProps) => (
<div data-testid="add-row" data-id={payload.id}>
<span data-testid="add-row-name">{payload.name}</span>
<button data-testid={`add-change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'new_value' })}>Change</button>
<button data-testid="add-remove" onClick={onRemove}>Remove</button>
</div>
),
}))
vi.mock('../../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-modal">
{trigger}
<button data-testid="select-metadata" onClick={() => onSelect({ id: 'new-1', name: 'new_field', type: DataType.string, value: null, isMultipleValue: false })}>Select</button>
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => { })}>Save</button>
<button data-testid="manage-metadata" onClick={onManage}>Manage</button>
<div role="group" aria-label={`Added metadata ${payload.name}`}>
<span>{payload.name}</span>
<button type="button" onClick={() => onChange({ ...payload, value: 'new_value' })}>
Change
{' '}
{payload.name}
</button>
<button type="button" onClick={onRemove}>
Remove
{' '}
{payload.name}
</button>
</div>
),
}))
@@ -116,6 +118,25 @@ describe('EditMetadataBatchModal', () => {
mockCheckNameResult = { errorMsg: '' }
})
const getEditRows = () => screen.getAllByRole('group', { name: /^Edit metadata / })
const getEditRow = (name: string) => screen.getByRole('group', { name: `Edit metadata ${name}` })
const getAddedRow = (name: string) => screen.getByRole('group', { name: `Added metadata ${name}` })
const queryAddedRow = (name: string) => screen.queryByRole('group', { name: `Added metadata ${name}` })
const openMetadataPicker = () => {
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
}
const selectMetadata = async (name = 'existing_field') => {
openMetadataPicker()
fireEvent.click(await screen.findByRole('option', { name: new RegExp(name) }))
}
const createMetadata = async (name = 'created_field') => {
openMetadataPicker()
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }))
fireEvent.change(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), { target: { value: name } })
const saveButtons = screen.getAllByRole('button', { name: 'common.operation.save' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
}
describe('Rendering', () => {
it('should render without crashing', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
@@ -134,8 +155,7 @@ describe('EditMetadataBatchModal', () => {
it('should render all edit rows for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const editRows = screen.getAllByTestId('edit-row')
expect(editRows).toHaveLength(2)
expect(getEditRows()).toHaveLength(2)
})
})
@@ -150,15 +170,14 @@ describe('EditMetadataBatchModal', () => {
it('should render checkbox for apply to all', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const checkboxes = document.querySelectorAll('[data-testid*="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
expect(screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' })).toBeInTheDocument()
})
})
it('should render select metadata modal', async () => {
it('should render dataset metadata picker', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('select-modal'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))!.toBeInTheDocument()
})
})
})
@@ -186,7 +205,7 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
// Find the primary save button (not the one in SelectMetadataModal)
// Find the primary save button (not the one in DatasetMetadataPicker)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalled()
@@ -199,16 +218,12 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
expect(checkboxContainer)!.toBeInTheDocument()
const checkbox = screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' })
fireEvent.click(checkbox)
if (checkboxContainer) {
fireEvent.click(checkboxContainer)
await waitFor(() => {
const checkIcon = screen.getByTestId('check-icon-apply-to-all')
expect(checkIcon)!.toBeInTheDocument()
})
}
await waitFor(() => {
expect(checkbox).toHaveAttribute('aria-checked', 'true')
})
})
it('should call onHide when modal close button is clicked', async () => {
@@ -229,10 +244,10 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('change-1'))
fireEvent.click(screen.getByRole('button', { name: 'Change field_one' }))
// The component should update internally
expect(screen.getAllByTestId('edit-row').length).toBe(2)
expect(getEditRows()).toHaveLength(2)
})
it('should mark item as deleted when remove is clicked', async () => {
@@ -242,10 +257,10 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('remove-1'))
fireEvent.click(screen.getByRole('button', { name: 'Remove field_one' }))
// The component should update internally - item marked as deleted
expect(screen.getAllByTestId('edit-row').length).toBe(2)
expect(getEditRows()).toHaveLength(2)
})
it('should reset item when reset is clicked', async () => {
@@ -256,11 +271,11 @@ describe('EditMetadataBatchModal', () => {
})
// First change the item
fireEvent.click(screen.getByTestId('change-1'))
fireEvent.click(screen.getByRole('button', { name: 'Change field_one' }))
// Then reset it
fireEvent.click(screen.getByTestId('reset-1'))
fireEvent.click(screen.getByRole('button', { name: 'Reset field_one' }))
expect(screen.getAllByTestId('edit-row').length).toBe(2)
expect(getEditRows()).toHaveLength(2)
})
})
@@ -272,11 +287,11 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('select-metadata'))
await selectMetadata()
// Should now have add-row for the new item
await waitFor(() => {
expect(screen.getByTestId('add-row'))!.toBeInTheDocument()
expect(getAddedRow('existing_field'))!.toBeInTheDocument()
})
})
@@ -288,17 +303,17 @@ describe('EditMetadataBatchModal', () => {
})
// First add an item
fireEvent.click(screen.getByTestId('select-metadata'))
await selectMetadata()
await waitFor(() => {
expect(screen.getByTestId('add-row'))!.toBeInTheDocument()
expect(getAddedRow('existing_field'))!.toBeInTheDocument()
})
// Then remove it
fireEvent.click(screen.getByTestId('add-remove'))
fireEvent.click(screen.getByRole('button', { name: 'Remove existing_field' }))
await waitFor(() => {
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
expect(queryAddedRow('existing_field')).not.toBeInTheDocument()
})
})
@@ -310,16 +325,16 @@ describe('EditMetadataBatchModal', () => {
})
// First add an item
fireEvent.click(screen.getByTestId('select-metadata'))
await selectMetadata()
await waitFor(() => {
expect(screen.getByTestId('add-row'))!.toBeInTheDocument()
expect(getAddedRow('existing_field'))!.toBeInTheDocument()
})
// Then change it
fireEvent.click(screen.getByTestId('add-change-new-1'))
fireEvent.click(screen.getByRole('button', { name: 'Change existing_field' }))
expect(screen.getByTestId('add-row'))!.toBeInTheDocument()
expect(getAddedRow('existing_field'))!.toBeInTheDocument()
})
it('should call doAddMetaData when saving new metadata with valid name', async () => {
@@ -331,7 +346,7 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await createMetadata()
await waitFor(() => {
expect(mockDoAddMetaData).toHaveBeenCalled()
@@ -347,7 +362,7 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await createMetadata()
await waitFor(() => {
expect(mockDoAddMetaData).toHaveBeenCalled()
@@ -371,7 +386,7 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await createMetadata()
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
@@ -391,7 +406,8 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('manage-metadata'))
openMetadataPicker()
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }))
expect(onShowManage).toHaveBeenCalled()
})
@@ -415,7 +431,7 @@ describe('EditMetadataBatchModal', () => {
it('should handle empty list', async () => {
render(<EditMetadataBatchModal {...defaultProps} list={[]} />)
await waitFor(() => {
expect(screen.queryByTestId('edit-row')).not.toBeInTheDocument()
expect(screen.queryByRole('group', { name: /^Edit metadata / })).not.toBeInTheDocument()
})
})
})
@@ -427,7 +443,7 @@ describe('EditMetadataBatchModal', () => {
]
render(<EditMetadataBatchModal {...defaultProps} list={multipleValueList} />)
await waitFor(() => {
expect(screen.getByTestId('edit-row'))!.toBeInTheDocument()
expect(getEditRow('field'))!.toBeInTheDocument()
})
})
@@ -473,9 +489,7 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
if (checkboxContainer)
fireEvent.click(checkboxContainer)
fireEvent.click(screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
@@ -497,7 +511,7 @@ describe('EditMetadataBatchModal', () => {
})
// Remove an item
fireEvent.click(screen.getByTestId('remove-1'))
fireEvent.click(screen.getByRole('button', { name: 'Remove field_one' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
@@ -516,22 +530,22 @@ describe('EditMetadataBatchModal', () => {
})
// Add first item
fireEvent.click(screen.getByTestId('select-metadata'))
await selectMetadata()
await waitFor(() => {
expect(screen.getByTestId('add-row'))!.toBeInTheDocument()
expect(getAddedRow('existing_field'))!.toBeInTheDocument()
})
// Remove it
fireEvent.click(screen.getByTestId('add-remove'))
fireEvent.click(screen.getByRole('button', { name: 'Remove existing_field' }))
await waitFor(() => {
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
expect(queryAddedRow('existing_field')).not.toBeInTheDocument()
})
// Add again
fireEvent.click(screen.getByTestId('select-metadata'))
await selectMetadata()
await waitFor(() => {
expect(screen.getByTestId('add-row'))!.toBeInTheDocument()
expect(getAddedRow('existing_field'))!.toBeInTheDocument()
})
})
})

View File

@@ -12,9 +12,8 @@ import Divider from '@/app/components/base/divider'
import { useCreateMetaData } from '@/service/knowledge/use-metadata'
import Checkbox from '../../../base/checkbox'
import { Infotip } from '../../../base/infotip'
import AddMetadataButton from '../add-metadata-button'
import useCheckMetadataName from '../hooks/use-check-metadata-name'
import SelectMetadataModal from '../metadata-dataset/select-metadata-modal'
import { DatasetMetadataPicker } from '../metadata-dataset/dataset-metadata-picker'
import { UpdateType } from '../types'
import AddedMetadataItem from './add-row'
import EditMetadataBatchItem from './edit-row'
@@ -117,14 +116,27 @@ const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSav
{addedList.map((item, i) => (<AddedMetadataItem key={i} payload={item} onChange={handleAddedListChange} onRemove={handleAddedItemRemove(i)} />))}
</div>
<div className="mt-3">
<SelectMetadataModal datasetId={datasetId} popupPlacement="top-start" popupOffset={{ mainAxis: 4, crossAxis: 0 }} trigger={<AddMetadataButton />} onSave={handleAddMetaData} onSelect={data => setAddedList([...addedList, data as MetadataItemWithEdit])} onManage={onShowManage} />
<DatasetMetadataPicker
datasetId={datasetId}
placement="top-start"
sideOffset={4}
alignOffset={0}
onCreateMetadata={handleAddMetaData}
onSelectMetadata={data => setAddedList([...addedList, data as MetadataItemWithEdit])}
onOpenMetadataManagement={onShowManage}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center select-none">
<Checkbox checked={isApplyToAllSelectDocument} onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)} id="apply-to-all" />
<Checkbox
checked={isApplyToAllSelectDocument}
onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)}
id="apply-to-all"
ariaLabel={t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}
/>
<div className="mr-1 ml-2 system-xs-medium text-text-secondary">{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}</div>
<Infotip
aria-label={t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}

View File

@@ -3,7 +3,7 @@ import { Popover } from '@langgenius/dify-ui/popover'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../../types'
import CreateContent from '../create-content'
import { CreateContent } from '../create-content'
const renderCreateContent = (props: CreateContentProps) => {
return render(

View File

@@ -1,77 +1,10 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../../types'
import CreateMetadataModal from '../create-metadata-modal'
type PopoverProps = {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}
type TriggerProps = {
children?: React.ReactNode
render?: React.ReactNode
}
type ContentProps = {
children: React.ReactNode
className?: string
}
type CreateContentProps = {
onSave: (data: { type: DataType, name: string }) => void
onClose?: () => void
onBack?: () => void
hasBack?: boolean
}
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null)
return {
Popover: ({ children, open, onOpenChange }: PopoverProps) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={String(open)}>{children}</div>
</PopoverContext.Provider>
),
PopoverTrigger: ({ children, render }: TriggerProps) => {
const context = React.useContext(PopoverContext)
const content = render ?? children
const handleClick = () => context?.onOpenChange?.(!context.open)
if (React.isValidElement(content)) {
const element = content as React.ReactElement<{ onClick?: () => void }>
return React.cloneElement(element, { onClick: handleClick })
}
return <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button>
},
PopoverContent: ({ children, className }: ContentProps) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content" className={className}>{children}</div>
},
}
})
// Mock CreateContent component
vi.mock('../create-content', () => ({
default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<span data-testid="has-back">{String(hasBack)}</span>
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'test' })}>Save</button>
<button data-testid="close-btn" onClick={onClose}>Close</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
</div>
),
}))
import { CreateMetadataModal } from '../create-metadata-modal'
describe('CreateMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Open Modal</button>
const mockTrigger = <button>Open Modal</button>
describe('Rendering', () => {
it('should render trigger when closed', () => {
@@ -83,8 +16,8 @@ describe('CreateMetadataModal', () => {
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false')
expect(screen.getByRole('button', { name: 'Open Modal' })).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render content when open', () => {
@@ -96,8 +29,8 @@ describe('CreateMetadataModal', () => {
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
expect(screen.getByTestId('create-content')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' })).toBeInTheDocument()
})
it('should render trigger element', () => {
@@ -109,7 +42,7 @@ describe('CreateMetadataModal', () => {
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open Modal' })).toBeInTheDocument()
})
})
@@ -124,7 +57,7 @@ describe('CreateMetadataModal', () => {
hasBack
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('true')
expect(screen.getByRole('button', { name: 'dataset.metadata.createMetadata.back' })).toBeInTheDocument()
})
it('should pass hasBack=undefined when not provided', () => {
@@ -136,7 +69,7 @@ describe('CreateMetadataModal', () => {
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('undefined')
expect(screen.queryByRole('button', { name: 'dataset.metadata.createMetadata.back' })).not.toBeInTheDocument()
})
it('should accept custom popupLeft', () => {
@@ -149,7 +82,7 @@ describe('CreateMetadataModal', () => {
popupLeft={50}
/>,
)
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
@@ -165,9 +98,9 @@ describe('CreateMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByRole('button', { name: 'Open Modal' }))
expect(setOpen).toHaveBeenCalledWith(true)
expect(setOpen).toHaveBeenCalledWith(true, expect.any(Object))
})
it('should call onSave when save button is clicked', () => {
@@ -181,7 +114,10 @@ describe('CreateMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('save-btn'))
fireEvent.change(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), {
target: { value: 'test' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
@@ -200,7 +136,7 @@ describe('CreateMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('close-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(setOpen).toHaveBeenCalledWith(false)
})
@@ -217,7 +153,7 @@ describe('CreateMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('back-btn'))
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.createMetadata.back' }))
expect(setOpen).toHaveBeenCalledWith(false)
})
@@ -234,7 +170,7 @@ describe('CreateMetadataModal', () => {
/>,
)
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
rerender(
<CreateMetadataModal
@@ -245,11 +181,11 @@ describe('CreateMetadataModal', () => {
/>,
)
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle different trigger elements', () => {
const customTrigger = <div data-testid="custom-trigger">Custom</div>
const customTrigger = <button>Custom</button>
render(
<CreateMetadataModal
open={false}
@@ -259,7 +195,7 @@ describe('CreateMetadataModal', () => {
/>,
)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Custom' })).toBeInTheDocument()
})
})
})

View File

@@ -35,31 +35,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
// Type definitions for mock props
type CreateModalProps = {
open: boolean
setOpen: (open: boolean) => void
trigger: React.ReactNode
onSave: (data: BuiltInMetadataItem) => void
}
// Mock CreateModal to expose callbacks
vi.mock('@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal', () => ({
default: ({ open, setOpen, trigger, onSave }: CreateModalProps) => (
<div data-testid="create-modal-wrapper">
<div data-testid="create-trigger" onClick={() => setOpen(true)}>{trigger}</div>
{open && (
<div data-testid="create-modal">
<button data-testid="create-save" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>
Save
</button>
<button data-testid="create-close" onClick={() => setOpen(false)}>Close</button>
</div>
)}
</div>
),
}))
describe('DatasetMetadataDrawer', () => {
const mockUserMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
@@ -90,6 +65,20 @@ describe('DatasetMetadataDrawer', () => {
fireEvent.click(screen.getAllByRole('button', { name })[0]!)
}
const openCreateMetadata = async () => {
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.datasetMetadata.addMetaData' }))
await waitFor(() => {
expect(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' })).toBeInTheDocument()
})
}
const saveCreatedMetadata = (name = 'new_field') => {
fireEvent.change(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), {
target: { value: name },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
}
describe('Rendering', () => {
it('should render without crashing', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
@@ -125,7 +114,7 @@ describe('DatasetMetadataDrawer', () => {
it('should render add metadata button', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('create-trigger'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.metadata.datasetMetadata.addMetaData' }))!.toBeInTheDocument()
})
})
@@ -180,12 +169,9 @@ describe('DatasetMetadataDrawer', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
const trigger = screen.getByTestId('create-trigger')
fireEvent.click(trigger)
await openCreateMetadata()
await waitFor(() => {
expect(screen.getByTestId('create-modal'))!.toBeInTheDocument()
})
expect(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }))!.toBeInTheDocument()
})
it('should call onAdd and show success toast when metadata is added', async () => {
@@ -197,15 +183,10 @@ describe('DatasetMetadataDrawer', () => {
})
// Open create modal
const trigger = screen.getByTestId('create-trigger')
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByTestId('create-modal'))!.toBeInTheDocument()
})
await openCreateMetadata()
// Save new metadata
fireEvent.click(screen.getByTestId('create-save'))
saveCreatedMetadata()
await waitFor(() => {
expect(onAdd).toHaveBeenCalled()
@@ -229,16 +210,12 @@ describe('DatasetMetadataDrawer', () => {
})
// Open create modal
fireEvent.click(screen.getByTestId('create-trigger'))
await openCreateMetadata()
saveCreatedMetadata()
await waitFor(() => {
expect(screen.getByTestId('create-modal'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('create-save'))
await waitFor(() => {
expect(screen.queryByTestId('create-modal')).not.toBeInTheDocument()
expect(screen.queryByRole('textbox', { name: 'dataset.metadata.createMetadata.name' })).not.toBeInTheDocument()
})
})
})
@@ -276,7 +253,7 @@ describe('DatasetMetadataDrawer', () => {
expect(inputs.length).toBeGreaterThan(0)
})
const input = screen.getByPlaceholderText('dataset.metadata.datasetMetadata.namePlaceholder')
const input = screen.getByRole('textbox', { name: 'dataset.metadata.datasetMetadata.name' })
fireEvent.change(input, { target: { value: 'renamed_field' } })
// Find and click save button
@@ -311,7 +288,7 @@ describe('DatasetMetadataDrawer', () => {
})
// Change name first
const input = screen.getByPlaceholderText('dataset.metadata.datasetMetadata.namePlaceholder')
const input = screen.getByRole('textbox', { name: 'dataset.metadata.datasetMetadata.name' })
fireEvent.change(input, { target: { value: 'changed_name' } })
// Find and click cancel button

View File

@@ -0,0 +1,175 @@
import type { MetadataItem } from '../../types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataType } from '../../types'
import { DatasetMetadataPicker } from '../dataset-metadata-picker'
const { mockUseDatasetMetaData } = vi.hoisted(() => ({
mockUseDatasetMetaData: vi.fn(),
}))
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: mockUseDatasetMetaData,
}))
const metadataItems: MetadataItem[] = [
{ id: '1', name: 'field_one', type: DataType.string },
{ id: '2', name: 'field_two', type: DataType.number },
{ id: '3', name: 'field_three', type: DataType.time },
]
function renderDatasetMetadataPicker(overrides: Partial<React.ComponentProps<typeof DatasetMetadataPicker>> = {}) {
const props = {
datasetId: 'dataset-1',
onSelectMetadata: vi.fn(),
onCreateMetadata: vi.fn(),
onOpenMetadataManagement: vi.fn(),
...overrides,
}
return {
props,
...render(<DatasetMetadataPicker {...props} />),
}
}
describe('DatasetMetadataPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseDatasetMetaData.mockReturnValue({
data: {
doc_metadata: metadataItems,
},
})
})
describe('Rendering', () => {
it('should render an add metadata picker trigger', () => {
renderDatasetMetadataPicker()
expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toBeInTheDocument()
})
it('should show metadata options when opened', async () => {
const user = userEvent.setup()
renderDatasetMetadataPicker()
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
expect(await screen.findByRole('option', { name: /field_one/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /field_two/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /field_three/ })).toBeInTheDocument()
})
})
describe('Search', () => {
it('should filter metadata options by search query', async () => {
const user = userEvent.setup()
renderDatasetMetadataPicker()
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
await user.type(screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }), 'two')
expect(screen.getByRole('option', { name: /field_two/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /field_one/ })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /field_three/ })).not.toBeInTheDocument()
})
it('should show an empty state when no metadata matches', async () => {
const user = userEvent.setup()
renderDatasetMetadataPicker()
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
await user.type(screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }), 'missing')
expect(await screen.findByRole('status')).toHaveTextContent('common.noData')
})
})
describe('Selection', () => {
it('should call onSelectMetadata and close when an option is selected', async () => {
const user = userEvent.setup()
const onSelectMetadata = vi.fn()
renderDatasetMetadataPicker({ onSelectMetadata })
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
await user.click(await screen.findByRole('option', { name: /field_two/ }))
expect(onSelectMetadata).toHaveBeenCalledWith({
id: '2',
name: 'field_two',
type: DataType.number,
})
await waitFor(() => {
expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toHaveAttribute('aria-expanded', 'false')
})
})
})
describe('Actions', () => {
it('should switch to create view and save a new metadata item', async () => {
const user = userEvent.setup()
const onCreateMetadata = vi.fn().mockResolvedValue(undefined)
renderDatasetMetadataPicker({ onCreateMetadata })
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
await user.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }))
await user.type(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), 'new_field')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCreateMetadata).toHaveBeenCalledWith({
name: 'new_field',
type: DataType.string,
})
await waitFor(() => {
expect(screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' })).toBeInTheDocument()
})
})
it('should return from create view without closing the picker', async () => {
const user = userEvent.setup()
renderDatasetMetadataPicker()
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
await user.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }))
await user.click(screen.getByRole('button', { name: 'dataset.metadata.createMetadata.back' }))
expect(screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toHaveAttribute('aria-expanded', 'true')
})
it('should open metadata management and close the picker', async () => {
const user = userEvent.setup()
const onOpenMetadataManagement = vi.fn()
renderDatasetMetadataPicker({ onOpenMetadataManagement })
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
await user.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }))
expect(onOpenMetadataManagement).toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toHaveAttribute('aria-expanded', 'false')
})
})
})
describe('Edge Cases', () => {
it('should keep action buttons available when metadata list is empty', async () => {
const user = userEvent.setup()
mockUseDatasetMetaData.mockReturnValue({
data: {
doc_metadata: [],
},
})
renderDatasetMetadataPicker()
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
expect(await screen.findByRole('status')).toHaveTextContent('common.noData')
expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })).toBeInTheDocument()
})
})
})

View File

@@ -1,374 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../../types'
import SelectMetadataModal from '../select-metadata-modal'
type MetadataItem = {
id: string
name: string
type: DataType
}
type PopoverProps = {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}
type TriggerProps = {
children?: React.ReactNode
render?: React.ReactNode
}
type ContentProps = {
children: React.ReactNode
}
type SelectMetadataProps = {
onSelect: (item: MetadataItem) => void
onNew: () => void
onManage: () => void
list: MetadataItem[]
}
type CreateContentProps = {
onSave: (data: { type: DataType, name: string }) => void
onBack?: () => void
onClose?: () => void
hasBack?: boolean
}
// Mock useDatasetMetaData hook
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string },
{ id: '2', name: 'field_two', type: DataType.number },
],
},
}),
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null)
return {
Popover: ({ children, open, onOpenChange }: PopoverProps) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={String(open)}>{children}</div>
</PopoverContext.Provider>
),
PopoverTrigger: ({ children, render }: TriggerProps) => {
const context = React.useContext(PopoverContext)
const content = render ?? children
const handleClick = () => context?.onOpenChange?.(!context.open)
if (React.isValidElement(content)) {
const element = content as React.ReactElement<{ onClick?: () => void }>
return React.cloneElement(element, { onClick: handleClick })
}
return <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button>
},
PopoverContent: ({ children }: ContentProps) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content">{children}</div>
},
}
})
// Mock SelectMetadata component
vi.mock('../select-metadata', () => ({
default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => (
<div data-testid="select-metadata">
<span data-testid="list-count">{list?.length || 0}</span>
<button data-testid="select-item" onClick={() => onSelect({ id: '1', name: 'field_one', type: DataType.string })}>Select</button>
<button data-testid="new-btn" onClick={onNew}>New</button>
<button data-testid="manage-btn" onClick={onManage}>Manage</button>
</div>
),
}))
// Mock CreateContent component
vi.mock('../create-content', () => ({
default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'new_field' })}>Save</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
<button data-testid="close-btn" onClick={onClose}>Close</button>
</div>
),
}))
describe('SelectMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Select Metadata</button>
describe('Rendering', () => {
it('should render without crashing', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
it('should render trigger element', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
it('should not render SelectMetadata before opening', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.queryByTestId('select-metadata')).not.toBeInTheDocument()
})
it('should pass dataset metadata to SelectMetadata', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
expect(screen.getByTestId('list-count')).toHaveTextContent('2')
})
})
describe('User Interactions', () => {
it('should toggle open state when trigger is clicked', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
})
it('should call onSelect and close when item is selected', () => {
const handleSelect = vi.fn()
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={handleSelect}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('select-item'))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'field_one',
type: DataType.string,
})
})
it('should switch to create step when new button is clicked', async () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
})
it('should call onManage when manage button is clicked', () => {
const handleManage = vi.fn()
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={handleManage}
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('manage-btn'))
expect(handleManage).toHaveBeenCalled()
})
})
describe('Create Flow', () => {
it('should switch back to select when back is clicked in create step', async () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
// Go to create step
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
// Go back to select step
fireEvent.click(screen.getByTestId('back-btn'))
await waitFor(() => {
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
})
})
it('should call onSave and return to select step when save is clicked', async () => {
const handleSave = vi.fn().mockResolvedValue(undefined)
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={handleSave}
onManage={vi.fn()}
/>,
)
// Go to create step
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
// Save new metadata
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'new_field',
})
})
})
})
describe('Props', () => {
it('should accept custom popupPlacement', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
popupPlacement="bottom-start"
/>,
)
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
it('should accept custom popupOffset', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
popupOffset={{ mainAxis: 10, crossAxis: 5 }}
/>,
)
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle different datasetIds', () => {
const { rerender } = render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
rerender(
<SelectMetadataModal
datasetId="dataset-2"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
it('should handle empty trigger', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={<span data-testid="empty-trigger" />}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('empty-trigger')).toBeInTheDocument()
})
})
})

View File

@@ -1,326 +0,0 @@
import type { MetadataItem } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../../types'
import SelectMetadata from '../select-metadata'
type IconProps = {
className?: string
}
// Mock getIcon utility
vi.mock('../../utils/get-icon', () => ({
getIcon: () => (props: IconProps) => <span data-testid="icon" className={props.className}>Icon</span>,
}))
describe('SelectMetadata', () => {
const mockList: MetadataItem[] = [
{ id: '1', name: 'field_one', type: DataType.string },
{ id: '2', name: 'field_two', type: DataType.number },
{ id: '3', name: 'field_three', type: DataType.time },
]
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render search input', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render all metadata items', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
expect(screen.getByText('field_three')).toBeInTheDocument()
})
it('should render new action button', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })).toBeInTheDocument()
})
it('should render manage action button', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })).toBeInTheDocument()
})
it('should display type for each item', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
expect(screen.getAllByText(DataType.time).length).toBeGreaterThan(0)
})
})
describe('Search Functionality', () => {
it('should filter items based on search query', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'one' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
})
it('should be case insensitive search', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'ONE' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
})
it('should show all items when search is cleared', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
// Search for something
fireEvent.change(searchInput, { target: { value: 'one' } })
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
// Clear search
fireEvent.change(searchInput, { target: { value: '' } })
expect(screen.getByText('field_two')).toBeInTheDocument()
})
it('should show no results when search matches nothing', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'xyz' } })
expect(screen.queryByText('field_one')).not.toBeInTheDocument()
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onSelect with item data when item is clicked', () => {
const handleSelect = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={handleSelect}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /field_one/i }))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'field_one',
type: DataType.string,
})
})
it('should call onNew when new button is clicked', () => {
const handleNew = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={handleNew}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }))
expect(handleNew).toHaveBeenCalled()
})
it('should call onManage when manage button is clicked', () => {
const handleManage = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={handleManage}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }))
expect(handleManage).toHaveBeenCalled()
})
})
describe('Empty State', () => {
it('should render empty list', () => {
const { container } = render(
<SelectMetadata
list={[]}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should still show new and manage buttons with empty list', () => {
render(
<SelectMetadata
list={[]}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling', () => {
const { container } = render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toHaveClass('w-[320px]', 'rounded-xl')
})
})
describe('Edge Cases', () => {
it('should handle single item list', () => {
const singleItem: MetadataItem[] = [
{ id: '1', name: 'only_one', type: DataType.string },
]
render(
<SelectMetadata
list={singleItem}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('only_one')).toBeInTheDocument()
})
it('should handle item with long name', () => {
const longNameItem: MetadataItem[] = [
{ id: '1', name: 'this_is_a_very_long_field_name_that_might_overflow', type: DataType.string },
]
render(
<SelectMetadata
list={longNameItem}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('this_is_a_very_long_field_name_that_might_overflow')).toBeInTheDocument()
})
it('should handle rapid search input changes', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
// Rapid typing
fireEvent.change(searchInput, { target: { value: 'f' } })
fireEvent.change(searchInput, { target: { value: 'fi' } })
fireEvent.change(searchInput, { target: { value: 'fie' } })
fireEvent.change(searchInput, { target: { value: 'fiel' } })
fireEvent.change(searchInput, { target: { value: 'field' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
expect(screen.getByText('field_three')).toBeInTheDocument()
})
})
})

View File

@@ -1,10 +1,7 @@
'use client'
import type { FC } from 'react'
import type { BuiltInMetadataItem } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { PopoverTitle } from '@langgenius/dify-ui/popover'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@@ -21,12 +18,12 @@ export type Props = {
onBack?: () => void
}
const CreateContent: FC<Props> = ({
export function CreateContent({
onClose = noop,
hasBack,
onBack,
onSave,
}) => {
}: Props) {
const { t } = useTranslation()
const [type, setType] = useState(DataType.string)
@@ -46,7 +43,7 @@ const CreateContent: FC<Props> = ({
}, [onSave, type, name])
return (
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-3 pt-3.5 pb-4 shadow-xl">
<div className="px-3 pt-3.5 pb-4">
{hasBack && (
<button
type="button"
@@ -58,9 +55,9 @@ const CreateContent: FC<Props> = ({
</button>
)}
<div className="mb-1 flex h-6 items-center justify-between">
<PopoverTitle className="system-xl-semibold text-text-primary">
<div className="system-xl-semibold text-text-primary">
{t(`${i18nPrefix}.title`, { ns: 'dataset' })}
</PopoverTitle>
</div>
{!hasBack && (
<button
type="button"
@@ -95,6 +92,7 @@ const CreateContent: FC<Props> = ({
</Field>
<Field label={t(`${i18nPrefix}.name`, { ns: 'dataset' })}>
<Input
aria-label={t(`${i18nPrefix}.name`, { ns: 'dataset' })}
value={name}
onChange={handleNameChange}
placeholder={t(`${i18nPrefix}.namePlaceholder`, { ns: 'dataset' })}
@@ -119,4 +117,3 @@ const CreateContent: FC<Props> = ({
</div>
)
}
export default React.memo(CreateContent)

View File

@@ -1,9 +1,8 @@
'use client'
import type { FC } from 'react'
import type { Props as CreateContentProps } from './create-content'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import CreateContent from './create-content'
import { CreateContent } from './create-content'
type Props = {
open: boolean
@@ -12,13 +11,13 @@ type Props = {
popupLeft?: number
} & CreateContentProps
const CreateMetadataModal: FC<Props> = ({
export function CreateMetadataModal({
open,
setOpen,
trigger,
popupLeft = 20,
...createContentProps
}) => {
}: Props) {
const triggerElement = React.isValidElement(trigger)
? trigger
: <button type="button">{trigger}</button>
@@ -33,7 +32,7 @@ const CreateMetadataModal: FC<Props> = ({
placement="left-start"
sideOffset={popupLeft}
alignOffset={-38}
popupClassName="border-none bg-transparent shadow-none"
popupClassName="w-[320px]"
>
<CreateContent {...createContentProps} onClose={() => setOpen(false)} onBack={() => setOpen(false)} />
</PopoverContent>
@@ -41,4 +40,3 @@ const CreateMetadataModal: FC<Props> = ({
)
}
export default React.memo(CreateMetadataModal)

View File

@@ -32,8 +32,8 @@ import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import CreateModal from '@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal'
import { getIcon } from '../utils/get-icon'
import { CreateMetadataModal } from '@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal'
import { getIconClassName } from '../utils/get-icon'
import Field from './field'
const i18nPrefix = 'metadata.datasetMetadata'
@@ -64,7 +64,7 @@ const Item: FC<ItemProps> = ({
onDelete,
}) => {
const { t } = useTranslation()
const Icon = getIcon(payload.type)
const iconClassName = getIconClassName(payload.type)
const handleRename = useCallback(() => {
onRename?.()
@@ -97,7 +97,7 @@ const Item: FC<ItemProps> = ({
)}
>
<div className="flex h-full items-center space-x-1 text-text-tertiary">
<Icon className="size-4 shrink-0" />
<span className={cn(iconClassName, 'size-4 shrink-0')} aria-hidden="true" />
<div className="max-w-[250px] truncate system-sm-medium text-text-primary">{payload.name}</div>
<div className="shrink-0 system-xs-regular">{payload.type}</div>
</div>
@@ -222,7 +222,7 @@ const DatasetMetadataDrawer: FC<Props> = ({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
<div className="system-sm-regular text-text-tertiary">{t(`${i18nPrefix}.description`, { ns: 'dataset' })}</div>
<CreateModal
<CreateMetadataModal
open={open}
setOpen={setOpen}
trigger={(
@@ -283,6 +283,7 @@ const DatasetMetadataDrawer: FC<Props> = ({
<Field label={t(`${i18nPrefix}.name`, { ns: 'dataset' })} className="mt-4">
<Input
aria-label={t(`${i18nPrefix}.name`, { ns: 'dataset' })}
value={templeName}
onChange={e => setTempleName(e.target.value)}
placeholder={t(`${i18nPrefix}.namePlaceholder`, { ns: 'dataset' })}

View File

@@ -0,0 +1,285 @@
'use client'
import type { ComboboxRootChangeEventDetails, Placement } from '@langgenius/dify-ui/combobox'
import type { BuiltInMetadataItem, MetadataItem } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxClear,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxSeparator,
} from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDatasetMetaData } from '@/service/knowledge/use-metadata'
import { getIconClassName } from '../utils/get-icon'
import { CreateContent } from './create-content'
const i18nPrefix = 'metadata.selectMetadata'
const PickerView = {
select: 'select',
create: 'create',
} as const
type PickerView = typeof PickerView[keyof typeof PickerView]
export type DatasetMetadataPickerProps = {
datasetId: string
placement?: Placement
sideOffset?: number
alignOffset?: number
onSelectMetadata: (metadata: MetadataItem) => void
onCreateMetadata: (metadata: BuiltInMetadataItem) => void | Promise<void>
onOpenMetadataManagement: () => void
}
function getMetadataLabel(metadata: MetadataItem) {
return metadata.name
}
function getMetadataValue(metadata: MetadataItem) {
return metadata.id
}
function isSameMetadata(item: MetadataItem, value: MetadataItem) {
return item.id === value.id
}
function metadataFilter(metadata: MetadataItem, query: string) {
return metadata.name.toLowerCase().includes(query.toLowerCase())
}
export function DatasetMetadataPicker({
datasetId,
placement = 'left-start',
sideOffset = -38,
alignOffset = 4,
onSelectMetadata,
onCreateMetadata,
onOpenMetadataManagement,
}: DatasetMetadataPickerProps) {
const { t } = useTranslation()
const { data: datasetMetaData } = useDatasetMetaData(datasetId)
const metadataItems = datasetMetaData?.doc_metadata ?? []
const [open, setOpen] = useState(false)
const [view, setView] = useState<PickerView>(PickerView.select)
const [query, setQuery] = useState('')
const resetPicker = () => {
setView(PickerView.select)
setQuery('')
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen)
resetPicker()
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setQuery(inputValue)
}
const handleMetadataChange = (metadata: MetadataItem | null) => {
if (!metadata)
return
onSelectMetadata({
id: metadata.id,
name: metadata.name,
type: metadata.type,
})
setOpen(false)
resetPicker()
}
const handleCreateMetadata = async (metadata: BuiltInMetadataItem) => {
try {
await onCreateMetadata(metadata)
resetPicker()
}
catch {
// Keep the create view open so callers can surface validation feedback and the user can correct the input.
}
}
const handleOpenManagement = () => {
setOpen(false)
resetPicker()
onOpenMetadataManagement()
}
return (
<Popover
open={open}
onOpenChange={handleOpenChange}
>
<PopoverTrigger
render={(
<button
type="button"
aria-label={t('metadata.addMetadata', { ns: 'dataset' })}
aria-expanded={open}
className="flex h-6 w-full cursor-pointer items-center justify-center rounded-md border-0 bg-components-button-tertiary-bg px-2 py-0 text-xs font-medium text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover focus-visible:bg-components-button-tertiary-bg-hover"
>
<span className="flex min-w-0 items-center justify-center gap-1">
<span className="i-ri-add-line size-3.5 shrink-0 text-components-button-tertiary-text" aria-hidden="true" />
<span className="truncate text-components-button-tertiary-text">{t('metadata.addMetadata', { ns: 'dataset' })}</span>
</span>
</button>
)}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="w-[320px] bg-components-panel-bg-blur backdrop-blur-[5px]"
>
{view === PickerView.select
? (
<Combobox<MetadataItem>
value={null}
items={metadataItems}
inputValue={query}
onInputValueChange={handleInputValueChange}
onValueChange={handleMetadataChange}
itemToStringLabel={getMetadataLabel}
itemToStringValue={getMetadataValue}
isItemEqualToValue={isSameMetadata}
filter={metadataFilter}
>
<MetadataPickerSelectPanel
query={query}
onNewMetadata={() => {
setView(PickerView.create)
setQuery('')
}}
onOpenMetadataManagement={handleOpenManagement}
/>
</Combobox>
)
: (
<CreateContent
onSave={handleCreateMetadata}
hasBack
onBack={resetPicker}
onClose={resetPicker}
/>
)}
</PopoverContent>
</Popover>
)
}
function MetadataPickerSelectPanel({
query,
onNewMetadata,
onOpenMetadataManagement,
}: {
query: string
onNewMetadata: () => void
onOpenMetadataManagement: () => void
}) {
const { t } = useTranslation()
return (
<>
<div className="p-2 pb-1">
<ComboboxInputGroup>
<span className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t(`${i18nPrefix}.search`, { ns: 'dataset' })}
placeholder={t(`${i18nPrefix}.search`, { ns: 'dataset' })}
className="pl-2"
/>
{query && (
<ComboboxClear
aria-label={t('operation.clear', { ns: 'common' })}
/>
)}
</ComboboxInputGroup>
</div>
<ComboboxList>
{(metadata: MetadataItem) => (
<MetadataOption key={metadata.id} metadata={metadata} />
)}
</ComboboxList>
<ComboboxEmpty>
{t('noData', { ns: 'common' })}
</ComboboxEmpty>
<ComboboxSeparator />
<MetadataPickerActions
onNewMetadata={onNewMetadata}
onOpenMetadataManagement={onOpenMetadataManagement}
/>
</>
)
}
function MetadataOption({
metadata,
}: {
metadata: MetadataItem
}) {
const iconClassName = getIconClassName(metadata.type)
return (
<ComboboxItem value={metadata}>
<ComboboxItemText className="flex items-center gap-1.5 px-0">
<span className={cn(iconClassName, 'size-3.5 shrink-0')} aria-hidden="true" />
<span className="min-w-0 grow truncate">{metadata.name}</span>
</ComboboxItemText>
<span className="shrink-0 system-xs-regular text-text-tertiary">
{metadata.type}
</span>
</ComboboxItem>
)
}
function MetadataPickerActions({
onNewMetadata,
onOpenMetadataManagement,
}: {
onNewMetadata: () => void
onOpenMetadataManagement: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between p-1">
<button
type="button"
className={cn(
'flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 text-left text-text-secondary outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active',
)}
onClick={onNewMetadata}
>
<span className="i-ri-add-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<span className="truncate system-sm-medium">{t(`${i18nPrefix}.newAction`, { ns: 'dataset' })}</span>
</button>
<div className="flex h-8 shrink-0 items-center text-text-secondary">
<div className="mx-1 h-3 w-px bg-divider-regular" />
<button
type="button"
className={cn(
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 text-left text-text-secondary outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active',
)}
onClick={onOpenMetadataManagement}
>
<span className="system-sm-medium">{t(`${i18nPrefix}.manageAction`, { ns: 'dataset' })}</span>
<span className="i-ri-arrow-right-up-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
</button>
</div>
</div>
)
}

View File

@@ -1,96 +0,0 @@
'use client'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { FC } from 'react'
import type { BuiltInMetadataItem, MetadataItem } from '../types'
import type { Props as CreateContentProps } from './create-content'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useDatasetMetaData } from '@/service/knowledge/use-metadata'
import CreateContent from './create-content'
import SelectMetadata from './select-metadata'
type Props = {
datasetId: string
popupPlacement?: Placement
popupOffset?: { mainAxis: number, crossAxis: number }
onSelect: (data: MetadataItem) => void
trigger: React.ReactNode
onManage: () => void
} & CreateContentProps
const Step = {
select: 'select',
create: 'create',
} as const
type Step = typeof Step[keyof typeof Step]
const SelectMetadataModal: FC<Props> = ({
datasetId,
popupPlacement = 'left-start',
popupOffset = { mainAxis: -38, crossAxis: 4 },
trigger,
onSelect,
onSave,
onManage,
}) => {
const { data: datasetMetaData } = useDatasetMetaData(datasetId)
const [open, setOpen] = useState(false)
const [step, setStep] = useState<Step>(Step.select)
const triggerElement = React.isValidElement(trigger)
? trigger
: <button type="button">{trigger}</button>
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen)
setStep(Step.select)
}, [])
const handleSave = useCallback(async (data: BuiltInMetadataItem) => {
await onSave(data)
setStep(Step.select)
}, [onSave])
return (
<Popover
open={open}
onOpenChange={handleOpenChange}
>
<PopoverTrigger render={triggerElement as React.ReactElement} />
<PopoverContent
placement={popupPlacement}
sideOffset={popupOffset.mainAxis}
alignOffset={popupOffset.crossAxis}
popupClassName="border-none bg-transparent shadow-none"
>
{step === Step.select
? (
<SelectMetadata
onSelect={(data) => {
onSelect(data)
setOpen(false)
}}
list={datasetMetaData?.doc_metadata || []}
onNew={() => setStep(Step.create)}
onManage={() => {
setOpen(false)
setStep(Step.select)
onManage()
}}
/>
)
: (
<CreateContent
onSave={handleSave}
hasBack
onBack={() => setStep(Step.select)}
onClose={() => setStep(Step.select)}
/>
)}
</PopoverContent>
</Popover>
)
}
export default React.memo(SelectMetadataModal)

View File

@@ -1,93 +0,0 @@
'use client'
import type { FC } from 'react'
import type { MetadataItem } from '../types'
import { RiAddLine, RiArrowRightUpLine } from '@remixicon/react'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input'
import { getIcon } from '../utils/get-icon'
const i18nPrefix = 'metadata.selectMetadata'
type Props = {
list: MetadataItem[]
onSelect: (data: MetadataItem) => void
onNew: () => void
onManage: () => void
}
const SelectMetadata: FC<Props> = ({
list: notFilteredList,
onSelect,
onNew,
onManage,
}) => {
const { t } = useTranslation()
const [query, setQuery] = useState('')
const list = useMemo(() => {
if (!query)
return notFilteredList
return notFilteredList.filter((item) => {
return item.name.toLowerCase().includes(query.toLowerCase())
})
}, [query, notFilteredList])
return (
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-2 pb-0 shadow-lg backdrop-blur-[5px]">
<SearchInput
className="mx-2"
value={query}
onChange={setQuery}
placeholder={t(`${i18nPrefix}.search`, { ns: 'dataset' })}
/>
<div className="mt-2">
{list.map((item) => {
const Icon = getIcon(item.type)
return (
<button
type="button"
key={item.id}
className="mx-1 flex h-6 cursor-pointer items-center justify-between rounded-md border-none bg-transparent px-3 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => onSelect({
id: item.id,
name: item.name,
type: item.type,
})}
>
<div className="flex h-full w-0 grow items-center text-text-secondary">
<Icon className="mr-[5px] size-3.5 shrink-0" aria-hidden="true" />
<div className="w-0 grow truncate system-sm-medium">{item.name}</div>
</div>
<div className="ml-1 shrink-0 system-xs-regular text-text-tertiary">
{item.type}
</div>
</button>
)
})}
</div>
<div className="mt-1 flex justify-between border-t border-divider-subtle p-1">
<button
type="button"
className="flex h-6 cursor-pointer items-center space-x-1 rounded-md border-none bg-transparent px-3 text-left text-text-secondary hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onNew}
>
<RiAddLine className="size-3.5" aria-hidden="true" />
<div className="system-sm-medium">{t(`${i18nPrefix}.newAction`, { ns: 'dataset' })}</div>
</button>
<div className="flex h-6 items-center text-text-secondary">
<div className="mr-[3px] h-3 w-px bg-divider-regular"></div>
<button
type="button"
className="flex h-full cursor-pointer items-center rounded-md border-none bg-transparent px-1.5 text-left text-text-secondary hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onManage}
>
<div className="mr-1 system-sm-medium">{t(`${i18nPrefix}.manageAction`, { ns: 'dataset' })}</div>
<RiArrowRightUpLine className="size-3.5" aria-hidden="true" />
</button>
</div>
</div>
</div>
)
}
export default React.memo(SelectMetadata)

View File

@@ -377,7 +377,7 @@ describe('MetadataDocument', () => {
setTempList,
})
const { container } = render(
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
@@ -385,14 +385,12 @@ describe('MetadataDocument', () => {
/>,
)
const inputs = container.querySelectorAll('input')
if (inputs.length > 0) {
fireEvent.change(inputs[0]!, { target: { value: 'new value' } })
const valueInput = screen.getByDisplayValue('Value 1')
fireEvent.change(valueInput, { target: { value: 'new value' } })
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
}
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
})
it('should have handleAddMetaData function available', () => {
@@ -445,7 +443,7 @@ describe('MetadataDocument', () => {
setTempList,
})
const { container } = render(
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
@@ -453,13 +451,11 @@ describe('MetadataDocument', () => {
/>,
)
const inputs = container.querySelectorAll('input')
if (inputs.length > 0) {
fireEvent.change(inputs[0]!, { target: { value: 'updated' } })
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
}
const valueInput = screen.getByDisplayValue('Value 1')
fireEvent.change(valueInput, { target: { value: 'updated' } })
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
})
it('should pass onDelete callback to InfoGroup', async () => {

View File

@@ -1,30 +1,32 @@
import type { MetadataItemWithValue } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataType } from '../../types'
import InfoGroup from '../info-group'
type SelectModalProps = {
trigger: React.ReactNode
onSelect: (item: MetadataItemWithValue) => void
onSave: (data: { name: string, type: DataType }) => void
onManage: () => void
}
type FieldProps = {
label: string
children: React.ReactNode
}
type InputCombinedProps = {
value: string | number | null
onChange: (value: string | number) => void
type: DataType
}
const { mockRouterPush } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
push: mockRouterPush,
}),
}))
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'test', type: DataType.string },
],
},
}),
}))
@@ -39,16 +41,11 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
// Mock AddMetadataButton
vi.mock('../../add-metadata-button', () => ({
default: () => <button>Add Metadata</button>,
}))
// Mock InputCombined
vi.mock('../../edit-metadata-batch/input-combined', () => ({
default: ({ value, onChange, type }: InputCombinedProps) => (
<input
data-testid="input-combined"
aria-label={`Metadata ${type} value`}
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
@@ -56,28 +53,6 @@ vi.mock('../../edit-metadata-batch/input-combined', () => ({
),
}))
// Mock SelectMetadataModal
vi.mock('../../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-metadata-modal">
{trigger}
<button onClick={() => onSelect({ id: '1', name: 'test', type: DataType.string, value: null })}>Select</button>
<button onClick={() => onSave({ name: 'new_field', type: DataType.string })}>Save</button>
<button onClick={onManage}>Manage</button>
</div>
),
}))
// Mock Field
vi.mock('../field', () => ({
default: ({ label, children }: FieldProps) => (
<div data-testid="field">
<span data-testid="field-label">{label}</span>
<div data-testid="field-content">{children}</div>
</div>
),
}))
describe('InfoGroup', () => {
const mockList: MetadataItemWithValue[] = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
@@ -85,6 +60,10 @@ describe('InfoGroup', () => {
{ id: '3', name: 'built-in', type: DataType.time, value: 1609459200 },
]
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
@@ -111,8 +90,9 @@ describe('InfoGroup', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} />,
)
const fields = screen.getAllByTestId('field')
expect(fields).toHaveLength(3)
expect(screen.getByText('field_one'))!.toBeInTheDocument()
expect(screen.getByText('field_two'))!.toBeInTheDocument()
expect(screen.getByText('built-in'))!.toBeInTheDocument()
})
it('should render tooltip when titleTooltip is provided', () => {
@@ -133,33 +113,33 @@ describe('InfoGroup', () => {
dataSetId="ds-1"
list={mockList}
title="Test"
headerRight={<button data-testid="header-right-btn">Action</button>}
headerRight={<button>Action</button>}
/>,
)
expect(screen.getByTestId('header-right-btn'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Action' }))!.toBeInTheDocument()
})
})
describe('Edit Mode', () => {
it('should render add metadata button when isEdit is true', () => {
it('should render dataset metadata picker when isEdit is true', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
expect(screen.getByRole('button', { name: 'Add Metadata' }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))!.toBeInTheDocument()
})
it('should not render add metadata button when isEdit is false', () => {
it('should not render dataset metadata picker when isEdit is false', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />,
)
expect(screen.queryByRole('button', { name: 'Add Metadata' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'dataset.metadata.addMetadata' })).not.toBeInTheDocument()
})
it('should render input combined for each item in edit mode', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
const inputs = screen.getAllByTestId('input-combined')
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(3)
})
@@ -178,7 +158,7 @@ describe('InfoGroup', () => {
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onChange={handleChange} />,
)
const inputs = screen.getAllByTestId('input-combined')
const inputs = screen.getAllByRole('textbox')
fireEvent.change(inputs[0]!, { target: { value: 'New Value' } })
expect(handleChange).toHaveBeenCalled()
@@ -195,29 +175,34 @@ describe('InfoGroup', () => {
expect(handleDelete).toHaveBeenCalled()
})
it('should call onSelect when metadata is selected', () => {
it('should call onSelect when metadata is selected', async () => {
const handleSelect = vi.fn()
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />,
)
fireEvent.click(screen.getByRole('button', { name: 'Select' }))
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
fireEvent.click(await screen.findByRole('option', { name: /test/ }))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'test',
type: DataType.string,
value: null,
})
})
it('should call onAdd when new metadata is saved', () => {
it('should call onAdd when new metadata is saved', async () => {
const handleAdd = vi.fn()
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />,
)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
fireEvent.click(await screen.findByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }))
fireEvent.change(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), {
target: { value: 'new_field' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(handleAdd).toHaveBeenCalledWith({
name: 'new_field',
@@ -225,14 +210,15 @@ describe('InfoGroup', () => {
})
})
it('should navigate to documents page when manage is clicked', () => {
it('should navigate to documents page when manage is clicked', async () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
fireEvent.click(screen.getByRole('button', { name: 'Manage' }))
fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
fireEvent.click(await screen.findByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }))
expect(screen.getByRole('button', { name: 'Manage' }))!.toBeInTheDocument()
expect(mockRouterPush).toHaveBeenCalledWith('/datasets/ds-1/documents')
})
})
@@ -299,7 +285,7 @@ describe('InfoGroup', () => {
)
// The mock formatTime returns formatted date
// The mock formatTime returns formatted date
expect(screen.getByTestId('field-content'))!.toBeInTheDocument()
expect(screen.getByText('field'))!.toBeInTheDocument()
})
})
@@ -318,7 +304,7 @@ describe('InfoGroup', () => {
render(
<InfoGroup dataSetId="ds-1" list={nullList} />,
)
expect(screen.getByTestId('field'))!.toBeInTheDocument()
expect(screen.getByText('field'))!.toBeInTheDocument()
})
it('should handle items with built-in id', () => {
@@ -328,7 +314,7 @@ describe('InfoGroup', () => {
render(
<InfoGroup dataSetId="ds-1" list={builtInList} />,
)
expect(screen.getByTestId('field'))!.toBeInTheDocument()
expect(screen.getByText('field'))!.toBeInTheDocument()
})
})
})

View File

@@ -9,9 +9,8 @@ import Divider from '@/app/components/base/divider'
import { Infotip } from '@/app/components/base/infotip'
import useTimestamp from '@/hooks/use-timestamp'
import { useRouter } from '@/next/navigation'
import AddMetadataButton from '../add-metadata-button'
import InputCombined from '../edit-metadata-batch/input-combined'
import SelectMetadataModal from '../metadata-dataset/select-metadata-modal'
import { DatasetMetadataPicker } from '../metadata-dataset/dataset-metadata-picker'
import { DataType, isShowManageMetadataLocalStorageKey } from '../types'
import Field from './field'
@@ -76,14 +75,11 @@ const InfoGroup: FC<Props> = ({
<div className={cn('mt-3 space-y-1', contentClassName)}>
{isEdit && (
<div>
<SelectMetadataModal
<DatasetMetadataPicker
datasetId={dataSetId}
trigger={
<AddMetadataButton />
}
onSelect={data => onSelect?.(data as MetadataItemWithValue)}
onSave={data => onAdd?.(data)}
onManage={handleMangeMetadata}
onSelectMetadata={data => onSelect?.(data as MetadataItemWithValue)}
onCreateMetadata={data => onAdd?.(data)}
onOpenMetadataManagement={handleMangeMetadata}
/>
{list.length > 0 && <Divider className="my-3" bgStyle="gradient" />}
</div>

View File

@@ -1,45 +1,44 @@
import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react'
import { describe, expect, it } from 'vitest'
import { DataType } from '../../types'
import { getIcon } from '../get-icon'
import { getIconClassName } from '../get-icon'
describe('getIcon', () => {
describe('getIconClassName', () => {
describe('Rendering', () => {
it('should return RiTextSnippet for DataType.string', () => {
const result = getIcon(DataType.string)
expect(result).toBe(RiTextSnippet)
it('should return text snippet icon class for DataType.string', () => {
const result = getIconClassName(DataType.string)
expect(result).toBe('i-ri-text-snippet')
})
it('should return RiHashtag for DataType.number', () => {
const result = getIcon(DataType.number)
expect(result).toBe(RiHashtag)
it('should return hashtag icon class for DataType.number', () => {
const result = getIconClassName(DataType.number)
expect(result).toBe('i-ri-hashtag')
})
it('should return RiTimeLine for DataType.time', () => {
const result = getIcon(DataType.time)
expect(result).toBe(RiTimeLine)
it('should return time line icon class for DataType.time', () => {
const result = getIconClassName(DataType.time)
expect(result).toBe('i-ri-time-line')
})
})
describe('Edge Cases', () => {
it('should return RiTextSnippet as fallback for unknown type', () => {
const result = getIcon('unknown' as DataType)
expect(result).toBe(RiTextSnippet)
it('should return text snippet class as fallback for unknown type', () => {
const result = getIconClassName('unknown' as DataType)
expect(result).toBe('i-ri-text-snippet')
})
it('should return RiTextSnippet for undefined type', () => {
const result = getIcon(undefined as unknown as DataType)
expect(result).toBe(RiTextSnippet)
it('should return text snippet class for undefined type', () => {
const result = getIconClassName(undefined as unknown as DataType)
expect(result).toBe('i-ri-text-snippet')
})
it('should return RiTextSnippet for null type', () => {
const result = getIcon(null as unknown as DataType)
expect(result).toBe(RiTextSnippet)
it('should return text snippet class for null type', () => {
const result = getIconClassName(null as unknown as DataType)
expect(result).toBe('i-ri-text-snippet')
})
it('should return RiTextSnippet for empty string type', () => {
const result = getIcon('' as DataType)
expect(result).toBe(RiTextSnippet)
it('should return text snippet class for empty string type', () => {
const result = getIconClassName('' as DataType)
expect(result).toBe('i-ri-text-snippet')
})
})
})

View File

@@ -1,10 +1,11 @@
import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react'
import { DataType } from '../types'
export const getIcon = (type: DataType) => {
return ({
[DataType.string]: RiTextSnippet,
[DataType.number]: RiHashtag,
[DataType.time]: RiTimeLine,
}[type] || RiTextSnippet)
const metadataTypeIconClassMap: Record<DataType, string> = {
[DataType.string]: 'i-ri-text-snippet',
[DataType.number]: 'i-ri-hashtag',
[DataType.time]: 'i-ri-time-line',
}
export function getIconClassName(type: DataType) {
return metadataTypeIconClassMap[type] ?? metadataTypeIconClassMap[DataType.string]
}