import { forwardRef, useImperativeHandle, useRef, useEffect, useLayoutEffect, useState, useCallback, useMemo, createElement } from 'react' import { flushSync } from 'react-dom' import { Table, Skeleton, Row, Col, Flex, Spin, Button, Space, Form, Splitter, Card } from 'antd' import { LoadingOutlined } from '@ant-design/icons' import PropTypes from 'prop-types' import { useMediaQuery } from 'react-responsive' import { useContext } from 'react' import { ApiServerContext } from '../context/ApiServerContext' import config from '../../../config' import loglevel from 'loglevel' import { getModelProperties, getModelByName } from '../../../database/ObjectModels' import ObjectProperty from './ObjectProperty' import ObjectCard from './ObjectCard' import FilterSidebar from './FilterSidebar' import SortSidebar from './SortSidebar' import CheckIcon from '../../Icons/CheckIcon' import { useLocation } from 'react-router-dom' import QuestionCircleIcon from '../../Icons/QuestionCircleIcon' import { AuthContext } from '../context/AuthContext' import { ElectronContext } from '../context/ElectronContext' import { useActions } from '../context/ActionsContext' import ActionsIcon from '../../Icons/ActionsIcon' import FilterIcon from '../../Icons/FilterIcon' import ScrollBox from './ScrollBox' import SimplePropertyFilter from './SimplePropertyFilter' import QuickPropertyFilters from './QuickPropertyFilters' import FilterInput from './FilterInput' import { getActiveFilterValues, useTableState, useTableStatePersistence } from '../context/TableStateContext' import { hasActionPermission } from '../../../database/permissions' import Tooltip from './Tooltip' import { ObjectTableFilterContext } from './ObjectTableFilterContext' import ObjectListViewContext from '../context/ObjectListViewContext' const logger = loglevel.getLogger('DasboardTable') logger.setLevel(config.logLevel) const SCROLL_THRESHOLD = 50 const SKELETON_HEIGHT = 49.5 const getCardColSpan = (containerWidth) => { if (containerWidth >= 2980) return 2 if (containerWidth >= 1780) return 4 if (containerWidth >= 1080) return 8 if (containerWidth >= 680) return 12 return 24 } const toFilterExpression = (values) => { if (!values?.length) return undefined const parts = values.map((value) => { if (value && typeof value === 'object') { return String(value._id ?? value.type ?? JSON.stringify(value)) } return String(value) }) return parts.length === 1 ? parts[0] : parts.join('|') } const fromFilterExpression = (expr) => { if (expr === undefined || expr === null || expr === '') return null if (typeof expr === 'string' && expr.includes('|')) { return expr.split('|').filter((part) => part !== '') } return [expr] } const areValuesEqual = (v1, v2) => { const id1 = v1 && typeof v1 === 'object' && v1._id ? v1._id : v1 const id2 = v2 && typeof v2 === 'object' && v2._id ? v2._id : v2 return String(id1) === String(id2) } const getChangedKeys = (existing, updated) => { if (!updated || typeof updated !== 'object') return [] return Object.keys(updated).filter((key) => { if (key === '_id' || key === 'objectType') return false if (!existing) return true return !areValuesEqual(existing[key], updated[key]) }) } const updateAffectsFilterOrSort = ( changedKeys, filter, masterFilter, sorter ) => { if (!changedKeys.length) return false const filterKeys = new Set([ ...Object.keys(filter || {}), ...Object.keys(masterFilter || {}) ]) if (changedKeys.some((key) => filterKeys.has(key))) return true if (sorter?.field && changedKeys.includes(sorter.field)) return true return false } const ColumnFilterDropdown = ({ setSelectedKeys, selectedKeys, confirm, clearFilters, visible, propertyName, propertyLabel, modelType, filter = {}, masterFilter = {} }) => { const [expression, setExpression] = useState('') const [draft, setDraft] = useState(selectedKeys || []) const selectedKeysRef = useRef(selectedKeys) selectedKeysRef.current = selectedKeys // Re-sync from the applied sidebar/column filter each time the dropdown opens useEffect(() => { if (!visible) return const keys = selectedKeysRef.current || [] setDraft(keys) setExpression(toFilterExpression(keys) ?? '') }, [visible]) const handleDraftChange = (next) => { setDraft(next) setExpression(toFilterExpression(next) ?? '') } const handleExpressionChange = (text) => { setExpression(text) setDraft(fromFilterExpression(text) || []) } const applyFilter = () => { const trimmed = expression.trim() if (!trimmed) { clearFilters() } else if (draft?.length) { setSelectedKeys(draft) } else { setSelectedKeys([trimmed]) } confirm() } return (
) } ColumnFilterDropdown.propTypes = { setSelectedKeys: PropTypes.func, selectedKeys: PropTypes.array, confirm: PropTypes.func, clearFilters: PropTypes.func, visible: PropTypes.bool, propertyName: PropTypes.string, propertyLabel: PropTypes.string, modelType: PropTypes.string, filter: PropTypes.object, masterFilter: PropTypes.object } const RowForm = ({ record, isEditing, onRegister, children }) => { const [form] = Form.useForm() useEffect(() => { if (isEditing && record && !record.isSkeleton) { form.setFieldsValue(record) onRegister(record._id, form) } return () => { if (record?._id) onRegister(record._id, null) } }, [isEditing, record, form, onRegister]) return (
{children}
) } RowForm.propTypes = { record: PropTypes.object, isEditing: PropTypes.bool, onRegister: PropTypes.func, children: PropTypes.node } const EditableRow = ({ record, isEditing, onRegister, ...props }) => { return ( ) } EditableRow.propTypes = { record: PropTypes.object, isEditing: PropTypes.bool, onRegister: PropTypes.func } const ObjectTable = forwardRef( ( { type, pageSize = 25, scrollHeight = 'calc(var(--unit-100vh) - 258px)', onDataChange, initialPage = 1, cards = false, visibleColumns = {}, masterFilter = {}, size = 'middle', onStateChange, showFilterSidebar = false, showSortSidebar = false, expandHeight = false, showActions = true, saveFilterInSession = false, saveFilterInUrl = false, useFilterInSession = false, useFilterInUrl = false, saveSortInSession = false, saveSortInUrl = false, useSortInSession = false, useSortInUrl = false, onRowAction }, ref ) => { const { token, userProfile } = useContext(AuthContext) const { isElectron } = useContext(ElectronContext) const { callAction } = useActions() const objectListView = useContext(ObjectListViewContext) const listViewId = objectListView?.listViewId ?? null const listViewFilter = objectListView?.listViewFilter ?? null const listViewSort = objectListView?.listViewSort ?? null const activeObjectView = objectListView?.activeObjectView ?? null const onObjectViewFilterSortChange = objectListView?.isEditing ? objectListView.handleFilterSortChange : null const onStateChangeRef = useRef(onStateChange) useEffect(() => { onStateChangeRef.current = onStateChange }, [onStateChange]) const { fetchObjects, connected, subscribeToAllObjectUpdates, subscribeToObjectTypeUpdates, updateMultipleObjects, setObjectActivity, clearObjectActivity } = useContext(ApiServerContext) const isMobile = useMediaQuery({ maxWidth: 768 }) const location = useLocation() const { getPersistedFilter, getPersistedSorter, getAllTabPersistedFilter, getAllTabPersistedSorter, persistFilter, persistSort, persistTableState, persistAllTabTableState, registerPageFilter, registerPageSorter, registerObjectListFilter, registerObjectListSorter } = useTableStatePersistence({ scope: type, viewId: listViewId, pagePath: location.pathname, saveFilterInSession, saveFilterInUrl, useFilterInSession, useFilterInUrl, saveSortInSession, saveSortInUrl, useSortInSession, useSortInUrl }) const { getViewFromUrl } = useTableState() var adjustedScrollHeight = scrollHeight if (isMobile) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)' } if (cards) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)' } if (isElectron) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 238px)' } if (isMobile && isElectron) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)' } if (cards && isElectron) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 258px)' } const tableRef = useRef(null) const model = getModelByName(type) const activeFilterRef = useRef({}) const tableSorterRef = useRef({}) const listViewIdRef = useRef(listViewId) const listViewFilterRef = useRef(listViewFilter) const activeObjectViewRef = useRef(activeObjectView) activeObjectViewRef.current = activeObjectView const onObjectViewFilterSortChangeRef = useRef(onObjectViewFilterSortChange) onObjectViewFilterSortChangeRef.current = onObjectViewFilterSortChange const [sidebarFilter, setSidebarFilter] = useState({}) const [tableSorter, setTableSorter] = useState({}) const [initialized, setInitialized] = useState(false) // Keep refs in sync with props. applyViewState may update them earlier in // the same tick; props remain the source of truth after commit. useEffect(() => { listViewIdRef.current = listViewId listViewFilterRef.current = listViewFilter || {} }, [listViewId, listViewFilter]) const lastAppliedViewKeyRef = useRef(null) const buildEffectiveFilter = useCallback((userFilter, viewFilter = listViewFilterRef.current) => { const active = getActiveFilterValues(userFilter) const viewId = listViewIdRef.current if (!viewId) return active return { ...getActiveFilterValues(viewFilter || {}), ...active } }, []) // Table state const [pages, setPages] = useState([]) const pagesRef = useRef(pages) const tableData = useMemo(() => { const items = pages.flatMap((page) => page.items) const seen = new Set() return items.filter((item) => { const id = item?._id if (id == null) return true if (seen.has(id)) { logger.warn('Duplicate table row omitted:', id) return false } seen.add(id) return true }) }, [pages]) const [loading, setLoading] = useState(true) const [lazyLoading, setLazyLoading] = useState(false) const [isEditing, setIsEditing] = useState(false) const [editLoading, setEditLoading] = useState(false) const rowFormsRef = useRef({}) const registerForm = useCallback((id, form) => { if (form) { rowFormsRef.current[id] = form } else { delete rowFormsRef.current[id] } }, []) useEffect(() => { onStateChangeRef.current?.({ isEditing, editLoading }) }, [isEditing, editLoading]) const updateEventHandlerRef = useRef() const subscribeToObjectTypeUpdatesRef = useRef(null) const subscribedTypeRef = useRef(null) const subscribeToAllObjectUpdatesRef = useRef(null) const newEventHandlerRef = useRef() const subscriptionFilterRef = useRef() const effectiveSorterRef = useRef({}) const reloadRef = useRef(null) const silentReloadRef = useRef(null) const subscribeToObjectTypeUpdatesFnRef = useRef( subscribeToObjectTypeUpdates ) const prevValuesRef = useRef({ type, masterFilter }) const getMasterFilter = useCallback(() => masterFilter || {}, [masterFilter]) const resolveSorter = useCallback((userSorter) => { if (userSorter?.field && userSorter?.order) { return { field: userSorter.field, order: userSorter.order } } return {} }, []) const rowActions = model.actions?.filter((action) => action.row == true) || [] const createSkeletonData = useCallback( (pageNum) => { return Array(pageSize) .fill(null) .map((_, index) => ({ _id: `skeleton-${pageNum}-${index}`, isSkeleton: true })) }, [pageSize] ) const createSkeletonPage = useCallback( (pageNum) => ({ pageNum, items: createSkeletonData(pageNum), isSkeletonPage: true }), [createSkeletonData] ) const loadingPagesRef = useRef(new Set()) const pendingScrollAnchorRef = useRef(null) const dataLoadGenerationRef = useRef(0) const [tableListKey, setTableListKey] = useState(0) // Suppress Ant Design Table onChange while we remount / set filter+sort // programmatically. Remounting with controlled filteredValue often fires // onChange with all-null filters (sorter intact) and would wipe the view. const isInternalTableChangeRef = useRef(false) const internalTableChangeClearRafRef = useRef(null) const suppressTableChange = useCallback(() => { isInternalTableChangeRef.current = true if (internalTableChangeClearRafRef.current != null) { cancelAnimationFrame(internalTableChangeClearRafRef.current) } // Double rAF: wait until after the remounted Table commits and any // synchronous/mount onChange from Ant Design has run. internalTableChangeClearRafRef.current = requestAnimationFrame(() => { internalTableChangeClearRafRef.current = requestAnimationFrame(() => { internalTableChangeClearRafRef.current = null isInternalTableChangeRef.current = false }) }) }, []) const beginDataReload = useCallback(() => { dataLoadGenerationRef.current += 1 loadingPagesRef.current.clear() pendingScrollAnchorRef.current = null pagesRef.current = [] suppressTableChange() setTableListKey((key) => key + 1) return dataLoadGenerationRef.current }, [suppressTableChange]) const isStaleDataLoad = useCallback((generation) => { return generation !== dataLoadGenerationRef.current }, []) const clearTablePages = useCallback(() => { pagesRef.current = [] flushSync(() => { setPages([]) }) }, []) const setTablePages = useCallback((nextPages) => { pagesRef.current = nextPages setPages(nextPages) }, []) const mergeLoadedPage = useCallback((currentPages, loadedPage) => { const withoutSkeletons = currentPages.filter( (page) => !page.isSkeletonPage ) const withoutDuplicate = withoutSkeletons.filter( (page) => page.pageNum !== loadedPage.pageNum ) return [...withoutDuplicate, loadedPage].sort( (a, b) => a.pageNum - b.pageNum ) }, []) const renderActions = (objectData) => { return ( {rowActions.map((action, index) => { const denied = !hasActionPermission(userProfile, model, action.name) var disabled = denied if (action.disabled) { if (typeof action.disabled === 'function') { disabled = denied || action.disabled({ ...objectData, _user: userProfile }) } else { disabled = denied || action.disabled } } return (