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 (
)
}
return (
)
}
}))
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: {property.label || property.name},
dataIndex: property.name,
key: property.name,
width: property.columnWidth || getDefaultWidth(property.type),
render: (_text, record) => {
return (
{record[property.name]}
{rollupLabel && {rollupLabel}:}
)
}
}
})
}, [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 ? (
) : null
const tableComponent = (
}}
pagination={false}
size={size}
rowKey={resolvedRowKey}
scroll={scrollConfig}
locale={{ emptyText }}
className={hasRollups ? 'child-table-rollups' : 'child-table'}
{...tableProps}
/>
{rollupTable}
)
// 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 (
{(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 (
)
}
}))
const listTable = (
record.key ?? record._index}
scroll={scrollConfig}
locale={{ emptyText }}
className={hasRollups ? 'child-table-rollups' : 'child-table'}
{...tableProps}
/>
{rollupTable}
)
const handleAddListItem = () => {
const newItem = {}
resolvedProperties.forEach((property) => {
if (property?.name) {
newItem[property.name] = null
}
})
add(newItem)
}
return (
}
onClick={handleAddListItem}
/>
{listTable}
)
}}
)
}
if (isEditing === true) {
return (
}
onClick={handleAddItem}
/>
{tableComponent}
)
}
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