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 XMarkIcon from '../../Icons/XMarkIcon' 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 FilterInput from './FilterInput' import { getActiveFilterValues, useTableStatePersistence } from '../context/TableStateContext' import { hasActionPermission } from '../../../database/permissions' import Tooltip from './Tooltip' 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 ColumnFilterDropdown = ({ setSelectedKeys, selectedKeys, confirm, clearFilters, visible, propertyName, propertyLabel, modelType }) => { 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() } const resetFilter = () => { setExpression('') setDraft([]) clearFilters() 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 } 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 onStateChangeRef = useRef(onStateChange) useEffect(() => { onStateChangeRef.current = onStateChange }, [onStateChange]) const { fetchObjects, connected, subscribeToObjectUpdates, subscribeToObjectTypeUpdates, updateMultipleObjects, setObjectActivity, clearObjectActivity } = useContext(ApiServerContext) const isMobile = useMediaQuery({ maxWidth: 768 }) const location = useLocation() const { getPersistedFilter, getPersistedSorter, persistFilter, persistSort, persistTableState, registerPageFilter } = useTableStatePersistence({ scope: type, pagePath: location.pathname, saveFilterInSession, saveFilterInUrl, useFilterInSession, useFilterInUrl, saveSortInSession, saveSortInUrl, useSortInSession, useSortInUrl }) 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 [sidebarFilter, setSidebarFilter] = useState({}) const [tableSorter, setTableSorter] = useState({}) const [initialized, setInitialized] = useState(false) // 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 subscribedIdsRef = useRef([]) // const [typeSubscribed, setTypeSubscribed] = useState(false) const unsubscribesRef = useRef([]) const updateEventHandlerRef = useRef() const subscribeToObjectTypeUpdatesRef = useRef(null) const subscribedTypeRef = useRef(null) const newEventHandlerRef = useRef() const subscriptionFilterRef = useRef() const subscribeToObjectTypeUpdatesFnRef = useRef( subscribeToObjectTypeUpdates ) const prevValuesRef = useRef({ type, masterFilter }) 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) const isInternalSortUpdateRef = useRef(false) const beginDataReload = useCallback(() => { dataLoadGenerationRef.current += 1 loadingPagesRef.current.clear() pendingScrollAnchorRef.current = null pagesRef.current = [] setTableListKey((key) => key + 1) return dataLoadGenerationRef.current }, []) 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 (