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:
Coding On Star
2026-03-24 17:51:07 +08:00
committed by GitHub
parent 67d5c9d148
commit a408a5d87e
75 changed files with 9402 additions and 2507 deletions

View File

@ -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()
})
})

View File

@ -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>