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 (