mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
test(workflow): add helper specs and raise targeted workflow coverage (#33995)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,197 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import GenericTable from '../generic-table'
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
type: 'input' as const,
|
||||
placeholder: 'Name',
|
||||
width: 'w-[140px]',
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
title: 'Enabled',
|
||||
type: 'switch' as const,
|
||||
width: 'w-[80px]',
|
||||
},
|
||||
]
|
||||
|
||||
const advancedColumns = [
|
||||
{
|
||||
key: 'method',
|
||||
title: 'Method',
|
||||
type: 'select' as const,
|
||||
placeholder: 'Choose method',
|
||||
options: [{ name: 'POST', value: 'post' }],
|
||||
width: 'w-[120px]',
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
title: 'Preview',
|
||||
type: 'custom' as const,
|
||||
width: 'w-[120px]',
|
||||
render: (_value: unknown, row: { method?: string }, index: number, onChange: (value: unknown) => void) => (
|
||||
<button type="button" onClick={() => onChange(`${index}:${row.method || 'empty'}`)}>
|
||||
custom-render
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'unsupported',
|
||||
title: 'Unsupported',
|
||||
type: 'unsupported' as never,
|
||||
width: 'w-[80px]',
|
||||
},
|
||||
]
|
||||
|
||||
describe('GenericTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render an empty editable row and append a configured row when typing into the virtual row', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<GenericTable
|
||||
title="Headers"
|
||||
columns={columns}
|
||||
data={[]}
|
||||
emptyRowData={{ name: '', enabled: false }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'my key' } })
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([{ name: 'my_key', enabled: false }])
|
||||
})
|
||||
|
||||
it('should skip intermediate empty rows and blur the current input when enter is pressed', () => {
|
||||
render(
|
||||
<GenericTable
|
||||
title="Headers"
|
||||
columns={columns}
|
||||
data={[
|
||||
{ name: 'alpha', enabled: false },
|
||||
{ name: '', enabled: false },
|
||||
{ name: 'beta', enabled: true },
|
||||
]}
|
||||
emptyRowData={{ name: '', enabled: false }}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs).toHaveLength(3)
|
||||
expect(screen.getAllByRole('button', { name: 'Delete row' })).toHaveLength(2)
|
||||
|
||||
const blurSpy = vi.spyOn(inputs[0], 'blur')
|
||||
fireEvent.keyDown(inputs[0], { key: 'Enter' })
|
||||
expect(blurSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should update existing rows, show delete action, and remove rows by primary key', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<GenericTable
|
||||
title="Headers"
|
||||
columns={columns}
|
||||
data={[{ name: 'alpha', enabled: false }]}
|
||||
emptyRowData={{ name: '', enabled: false }}
|
||||
onChange={onChange}
|
||||
showHeader
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Name')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getAllByRole('checkbox')[0])
|
||||
expect(onChange).toHaveBeenCalledWith([{ name: 'alpha', enabled: true }])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete row' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith([])
|
||||
})
|
||||
|
||||
it('should update select and custom cells for existing rows', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
const ControlledTable = () => {
|
||||
const [data, setData] = useState([{ method: '', preview: '' }])
|
||||
|
||||
return (
|
||||
<GenericTable
|
||||
title="Advanced"
|
||||
columns={advancedColumns}
|
||||
data={data}
|
||||
emptyRowData={{ method: '', preview: '' }}
|
||||
onChange={(nextData) => {
|
||||
onChange(nextData)
|
||||
setData(nextData as { method: string, preview: string }[])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ControlledTable />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Choose method' }))
|
||||
await user.click(await screen.findByText('POST'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])
|
||||
})
|
||||
|
||||
onChange.mockClear()
|
||||
await user.click(screen.getAllByRole('button', { name: 'custom-render' })[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '0:post' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore custom-cell updates when readonly rows are rendered', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<GenericTable
|
||||
title="Advanced"
|
||||
columns={advancedColumns}
|
||||
data={[{ method: 'post', preview: '' }]}
|
||||
emptyRowData={{ method: '', preview: '' }}
|
||||
onChange={onChange}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'custom-render' }))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show readonly placeholder without rendering editable rows', () => {
|
||||
render(
|
||||
<GenericTable
|
||||
title="Headers"
|
||||
columns={columns}
|
||||
data={[]}
|
||||
emptyRowData={{ name: '', enabled: false }}
|
||||
onChange={vi.fn()}
|
||||
readonly
|
||||
placeholder="No data"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No data')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -57,6 +57,126 @@ type DisplayRow = {
|
||||
isVirtual: boolean // whether this row is the extra empty row for adding new items
|
||||
}
|
||||
|
||||
const isEmptyRow = (row: GenericTableRow) => {
|
||||
return Object.values(row).every(v => v === '' || v === null || v === undefined || v === false)
|
||||
}
|
||||
|
||||
const getDisplayRows = (
|
||||
data: GenericTableRow[],
|
||||
emptyRowData: GenericTableRow,
|
||||
readonly: boolean,
|
||||
): DisplayRow[] => {
|
||||
if (readonly)
|
||||
return data.map((row, index) => ({ row, dataIndex: index, isVirtual: false }))
|
||||
|
||||
if (!data.length)
|
||||
return [{ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }]
|
||||
|
||||
const rows = data.reduce<DisplayRow[]>((acc, row, index) => {
|
||||
if (isEmptyRow(row) && index < data.length - 1)
|
||||
return acc
|
||||
|
||||
acc.push({ row, dataIndex: index, isVirtual: false })
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const lastRow = data.at(-1)
|
||||
if (lastRow && !isEmptyRow(lastRow))
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const getPrimaryKey = (columns: ColumnConfig[]) => {
|
||||
return columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
|
||||
}
|
||||
|
||||
const renderInputCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => {
|
||||
if (column.key === 'key' || column.key === 'name')
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
handleChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={column.placeholder}
|
||||
disabled={readonly}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
|
||||
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
|
||||
'text-text-secondary system-sm-regular placeholder:text-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSelectCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={column.options || []}
|
||||
defaultValue={value as string | undefined}
|
||||
onSelect={item => handleChange(item.value)}
|
||||
disabled={readonly}
|
||||
placeholder={column.placeholder}
|
||||
hideChecked={false}
|
||||
notClearable={true}
|
||||
wrapperClassName="h-6 w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
|
||||
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
|
||||
)}
|
||||
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSwitchCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
dataIndex: number | null,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<div className="flex h-7 items-center">
|
||||
<Checkbox
|
||||
id={`${column.key}-${String(dataIndex ?? 'v')}`}
|
||||
checked={Boolean(value)}
|
||||
onCheck={() => handleChange(!value)}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCustomCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
row: GenericTableRow,
|
||||
dataIndex: number | null,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
|
||||
}
|
||||
|
||||
const GenericTable: FC<GenericTableProps> = ({
|
||||
title,
|
||||
columns,
|
||||
@ -68,42 +188,8 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
className,
|
||||
showHeader = false,
|
||||
}) => {
|
||||
// Build the rows to display while keeping a stable mapping to original data
|
||||
const displayRows = useMemo<DisplayRow[]>(() => {
|
||||
// Helper to check empty
|
||||
const isEmptyRow = (r: GenericTableRow) =>
|
||||
Object.values(r).every(v => v === '' || v === null || v === undefined || v === false)
|
||||
|
||||
if (readonly)
|
||||
return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false }))
|
||||
|
||||
const hasData = data.length > 0
|
||||
const rows: DisplayRow[] = []
|
||||
|
||||
if (!hasData) {
|
||||
// Initialize with exactly one empty row when there is no data
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
return rows
|
||||
}
|
||||
|
||||
// Add configured rows, hide intermediate empty ones, keep mapping
|
||||
data.forEach((r, i) => {
|
||||
const isEmpty = isEmptyRow(r)
|
||||
// Skip empty rows except the very last configured row
|
||||
if (isEmpty && i < data.length - 1)
|
||||
return
|
||||
rows.push({ row: r, dataIndex: i, isVirtual: false })
|
||||
})
|
||||
|
||||
// If the last configured row has content, append a trailing empty row
|
||||
const lastRow = data.at(-1)
|
||||
if (!lastRow)
|
||||
return rows
|
||||
const lastHasContent = !isEmptyRow(lastRow)
|
||||
if (lastHasContent)
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
|
||||
return rows
|
||||
return getDisplayRows(data, emptyRowData, readonly)
|
||||
}, [data, emptyRowData, readonly])
|
||||
|
||||
const removeRow = useCallback((dataIndex: number) => {
|
||||
@ -134,9 +220,7 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
}, [data, emptyRowData, onChange, readonly])
|
||||
|
||||
// Determine the primary identifier column just once
|
||||
const primaryKey = useMemo(() => (
|
||||
columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
|
||||
), [columns])
|
||||
const primaryKey = useMemo(() => getPrimaryKey(columns), [columns])
|
||||
|
||||
const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => {
|
||||
const value = row[column.key]
|
||||
@ -144,67 +228,16 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
|
||||
switch (column.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => {
|
||||
// Format variable names (replace spaces with underscores)
|
||||
if (column.key === 'key' || column.key === 'name')
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
handleChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={column.placeholder}
|
||||
disabled={readonly}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
className={cn(
|
||||
// Ghost/inline style: looks like plain text until focus/hover
|
||||
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
|
||||
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
|
||||
'text-text-secondary system-sm-regular placeholder:text-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
return renderInputCell(column, value, readonly, handleChange)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={column.options || []}
|
||||
defaultValue={value as string | undefined}
|
||||
onSelect={item => handleChange(item.value)}
|
||||
disabled={readonly}
|
||||
placeholder={column.placeholder}
|
||||
hideChecked={false}
|
||||
notClearable={true}
|
||||
// wrapper provides compact height, trigger is transparent like text
|
||||
wrapperClassName="h-6 w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
|
||||
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
|
||||
)}
|
||||
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
|
||||
/>
|
||||
)
|
||||
return renderSelectCell(column, value, readonly, handleChange)
|
||||
|
||||
case 'switch':
|
||||
return (
|
||||
<div className="flex h-7 items-center">
|
||||
<Checkbox
|
||||
id={`${column.key}-${String(dataIndex ?? 'v')}`}
|
||||
checked={Boolean(value)}
|
||||
onCheck={() => handleChange(!value)}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return renderSwitchCell(column, value, dataIndex, readonly, handleChange)
|
||||
|
||||
case 'custom':
|
||||
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
|
||||
return renderCustomCell(column, value, row, dataIndex, handleChange)
|
||||
|
||||
default:
|
||||
return null
|
||||
@ -270,6 +303,7 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
className="p-1"
|
||||
aria-label="Delete row"
|
||||
>
|
||||
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user