580 lines
16 KiB
JavaScript
580 lines
16 KiB
JavaScript
import { useMemo, useEffect, useRef, useState, useCallback } from 'react'
|
|
import PropTypes from 'prop-types'
|
|
import { Table, Skeleton, Card, Button, Flex, Typography, Modal } from 'antd'
|
|
import PlusIcon from '../../Icons/PlusIcon'
|
|
import ObjectProperty from './ObjectProperty'
|
|
import { LoadingOutlined } from '@ant-design/icons'
|
|
import BinIcon from '../../Icons/BinIcon'
|
|
const { Text, Link, Title } = Typography
|
|
|
|
const DEFAULT_COLUMN_WIDTHS = {
|
|
text: 200,
|
|
number: 120,
|
|
dateTime: 200,
|
|
state: 200,
|
|
id: 180,
|
|
bool: 120,
|
|
tags: 200
|
|
}
|
|
|
|
const getDefaultWidth = (type) => {
|
|
return DEFAULT_COLUMN_WIDTHS[type] || 200
|
|
}
|
|
|
|
const resolveChangeValue = (val, type) => {
|
|
if (type === 'bool') return val
|
|
if (val?.target && typeof val.target === 'object') {
|
|
return val.target.value
|
|
}
|
|
return val
|
|
}
|
|
|
|
const createSkeletonRows = (rowCount, keyPrefix, keyName) => {
|
|
return Array.from({ length: rowCount }).map((_, index) => {
|
|
const skeletonKey = `${keyPrefix}-${index}`
|
|
const row = {
|
|
isSkeleton: true,
|
|
_objectChildTableKey: skeletonKey
|
|
}
|
|
|
|
if (typeof keyName === 'string') {
|
|
row[keyName] = skeletonKey
|
|
}
|
|
|
|
return row
|
|
})
|
|
}
|
|
|
|
const ObjectChildTable = ({
|
|
maxWidth = '100%',
|
|
name,
|
|
properties = [],
|
|
columns = [],
|
|
visibleColumns = {},
|
|
canAddRemove = true,
|
|
objectData = null,
|
|
scrollHeight = 240,
|
|
size = 'small',
|
|
loading = false,
|
|
rowKey = '_id',
|
|
skeletonRows = 5,
|
|
additionalColumns = [],
|
|
emptyText = 'No items',
|
|
isEditing = false,
|
|
value = [],
|
|
rollups = [],
|
|
onChange,
|
|
minimal = false,
|
|
label = '',
|
|
...tableProps
|
|
}) => {
|
|
const mainTableWrapperRef = useRef(null)
|
|
const rollupTableWrapperRef = useRef(null)
|
|
const generatedRowKeysRef = useRef(new WeakMap())
|
|
const generatedRowKeyCountRef = useRef(0)
|
|
const [minimalModelOpen, setMinimalModelOpen] = useState(false)
|
|
const getFallbackRowKey = (record) => {
|
|
if (!record || typeof record !== 'object') {
|
|
return `object-child-table-row-${String(record)}`
|
|
}
|
|
|
|
if (record._objectChildTableKey != null) {
|
|
return record._objectChildTableKey
|
|
}
|
|
|
|
const existing = generatedRowKeysRef.current.get(record)
|
|
if (existing) return existing
|
|
|
|
const generated = `object-child-table-row-${generatedRowKeyCountRef.current}`
|
|
generatedRowKeyCountRef.current += 1
|
|
generatedRowKeysRef.current.set(record, generated)
|
|
return generated
|
|
}
|
|
|
|
const getResolvedRecordKey = useCallback(
|
|
(record) => {
|
|
if (typeof rowKey === 'function') {
|
|
return rowKey(record) ?? getFallbackRowKey(record)
|
|
}
|
|
|
|
if (typeof rowKey === 'string' && rowKey.length > 0) {
|
|
return record?.[rowKey] ?? getFallbackRowKey(record)
|
|
}
|
|
|
|
return getFallbackRowKey(record)
|
|
},
|
|
[rowKey]
|
|
)
|
|
|
|
const propertyMap = useMemo(() => {
|
|
const map = new Map()
|
|
properties.forEach((property) => {
|
|
if (property?.name) {
|
|
map.set(property.name, property)
|
|
}
|
|
})
|
|
return map
|
|
}, [properties])
|
|
|
|
const orderedPropertyNames = useMemo(() => {
|
|
if (columns && columns.length > 0) {
|
|
return columns
|
|
}
|
|
return properties.map((property) => property.name).filter(Boolean)
|
|
}, [columns, properties])
|
|
|
|
const resolvedProperties = useMemo(() => {
|
|
const explicit = orderedPropertyNames
|
|
.map((name) => propertyMap.get(name))
|
|
.filter(Boolean)
|
|
|
|
const remaining = properties.filter(
|
|
(property) => !orderedPropertyNames.includes(property.name)
|
|
)
|
|
|
|
return [...explicit, ...remaining].filter((property) => {
|
|
if (!property?.name) return false
|
|
if (
|
|
visibleColumns &&
|
|
Object.prototype.hasOwnProperty.call(visibleColumns, property.name)
|
|
) {
|
|
return visibleColumns[property.name] !== false
|
|
}
|
|
return true
|
|
})
|
|
}, [orderedPropertyNames, propertyMap, properties, visibleColumns])
|
|
|
|
// When used inside antd Form.Item without Form.List, `value` will be the controlled array.
|
|
const itemsSource = useMemo(() => {
|
|
return value ?? []
|
|
}, [value])
|
|
|
|
const tableColumns = useMemo(() => {
|
|
const propertyColumns = resolvedProperties.map((property) => ({
|
|
title: property.label || property.name,
|
|
dataIndex: property.name,
|
|
key: property.name,
|
|
width: property.columnWidth || getDefaultWidth(property.type),
|
|
render: (_text, record, index) => {
|
|
if (record?.isSkeleton) {
|
|
return (
|
|
<Skeleton.Input active size='small' style={{ width: '100%' }} />
|
|
)
|
|
}
|
|
|
|
const handleCellChange = (newVal) => {
|
|
const resolved = resolveChangeValue(newVal, property.type)
|
|
const currentItems = Array.isArray(itemsSource)
|
|
? [...itemsSource]
|
|
: []
|
|
const existingRowKey = getResolvedRecordKey(record)
|
|
const updatedItem = {
|
|
...currentItems[index],
|
|
[property.name]: resolved
|
|
}
|
|
// Preserve fallback row identity across immutable updates so the row
|
|
// is not remounted while typing (which causes input focus loss).
|
|
generatedRowKeysRef.current.set(updatedItem, existingRowKey)
|
|
currentItems[index] = updatedItem
|
|
if (typeof onChange === 'function') {
|
|
onChange(currentItems)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ObjectProperty
|
|
{...property}
|
|
longId={false}
|
|
objectData={record}
|
|
isEditing={isEditing}
|
|
useFormItem={false}
|
|
name={undefined}
|
|
value={record[property.name]}
|
|
onChange={handleCellChange}
|
|
/>
|
|
)
|
|
}
|
|
}))
|
|
|
|
const deleteColumn =
|
|
isEditing && canAddRemove
|
|
? {
|
|
title: '',
|
|
key: 'delete',
|
|
width: 10,
|
|
fixed: 'right',
|
|
render: (_text, record, index) => {
|
|
if (record?.isSkeleton) {
|
|
return null
|
|
}
|
|
return (
|
|
<Button
|
|
type='text'
|
|
danger
|
|
size='small'
|
|
icon={<BinIcon />}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
const currentItems = Array.isArray(itemsSource)
|
|
? itemsSource
|
|
: []
|
|
|
|
// Use record's unique identifier if available, otherwise use index
|
|
let newItems
|
|
if (typeof rowKey === 'string' && record[rowKey] != null) {
|
|
// Use the unique key to find and remove the item
|
|
newItems = currentItems.filter(
|
|
(item) => item[rowKey] !== record[rowKey]
|
|
)
|
|
} else if (typeof rowKey === 'function') {
|
|
// If rowKey is a function, find the item by comparing resolved keys.
|
|
// Ant Design deprecates index-based rowKey callbacks.
|
|
const recordKey = getResolvedRecordKey(record)
|
|
newItems = currentItems.filter((item) => {
|
|
const itemKey = getResolvedRecordKey(item)
|
|
return itemKey !== recordKey
|
|
})
|
|
} else {
|
|
// Fallback to index-based removal
|
|
newItems = currentItems.filter((_, i) => i !== index)
|
|
}
|
|
|
|
if (typeof onChange === 'function') {
|
|
onChange(newItems)
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
: null
|
|
|
|
return [
|
|
...propertyColumns,
|
|
...additionalColumns,
|
|
...(deleteColumn ? [deleteColumn] : [])
|
|
]
|
|
}, [
|
|
resolvedProperties,
|
|
additionalColumns,
|
|
isEditing,
|
|
canAddRemove,
|
|
itemsSource,
|
|
onChange,
|
|
rowKey,
|
|
getResolvedRecordKey
|
|
])
|
|
|
|
const skeletonData = useMemo(() => {
|
|
return createSkeletonRows(
|
|
skeletonRows,
|
|
'object-child-table-skeleton',
|
|
typeof rowKey === 'string' ? rowKey : null
|
|
)
|
|
}, [skeletonRows, rowKey])
|
|
|
|
const dataSource = useMemo(() => {
|
|
if (loading && (!itemsSource || itemsSource.length === 0)) {
|
|
return skeletonData
|
|
}
|
|
return itemsSource
|
|
}, [itemsSource, loading, skeletonData])
|
|
|
|
const resolvedRowKey = (record) => getResolvedRecordKey(record)
|
|
|
|
const scrollConfig =
|
|
scrollHeight != null
|
|
? { y: scrollHeight, x: 'max-content' }
|
|
: { x: 'max-content' }
|
|
|
|
const handleAddItem = () => {
|
|
const newItem = {}
|
|
|
|
resolvedProperties.forEach((property) => {
|
|
if (
|
|
property?.name &&
|
|
!Object.prototype.hasOwnProperty.call(newItem, property.name)
|
|
) {
|
|
newItem[property.name] = null
|
|
}
|
|
})
|
|
|
|
const currentItems = Array.isArray(itemsSource) ? itemsSource : []
|
|
const newItems = [...currentItems, newItem]
|
|
|
|
console.log('newItems', newItems)
|
|
if (typeof onChange === 'function') {
|
|
onChange(newItems)
|
|
}
|
|
}
|
|
|
|
const rollupDataSource = useMemo(() => {
|
|
if (!rollups || rollups.length === 0) return []
|
|
|
|
// Use value from form/props, or fall back to objectData when entering edit mode
|
|
// (form may not have populated the field yet)
|
|
const itemsForRollup =
|
|
value ?? (name && objectData ? objectData[name] : null) ?? []
|
|
|
|
// Build parent object with children array for rollup functions (e.g. objectData.parts)
|
|
const updatedObjectData = { ...objectData }
|
|
if (name) {
|
|
updatedObjectData[name] = itemsForRollup
|
|
}
|
|
|
|
// Single summary row where each rollup value is placed under
|
|
// the column that matches its `property` field.
|
|
const summaryRow = {
|
|
_objectChildTableKey: 'object-child-table-rollup-summary'
|
|
}
|
|
|
|
properties.forEach((property) => {
|
|
const rollup = rollups.find(
|
|
(r) => r.property && r.property === property.name
|
|
)
|
|
|
|
if (rollup && typeof rollup.value === 'function') {
|
|
try {
|
|
summaryRow[property.name] = rollup.value(updatedObjectData)
|
|
} catch (e) {
|
|
// Fail quietly but log for debugging
|
|
console.error('Error computing rollup', rollup.name, e)
|
|
summaryRow[property.name] = null
|
|
}
|
|
} else {
|
|
summaryRow[property.name] = null
|
|
}
|
|
})
|
|
|
|
return [summaryRow]
|
|
}, [properties, rollups, objectData, value, name])
|
|
|
|
const rollupColumns = useMemo(() => {
|
|
const propertyColumns = properties.map((property, index) => {
|
|
const nextProperty = properties[index + 1]
|
|
var nextRollup = null
|
|
if (nextProperty) {
|
|
nextRollup = rollups?.find(
|
|
(r) => r.property && r.property === nextProperty.name
|
|
)
|
|
}
|
|
const rollupLabel = nextRollup?.label
|
|
return {
|
|
title: <Text>{property.label || property.name}</Text>,
|
|
dataIndex: property.name,
|
|
key: property.name,
|
|
width: property.columnWidth || getDefaultWidth(property.type),
|
|
render: (_text, record) => {
|
|
return (
|
|
<Flex justify={'space-between'}>
|
|
<Text>
|
|
{record[property.name] !== undefined &&
|
|
record[property.name] !== null && (
|
|
<>
|
|
{property?.prefix}
|
|
{record[property.name]}
|
|
{property?.suffix}
|
|
</>
|
|
)}
|
|
</Text>
|
|
{rollupLabel && <Text type='secondary'>{rollupLabel}:</Text>}
|
|
</Flex>
|
|
)
|
|
}
|
|
}
|
|
})
|
|
const blankDeleteColumn =
|
|
isEditing && canAddRemove
|
|
? {
|
|
title: '',
|
|
key: 'delete',
|
|
width: 40,
|
|
fixed: 'right',
|
|
render: () => {
|
|
return <Flex></Flex>
|
|
}
|
|
}
|
|
: null
|
|
return [
|
|
...propertyColumns,
|
|
...(blankDeleteColumn ? [blankDeleteColumn] : [])
|
|
]
|
|
}, [properties, rollups, isEditing, canAddRemove])
|
|
|
|
const hasRollups = useMemo(
|
|
() => Array.isArray(rollups) && rollups.length > 0,
|
|
[rollups]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!hasRollups || isEditing == null) return
|
|
|
|
const mainWrapper = mainTableWrapperRef.current
|
|
const rollupWrapper = rollupTableWrapperRef.current
|
|
|
|
if (!mainWrapper || !rollupWrapper) return
|
|
|
|
const mainBody =
|
|
mainWrapper.querySelector('.ant-table-body') ||
|
|
mainWrapper.querySelector('.ant-table-content')
|
|
const rollupBody =
|
|
rollupWrapper.querySelector('.ant-table-body') ||
|
|
rollupWrapper.querySelector('.ant-table-content')
|
|
|
|
if (!mainBody || !rollupBody) return
|
|
|
|
let isSyncing = false
|
|
|
|
const syncScroll = (source, target) => {
|
|
if (!target) return
|
|
isSyncing = true
|
|
target.scrollLeft = source.scrollLeft
|
|
window.requestAnimationFrame(() => {
|
|
isSyncing = false
|
|
})
|
|
}
|
|
|
|
const handleMainScroll = () => {
|
|
if (isSyncing) return
|
|
syncScroll(mainBody, rollupBody)
|
|
}
|
|
|
|
const handleRollupScroll = () => {
|
|
if (isSyncing) return
|
|
syncScroll(rollupBody, mainBody)
|
|
}
|
|
|
|
mainBody.addEventListener('scroll', handleMainScroll)
|
|
rollupBody.addEventListener('scroll', handleRollupScroll)
|
|
|
|
return () => {
|
|
mainBody.removeEventListener('scroll', handleMainScroll)
|
|
rollupBody.removeEventListener('scroll', handleRollupScroll)
|
|
}
|
|
}, [hasRollups, isEditing])
|
|
|
|
const rollupTable = hasRollups ? (
|
|
<div ref={rollupTableWrapperRef}>
|
|
<Table
|
|
dataSource={rollupDataSource}
|
|
showHeader={false}
|
|
columns={rollupColumns}
|
|
loading={{ spinning: loading, indicator: null }}
|
|
pagination={false}
|
|
size={size}
|
|
rowKey={resolvedRowKey}
|
|
scroll={scrollConfig}
|
|
locale={{ emptyText }}
|
|
bordered={minimal}
|
|
style={{ maxWidth: minimal ? '100%' : maxWidth, minWidth: 0 }}
|
|
className='rollup-table'
|
|
/>
|
|
</div>
|
|
) : null
|
|
|
|
const tableComponent = (
|
|
<Flex vertical>
|
|
<div ref={mainTableWrapperRef}>
|
|
<Table
|
|
style={{ maxWidth: minimal ? '100%' : maxWidth, minWidth: 0 }}
|
|
dataSource={dataSource}
|
|
columns={tableColumns}
|
|
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
|
size={size}
|
|
rowKey={resolvedRowKey}
|
|
scroll={scrollConfig}
|
|
bordered={minimal}
|
|
locale={{ emptyText }}
|
|
pagination={false}
|
|
className={hasRollups ? 'child-table-rollups' : 'child-table'}
|
|
{...tableProps}
|
|
/>
|
|
</div>
|
|
{rollupTable}
|
|
</Flex>
|
|
)
|
|
|
|
if (isEditing === true) {
|
|
return (
|
|
<Card>
|
|
<Flex vertical gap={'middle'}>
|
|
<Flex justify={'space-between'}>
|
|
<Button>Actions</Button>
|
|
|
|
{canAddRemove && (
|
|
<Button
|
|
type='primary'
|
|
icon={<PlusIcon />}
|
|
onClick={handleAddItem}
|
|
/>
|
|
)}
|
|
</Flex>
|
|
|
|
{tableComponent}
|
|
</Flex>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (minimal == true) {
|
|
return (
|
|
<>
|
|
<Link
|
|
onClick={() => {
|
|
setMinimalModelOpen(true)
|
|
}}
|
|
>
|
|
{value?.length || 0} {value?.length == 1 ? 'item' : 'items'}
|
|
</Link>
|
|
<Modal
|
|
open={minimalModelOpen}
|
|
onCancel={() => setMinimalModelOpen(false)}
|
|
footer={null}
|
|
width='860px'
|
|
>
|
|
<Title
|
|
level={2}
|
|
style={{ marginTop: 0, lineHeight: '0.7', marginBottom: 20 }}
|
|
>
|
|
{label}
|
|
</Title>
|
|
{tableComponent}
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return tableComponent
|
|
}
|
|
|
|
ObjectChildTable.propTypes = {
|
|
name: PropTypes.string,
|
|
properties: PropTypes.arrayOf(
|
|
PropTypes.shape({
|
|
name: PropTypes.string.isRequired,
|
|
label: PropTypes.string,
|
|
type: PropTypes.string
|
|
})
|
|
).isRequired,
|
|
columns: PropTypes.arrayOf(PropTypes.string),
|
|
visibleColumns: PropTypes.object,
|
|
scrollHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
|
size: PropTypes.string,
|
|
loading: PropTypes.bool,
|
|
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
|
skeletonRows: PropTypes.number,
|
|
additionalColumns: PropTypes.arrayOf(PropTypes.object),
|
|
emptyText: PropTypes.node,
|
|
label: PropTypes.string,
|
|
isEditing: PropTypes.bool,
|
|
value: PropTypes.arrayOf(PropTypes.object),
|
|
onChange: PropTypes.func,
|
|
maxWidth: PropTypes.string,
|
|
rollups: PropTypes.arrayOf(PropTypes.object),
|
|
objectData: PropTypes.object,
|
|
canAddRemove: PropTypes.bool,
|
|
minimal: PropTypes.bool
|
|
}
|
|
|
|
export default ObjectChildTable
|