import { forwardRef, useImperativeHandle, useRef, useEffect, useLayoutEffect, useState, useCallback, useMemo, createElement } from 'react' import { Table, Skeleton, Row, Col, Flex, 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 ObjectKanban from './ObjectKanban' 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 Spin from './Spin' 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' import { isCardsView, isKanbanView, normalizeViewMode } from './viewModeUtils' import { areValuesEqual } from '../utils/Utils' import LoadingPlaceholder from './LoadingPlaceholder' const logger = loglevel.getLogger('DasboardTable') logger.setLevel(config.logLevel) const SCROLL_THRESHOLD = 50 const SKELETON_HEIGHT = 49.5 const EMPTY_MASTER_FILTER = {} 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 idsEqual = (a, b) => { if (a == null || b == null) return false return String(a).toLowerCase() === String(b).toLowerCase() } const getUpdateKeys = (updated) => { if (!updated || typeof updated !== 'object') return [] return Object.keys(updated).filter( (key) => key !== '_id' && key !== 'objectType' ) } const updateAffectsFilterOrSort = ( existingItem, updatedData, filter, masterFilter, sorter ) => { const updateKeys = getUpdateKeys(updatedData) if (!updateKeys.length) return false const filterKeys = new Set([ ...Object.keys(filter || {}), ...Object.keys(masterFilter || {}) ]) const isAffectingKey = (key) => filterKeys.has(key) || (sorter?.field && key === sorter.field) // Item not in the current fetch: any filter/sort key in the update // may change whether it belongs in this view. if (!existingItem) { return updateKeys.some(isAffectingKey) } // Item already fetched: reload when a filter or sort key's value changed. return updateKeys.some( (key) => isAffectingKey(key) && !areValuesEqual(existingItem[key], updatedData[key]) ) } 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, viewMode: viewModeProp, 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 objectListView = useContext(ObjectListViewContext) const viewMode = normalizeViewMode( viewModeProp ?? objectListView?.viewMode ?? (cards ? { type: 'cards' } : { type: 'list' }) ) const isCards = isCardsView(viewMode) const isKanban = isKanbanView(viewMode) const kanbanRef = useRef(null) const prevViewModeKeyRef = useRef(null) const { token, userProfile } = useContext(AuthContext) const { isElectron } = useContext(ElectronContext) const { callAction } = useActions() const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER 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 (isCards || isKanban) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)' } if (isElectron) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 238px)' } if (isMobile && isElectron) { adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)' } if ((isCards || isKanban) && 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 sidebarViewIdRef = useRef(listViewId) 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 assignSidebarToView = useCallback((viewId) => { sidebarViewIdRef.current = viewId ?? null }, []) const lastAppliedViewKeyRef = useRef(null) const buildEffectiveFilter = useCallback( (userFilter, viewFilter = listViewFilterRef.current) => { const active = getActiveFilterValues(userFilter) const viewId = listViewIdRef.current // While editing a view, the sidebar IS the view filter. Merging the // previous definition would put removed keys back. if (!viewId || activeObjectViewRef.current) 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: resolvedMasterFilter }) const getMasterFilter = useCallback( () => resolvedMasterFilter || EMPTY_MASTER_FILTER, [resolvedMasterFilter] ) 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 = [] 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, actionsDisabled = false) => { 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 }) || actionsDisabled } else { disabled = denied || action.disabled || actionsDisabled } } return (