mirror of
https://github.com/langgenius/dify.git
synced 2026-01-28 22:53:23 +08:00
286 lines
9.8 KiB
TypeScript
286 lines
9.8 KiB
TypeScript
'use client'
|
|
import type { FC, ReactNode } from 'react'
|
|
import React, { useCallback, useMemo } from 'react'
|
|
import { RiDeleteBinLine } from '@remixicon/react'
|
|
import Input from '@/app/components/base/input'
|
|
import Checkbox from '@/app/components/base/checkbox'
|
|
import { SimpleSelect } from '@/app/components/base/select'
|
|
import cn from '@/utils/classnames'
|
|
|
|
// 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 GenericTable: FC<GenericTableProps> = ({
|
|
title,
|
|
columns,
|
|
data,
|
|
onChange,
|
|
readonly = false,
|
|
placeholder,
|
|
emptyRowData,
|
|
className,
|
|
showHeader = false,
|
|
}) => {
|
|
const DELETE_COL_PADDING_CLASS = 'pr-[56px]'
|
|
const DELETE_COL_WIDTH_CLASS = 'w-[56px]'
|
|
|
|
// 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 lastHasContent = !isEmptyRow(data[data.length - 1])
|
|
if (lastHasContent)
|
|
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
|
|
|
return rows
|
|
}, [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])
|
|
|
|
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 (
|
|
<Input
|
|
value={(value as string) || ''}
|
|
onChange={e => handleChange(e.target.value)}
|
|
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',
|
|
'system-sm-regular text-text-secondary placeholder:text-text-tertiary',
|
|
)}
|
|
/>
|
|
)
|
|
|
|
case 'select':
|
|
return (
|
|
<SimpleSelect
|
|
items={column.options || []}
|
|
defaultValue={value as string | undefined}
|
|
onSelect={item => handleChange(item.value)}
|
|
disabled={readonly}
|
|
placeholder={column.placeholder}
|
|
// 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 px-0 text-text-secondary',
|
|
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
|
|
)}
|
|
optionWrapClassName="rounded-md"
|
|
notClearable
|
|
/>
|
|
)
|
|
|
|
case 'switch':
|
|
return (
|
|
<Checkbox
|
|
id={`${column.key}-${String(dataIndex ?? 'v')}`}
|
|
checked={Boolean(value)}
|
|
onCheck={() => handleChange(!value)}
|
|
disabled={readonly}
|
|
className="!h-4 !w-4 shadow-none"
|
|
/>
|
|
)
|
|
|
|
case 'custom':
|
|
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
|
|
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
const renderTable = () => {
|
|
return (
|
|
<div className="rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs">
|
|
{showHeader && (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-2 border-b border-divider-subtle px-3 py-2',
|
|
!readonly && DELETE_COL_PADDING_CLASS,
|
|
)}
|
|
>
|
|
{columns.map(column => (
|
|
<div
|
|
key={column.key}
|
|
className={cn(
|
|
'text-xs uppercase text-text-tertiary',
|
|
column.width && column.width.startsWith('w-') ? 'shrink-0' : 'min-w-0 flex-1 overflow-hidden',
|
|
column.width,
|
|
)}
|
|
>
|
|
<span className="truncate">{column.title}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="divide-y divide-divider-subtle">
|
|
{displayRows.map(({ row, dataIndex, isVirtual }, renderIndex) => {
|
|
// Determine emptiness for UI-only controls visibility
|
|
const isEmpty = Object.values(row).every(value =>
|
|
value === '' || value === null || value === undefined || value === false,
|
|
)
|
|
|
|
const rowKey = `row-${renderIndex}`
|
|
|
|
return (
|
|
<div
|
|
key={rowKey}
|
|
className={cn(
|
|
'group relative flex items-center gap-2 px-3 py-1.5 hover:bg-components-panel-on-panel-item-bg-hover',
|
|
!readonly && DELETE_COL_PADDING_CLASS,
|
|
)}
|
|
>
|
|
{columns.map(column => (
|
|
<div
|
|
key={column.key}
|
|
className={cn(
|
|
'relative',
|
|
column.width && column.width.startsWith('w-') ? 'shrink-0' : 'min-w-0 flex-1',
|
|
column.width,
|
|
// Avoid children overflow when content is long in flexible columns
|
|
!(column.width && column.width.startsWith('w-')) && 'overflow-hidden',
|
|
)}
|
|
>
|
|
{renderCell(column, row, dataIndex)}
|
|
</div>
|
|
))}
|
|
{!readonly && data.length > 1 && !isEmpty && !isVirtual && (
|
|
<div
|
|
className={cn(
|
|
'pointer-events-none absolute inset-y-0 right-0 hidden items-center justify-end rounded-lg bg-gradient-to-l from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent pr-2 group-hover:pointer-events-auto group-hover:flex',
|
|
DELETE_COL_WIDTH_CLASS,
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => dataIndex !== null && removeRow(dataIndex)}
|
|
className="text-text-tertiary opacity-70 transition-colors hover:text-text-destructive hover:opacity-100"
|
|
aria-label="Delete row"
|
|
>
|
|
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Show placeholder only when readonly and there is no data configured
|
|
const showPlaceholder = readonly && data.length === 0
|
|
|
|
return (
|
|
<div className={className}>
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h4 className="text-sm font-medium text-text-secondary">{title}</h4>
|
|
</div>
|
|
|
|
{showPlaceholder ? (
|
|
<div className="py-8 text-center text-sm text-text-tertiary">
|
|
{placeholder}
|
|
</div>
|
|
) : (
|
|
renderTable()
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default React.memo(GenericTable)
|