'use client' import type { FC, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { RiDeleteBinLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useMemo } from 'react' import Checkbox from '@/app/components/base/checkbox' import Input from '@/app/components/base/input' import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' // Tiny utility to judge whether a cell value is effectively present const isPresent = (v: unknown): boolean => { if (typeof v === 'string') return v.trim() !== '' return !(v === '' || v === null || v === undefined || v === false) } // Column configuration types for table components export type ColumnType = 'input' | 'select' | 'switch' | 'custom' export type SelectOption = { name: string value: string } export type ColumnConfig = { key: string title: string type: ColumnType width?: string // CSS class for width (e.g., 'w-1/2', 'w-[140px]') placeholder?: string options?: SelectOption[] // For select type render?: (value: unknown, row: GenericTableRow, index: number, onChange: (value: unknown) => void) => ReactNode required?: boolean } export type GenericTableRow = { [key: string]: unknown } type GenericTableProps = { title: string columns: ColumnConfig[] data: GenericTableRow[] onChange: (data: GenericTableRow[]) => void readonly?: boolean placeholder?: string emptyRowData: GenericTableRow // Template for new empty rows className?: string showHeader?: boolean // Whether to show column headers } // Internal type for stable mapping between rendered rows and data indices type DisplayRow = { row: GenericTableRow dataIndex: number | null // null indicates the trailing UI-only row 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((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 ( { 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', 'system-sm-regular text-text-secondary placeholder:text-text-quaternary', )} /> ) } const renderSelectCell = ( column: ColumnConfig, value: unknown, readonly: boolean, handleChange: (value: unknown) => void, ) => { const options = column.options || [] const selectedOption = options.find(option => option.value === value) ?? null return ( ) } const renderSwitchCell = ( column: ColumnConfig, value: unknown, dataIndex: number | null, readonly: boolean, handleChange: (value: unknown) => void, ) => { return (
handleChange(!value)} disabled={readonly} />
) } 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 = ({ title, columns, data, onChange, readonly = false, placeholder, emptyRowData, className, showHeader = false, }) => { const displayRows = useMemo(() => { return getDisplayRows(data, emptyRowData, readonly) }, [data, emptyRowData, readonly]) const removeRow = useCallback((dataIndex: number) => { if (readonly) return if (dataIndex < 0 || dataIndex >= data.length) return // ignore virtual rows const newData = data.filter((_, i) => i !== dataIndex) onChange(newData) }, [data, readonly, onChange]) const updateRow = useCallback((dataIndex: number | null, key: string, value: unknown) => { if (readonly) return if (dataIndex !== null && dataIndex < data.length) { // Editing existing configured row const newData = [...data] newData[dataIndex] = { ...newData[dataIndex], [key]: value } onChange(newData) return } // Editing the trailing UI-only empty row: create a new configured row const newRow = { ...emptyRowData, [key]: value } const next = [...data, newRow] onChange(next) }, [data, emptyRowData, onChange, readonly]) // Determine the primary identifier column just once const primaryKey = useMemo(() => getPrimaryKey(columns), [columns]) const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => { const value = row[column.key] const handleChange = (newValue: unknown) => updateRow(dataIndex, column.key, newValue) switch (column.type) { case 'input': return renderInputCell(column, value, readonly, handleChange) case 'select': return renderSelectCell(column, value, readonly, handleChange) case 'switch': return renderSwitchCell(column, value, dataIndex, readonly, handleChange) case 'custom': return renderCustomCell(column, value, row, dataIndex, handleChange) default: return null } } const renderTable = () => { return (
{showHeader && (
{columns.map((column, index) => (
{column.title}
))}
)}
{displayRows.map(({ row, dataIndex, isVirtual: _isVirtual }, renderIndex) => { const rowKey = `row-${renderIndex}` // Check if primary identifier column has content const primaryValue = row[primaryKey] const hasContent = isPresent(primaryValue) return (
{columns.map((column, columnIndex) => (
{renderCell(column, row, dataIndex)}
))} {!readonly && dataIndex !== null && hasContent && (
)}
) })}
) } // Show placeholder only when readonly and there is no data configured const showPlaceholder = readonly && data.length === 0 return (

{title}

{showPlaceholder ? (
{placeholder}
) : ( renderTable() )}
) } export default React.memo(GenericTable)