470 lines
13 KiB
JavaScript
470 lines
13 KiB
JavaScript
import { useMemo, useEffect, useRef } from 'react'
|
|
import PropTypes from 'prop-types'
|
|
import { Table, Skeleton, Card, Button, Flex, Form, Typography } from 'antd'
|
|
import PlusIcon from '../../Icons/PlusIcon'
|
|
import ObjectProperty from './ObjectProperty'
|
|
import { LoadingOutlined } from '@ant-design/icons'
|
|
const { Text } = 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 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%',
|
|
properties = [],
|
|
columns = [],
|
|
visibleColumns = {},
|
|
objectData = null,
|
|
scrollHeight = 240,
|
|
size = 'small',
|
|
loading = false,
|
|
rowKey = '_id',
|
|
skeletonRows = 5,
|
|
additionalColumns = [],
|
|
emptyText = 'No items',
|
|
isEditing = false,
|
|
formListName,
|
|
value = [],
|
|
rollups = [],
|
|
onChange,
|
|
...tableProps
|
|
}) => {
|
|
const mainTableWrapperRef = useRef(null)
|
|
const rollupTableWrapperRef = useRef(null)
|
|
|
|
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])
|
|
|
|
// When used with antd Form.List, grab the form instance so we can read
|
|
// the latest row values and pass them into ObjectProperty as objectData.
|
|
// Assumes this component is rendered within a Form context when editing.
|
|
const formInstance = Form.useFormInstance()
|
|
|
|
const listNamePath = useMemo(() => {
|
|
if (!formListName) return null
|
|
return Array.isArray(formListName) ? formListName : [formListName]
|
|
}, [formListName])
|
|
|
|
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) => {
|
|
if (record?.isSkeleton) {
|
|
return (
|
|
<Skeleton.Input active size='small' style={{ width: '100%' }} />
|
|
)
|
|
}
|
|
return (
|
|
<ObjectProperty
|
|
{...property}
|
|
longId={false}
|
|
objectData={record}
|
|
isEditing={isEditing}
|
|
/>
|
|
)
|
|
}
|
|
}))
|
|
|
|
return [...propertyColumns, ...additionalColumns]
|
|
}, [resolvedProperties, additionalColumns, isEditing])
|
|
|
|
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 =
|
|
typeof rowKey === 'function' ? rowKey : (_record, index) => index
|
|
|
|
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]
|
|
|
|
if (typeof onChange === 'function') {
|
|
onChange(newItems)
|
|
}
|
|
}
|
|
|
|
const rollupDataSource = useMemo(() => {
|
|
if (!rollups || rollups.length === 0) return []
|
|
|
|
// Single summary row where each rollup value is placed under
|
|
// the column that matches its `property` field.
|
|
const summaryRow = {}
|
|
|
|
properties.forEach((property) => {
|
|
const rollup = rollups.find(
|
|
(r) => r.property && r.property === property.name
|
|
)
|
|
|
|
if (rollup && typeof rollup.value === 'function') {
|
|
try {
|
|
const updatedObjectData = { ...objectData }
|
|
console.log('UPDATED OBJECT DATA', value)
|
|
updatedObjectData[property.name] = value
|
|
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])
|
|
|
|
const rollupColumns = useMemo(() => {
|
|
return 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]}</Text>
|
|
{rollupLabel && <Text type='secondary'>{rollupLabel}:</Text>}
|
|
</Flex>
|
|
)
|
|
}
|
|
}
|
|
})
|
|
}, [properties, rollups])
|
|
|
|
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={loading}
|
|
pagination={false}
|
|
size={size}
|
|
rowKey={resolvedRowKey}
|
|
scroll={scrollConfig}
|
|
locale={{ emptyText }}
|
|
style={{ maxWidth }}
|
|
className='rollup-table'
|
|
/>
|
|
</div>
|
|
) : null
|
|
|
|
const tableComponent = (
|
|
<Flex vertical>
|
|
<div ref={mainTableWrapperRef}>
|
|
<Table
|
|
style={{ maxWidth }}
|
|
dataSource={dataSource}
|
|
columns={tableColumns}
|
|
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
|
pagination={false}
|
|
size={size}
|
|
rowKey={resolvedRowKey}
|
|
scroll={scrollConfig}
|
|
locale={{ emptyText }}
|
|
className={hasRollups ? 'child-table-rollups' : 'child-table'}
|
|
{...tableProps}
|
|
/>
|
|
</div>
|
|
{rollupTable}
|
|
</Flex>
|
|
)
|
|
|
|
// When editing and a Form.List name is provided, bind rows via Form.List
|
|
// instead of the manual value/onChange mechanism.
|
|
if (isEditing === true && formListName) {
|
|
return (
|
|
<Form.List name={formListName}>
|
|
{(fields, { add }) => {
|
|
const listDataSource = fields.map((field, index) => ({
|
|
_field: field,
|
|
_index: index,
|
|
key: field.key
|
|
}))
|
|
const listColumns = resolvedProperties.map((property) => ({
|
|
title: property.label || property.name,
|
|
dataIndex: property.name,
|
|
key: property.name,
|
|
width: property.columnWidth || getDefaultWidth(property.type),
|
|
render: (_text, record) => {
|
|
const field = record?._field
|
|
if (!field) return null
|
|
|
|
// Resolve the most up-to-date row data for this index from the form
|
|
let rowObjectData = undefined
|
|
if (formInstance && listNamePath) {
|
|
const namePath = [...listNamePath, field.name]
|
|
rowObjectData = formInstance.getFieldValue(namePath)
|
|
}
|
|
return (
|
|
<ObjectProperty
|
|
{...property}
|
|
// Bind directly to this list item + property via NamePath
|
|
name={[field.name, property.name]}
|
|
longId={false}
|
|
isEditing={true}
|
|
objectData={rowObjectData}
|
|
/>
|
|
)
|
|
}
|
|
}))
|
|
|
|
const listTable = (
|
|
<Flex vertical>
|
|
<div ref={mainTableWrapperRef}>
|
|
<Table
|
|
dataSource={listDataSource}
|
|
columns={[...listColumns, ...additionalColumns]}
|
|
pagination={false}
|
|
size={size}
|
|
loading={loading}
|
|
rowKey={(record) => record.key ?? record._index}
|
|
scroll={scrollConfig}
|
|
locale={{ emptyText }}
|
|
className={hasRollups ? 'child-table-rollups' : 'child-table'}
|
|
{...tableProps}
|
|
/>
|
|
</div>
|
|
{rollupTable}
|
|
</Flex>
|
|
)
|
|
|
|
const handleAddListItem = () => {
|
|
const newItem = {}
|
|
resolvedProperties.forEach((property) => {
|
|
if (property?.name) {
|
|
newItem[property.name] = null
|
|
}
|
|
})
|
|
add(newItem)
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<Flex vertical gap={'middle'}>
|
|
<Flex justify={'space-between'}>
|
|
<Button>Actions</Button>
|
|
<Button
|
|
type='primary'
|
|
icon={<PlusIcon />}
|
|
onClick={handleAddListItem}
|
|
/>
|
|
</Flex>
|
|
{listTable}
|
|
</Flex>
|
|
</Card>
|
|
)
|
|
}}
|
|
</Form.List>
|
|
)
|
|
}
|
|
|
|
if (isEditing === true) {
|
|
return (
|
|
<Card>
|
|
<Flex vertical gap={'middle'}>
|
|
<Flex justify={'space-between'}>
|
|
<Button>Actions</Button>
|
|
<Button
|
|
type='primary'
|
|
icon={<PlusIcon />}
|
|
onClick={handleAddItem}
|
|
/>
|
|
</Flex>
|
|
{tableComponent}
|
|
</Flex>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return tableComponent
|
|
}
|
|
|
|
ObjectChildTable.propTypes = {
|
|
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,
|
|
isEditing: PropTypes.bool,
|
|
formListName: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
|
|
value: PropTypes.arrayOf(PropTypes.object),
|
|
onChange: PropTypes.func,
|
|
maxWidth: PropTypes.string,
|
|
rollups: PropTypes.arrayOf(PropTypes.object),
|
|
objectData: PropTypes.object
|
|
}
|
|
|
|
export default ObjectChildTable
|