- Removed unnecessary useState hooks for view state management in multiple dashboard components (Invoices, PaymentPolicies, Payments, TaxRecords, FilamentStocks, OrderItems, PartStocks, ProductStocks, PurchaseOrders, Shipments, StockAudits, StockEvents, StockLocations, StockTransfers, AppPasswords, AuditLogs, Couriers, CourierServices, DocumentJobs, DocumentPrinters, DocumentSizes, DocumentTemplates, Filaments, FilamentSkus). - Updated ObjectListViewProvider integration to streamline component functionality and improve maintainability. - Adjusted CSS variables in App.css for enhanced styling consistency across components.
1957 lines
58 KiB
JavaScript
1957 lines
58 KiB
JavaScript
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 (
|
|
<div style={{ padding: 8 }}>
|
|
<Flex vertical gap='small'>
|
|
<Space.Compact>
|
|
<FilterInput
|
|
placeholder={'Filter ' + propertyLabel}
|
|
value={expression}
|
|
onChange={handleExpressionChange}
|
|
onPressEnter={applyFilter}
|
|
style={{ width: 260 }}
|
|
/>
|
|
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
|
</Space.Compact>
|
|
<QuickPropertyFilters
|
|
modelType={modelType}
|
|
propertyName={propertyName}
|
|
value={draft}
|
|
onChange={handleDraftChange}
|
|
/>
|
|
<Card size='small' styles={{ body: { padding: 0, height: 200 } }}>
|
|
<ScrollBox inner={true} smallPadding={true}>
|
|
<div style={{ padding: '18px 20px', minWidth: 0 }}>
|
|
<SimplePropertyFilter
|
|
modelType={modelType}
|
|
propertyName={propertyName}
|
|
value={draft}
|
|
onChange={handleDraftChange}
|
|
filter={filter}
|
|
masterFilter={masterFilter}
|
|
visible={visible}
|
|
/>
|
|
</div>
|
|
</ScrollBox>
|
|
</Card>
|
|
</Flex>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Form form={form} component={false}>
|
|
{children}
|
|
</Form>
|
|
)
|
|
}
|
|
|
|
RowForm.propTypes = {
|
|
record: PropTypes.object,
|
|
isEditing: PropTypes.bool,
|
|
onRegister: PropTypes.func,
|
|
children: PropTypes.node
|
|
}
|
|
|
|
const EditableRow = ({ record, isEditing, onRegister, ...props }) => {
|
|
return (
|
|
<RowForm record={record} isEditing={isEditing} onRegister={onRegister}>
|
|
<tr {...props} />
|
|
</RowForm>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Flex gap='small' align='center' justify='center'>
|
|
{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 (
|
|
<Tooltip key={index} title={action.label}>
|
|
<Button
|
|
icon={
|
|
action.icon ? (
|
|
createElement(action.icon)
|
|
) : (
|
|
<QuestionCircleIcon />
|
|
)
|
|
}
|
|
disabled={disabled || objectData?.isSkeleton}
|
|
type={'text'}
|
|
size={'small'}
|
|
danger={action?.danger || false}
|
|
onClick={() => {
|
|
if (denied) return
|
|
if (
|
|
onRowAction &&
|
|
onRowAction(action, objectData) !== false
|
|
) {
|
|
return
|
|
}
|
|
callAction(
|
|
action,
|
|
{ ...objectData, _user: userProfile },
|
|
type
|
|
)
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
</Flex>
|
|
)
|
|
}
|
|
|
|
const fetchPage = useCallback(
|
|
async (pageNum = 1, filter = null, sorter = null) => {
|
|
if (filter == null) {
|
|
filter = activeFilterRef.current
|
|
} else {
|
|
activeFilterRef.current = filter
|
|
}
|
|
if (sorter == null) {
|
|
sorter = tableSorterRef.current
|
|
} else {
|
|
tableSorterRef.current = {
|
|
field: sorter.field,
|
|
order: sorter.order
|
|
}
|
|
}
|
|
return fetchObjects(type, {
|
|
page: pageNum,
|
|
limit: pageSize,
|
|
filter: { ...getMasterFilter(), ...filter },
|
|
sorter: resolveSorter(sorter),
|
|
onDataChange
|
|
})
|
|
},
|
|
[
|
|
type,
|
|
getMasterFilter,
|
|
pageSize,
|
|
onDataChange,
|
|
fetchObjects,
|
|
resolveSorter
|
|
]
|
|
)
|
|
|
|
const skeletonsHeight = useMemo(() => {
|
|
return SKELETON_HEIGHT * pageSize
|
|
}, [pageSize])
|
|
|
|
const skeletonBoundaryIds = useMemo(() => {
|
|
const firstPage = pages[0]
|
|
const lastPage = pages[pages.length - 1]
|
|
return {
|
|
previous:
|
|
firstPage?.isSkeletonPage && firstPage.items.length > 0
|
|
? firstPage.items[firstPage.items.length - 1]._id
|
|
: null,
|
|
next:
|
|
lastPage?.isSkeletonPage && lastPage.items[0]
|
|
? lastPage.items[0]._id
|
|
: null
|
|
}
|
|
}, [pages])
|
|
|
|
const createPageWindow = useCallback(
|
|
(loadedPages, direction) => {
|
|
const dedupedByPageNum = new Map()
|
|
loadedPages.forEach((page) => {
|
|
dedupedByPageNum.set(page.pageNum, page)
|
|
})
|
|
const sortedPages = [...dedupedByPageNum.values()].sort(
|
|
(a, b) => a.pageNum - b.pageNum
|
|
)
|
|
const visiblePages =
|
|
direction === 'previous'
|
|
? sortedPages.slice(0, 2)
|
|
: sortedPages.slice(-2)
|
|
const firstPage = visiblePages[0]
|
|
const lastPage = visiblePages[visiblePages.length - 1]
|
|
const nextPages = []
|
|
|
|
if (firstPage?.pageNum > 1) {
|
|
nextPages.push(createSkeletonPage(firstPage.pageNum - 1))
|
|
}
|
|
nextPages.push(...visiblePages)
|
|
if (lastPage?.hasMore) {
|
|
nextPages.push(createSkeletonPage(lastPage.pageNum + 1))
|
|
}
|
|
|
|
return nextPages
|
|
},
|
|
[createSkeletonPage]
|
|
)
|
|
|
|
const fetchData = useCallback(
|
|
async (pageNum = 1, filter = null, sorter = null) => {
|
|
const generation = dataLoadGenerationRef.current
|
|
try {
|
|
const result = await fetchPage(pageNum, filter, sorter)
|
|
if (isStaleDataLoad(generation)) return []
|
|
const loadedPage = {
|
|
pageNum,
|
|
items: result.data || [],
|
|
hasMore: result.hasMore
|
|
}
|
|
setPages((prev) =>
|
|
prev.map((page) => (page.pageNum === pageNum ? loadedPage : page))
|
|
)
|
|
setLoading(false)
|
|
return result.data || []
|
|
} catch (error) {
|
|
if (!isStaleDataLoad(generation)) {
|
|
setLoading(false)
|
|
}
|
|
throw error
|
|
}
|
|
},
|
|
[fetchPage, isStaleDataLoad]
|
|
)
|
|
|
|
const findRenderedRow = useCallback((scrollTarget, id) => {
|
|
if (!scrollTarget || id == null) return null
|
|
return Array.from(scrollTarget.querySelectorAll('tr[data-row-key]')).find(
|
|
(row) => row.getAttribute('data-row-key') === String(id)
|
|
)
|
|
}, [])
|
|
|
|
const captureScrollAnchor = useCallback(
|
|
(scrollTarget, placeholderPage, direction, currentPages) => {
|
|
if (!scrollTarget) return null
|
|
|
|
const targetTop = scrollTarget.getBoundingClientRect().top
|
|
const closestRow = (items) => {
|
|
return items
|
|
.map((item, index) => {
|
|
const element = findRenderedRow(scrollTarget, item._id)
|
|
return element
|
|
? {
|
|
index,
|
|
id: item._id,
|
|
top: element.getBoundingClientRect().top
|
|
}
|
|
: null
|
|
})
|
|
.filter(Boolean)
|
|
.sort(
|
|
(a, b) =>
|
|
Math.abs(a.top - targetTop) - Math.abs(b.top - targetTop)
|
|
)[0]
|
|
}
|
|
|
|
const placeholderAnchor = closestRow(placeholderPage.items)
|
|
const loadedPages = currentPages.filter((page) => !page.isSkeletonPage)
|
|
const retainedPage =
|
|
direction === 'previous'
|
|
? loadedPages[0]
|
|
: loadedPages[loadedPages.length - 1]
|
|
const fallbackAnchor = retainedPage
|
|
? closestRow(retainedPage.items)
|
|
: null
|
|
|
|
return { placeholderAnchor, fallbackAnchor }
|
|
},
|
|
[findRenderedRow]
|
|
)
|
|
|
|
const restoreScrollAnchor = useCallback(
|
|
(scrollTarget, anchor, loadedPage) => {
|
|
if (!scrollTarget || !anchor) return
|
|
|
|
const loadedItem = loadedPage.items[anchor.placeholderAnchor?.index]
|
|
const anchorId = loadedItem?._id ?? anchor.fallbackAnchor?.id
|
|
const previousTop = loadedItem
|
|
? anchor.placeholderAnchor?.top
|
|
: anchor.fallbackAnchor?.top
|
|
if (anchorId == null || previousTop == null) return
|
|
|
|
const element = findRenderedRow(scrollTarget, anchorId)
|
|
if (!element) return
|
|
const renderedTop = element.getBoundingClientRect().top
|
|
scrollTarget.scrollTop += renderedTop - previousTop
|
|
},
|
|
[findRenderedRow]
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
const pendingAnchor = pendingScrollAnchorRef.current
|
|
if (!pendingAnchor) return
|
|
pendingScrollAnchorRef.current = null
|
|
restoreScrollAnchor(
|
|
pendingAnchor.scrollTarget,
|
|
pendingAnchor.anchor,
|
|
pendingAnchor.loadedPage
|
|
)
|
|
}, [pages, restoreScrollAnchor])
|
|
|
|
const loadBoundaryPage = useCallback(
|
|
async (pageNum, direction, scrollTarget) => {
|
|
if (loadingPagesRef.current.size > 0) return
|
|
|
|
const currentPages = pagesRef.current
|
|
const placeholderPage = currentPages.find(
|
|
(page) => page.pageNum === pageNum && page.isSkeletonPage
|
|
)
|
|
if (!placeholderPage) return
|
|
const scrollAnchor = captureScrollAnchor(
|
|
scrollTarget,
|
|
placeholderPage,
|
|
direction,
|
|
currentPages
|
|
)
|
|
|
|
const generation = dataLoadGenerationRef.current
|
|
loadingPagesRef.current.add(pageNum)
|
|
setLazyLoading(true)
|
|
logger.debug(`Loading ${direction} page...`)
|
|
|
|
try {
|
|
const result = await fetchPage(pageNum)
|
|
if (isStaleDataLoad(generation)) return
|
|
const loadedPage = {
|
|
pageNum,
|
|
items: result.data || [],
|
|
hasMore: result.hasMore
|
|
}
|
|
|
|
pendingScrollAnchorRef.current = {
|
|
scrollTarget,
|
|
anchor: scrollAnchor,
|
|
loadedPage
|
|
}
|
|
setPages((prev) => {
|
|
if (
|
|
!prev.some(
|
|
(page) => page.pageNum === pageNum && page.isSkeletonPage
|
|
)
|
|
) {
|
|
return prev
|
|
}
|
|
|
|
const loadedPages = mergeLoadedPage(prev, loadedPage)
|
|
return createPageWindow(loadedPages, direction)
|
|
})
|
|
} catch (error) {
|
|
logger.error(`Error loading page ${pageNum}:`, error)
|
|
} finally {
|
|
loadingPagesRef.current.delete(pageNum)
|
|
if (
|
|
!isStaleDataLoad(generation) &&
|
|
loadingPagesRef.current.size === 0
|
|
) {
|
|
setLazyLoading(false)
|
|
}
|
|
}
|
|
},
|
|
[
|
|
captureScrollAnchor,
|
|
createPageWindow,
|
|
fetchPage,
|
|
isStaleDataLoad,
|
|
mergeLoadedPage
|
|
]
|
|
)
|
|
|
|
const loadNextPage = useCallback(
|
|
(scrollTarget) => {
|
|
const nextPage = pagesRef.current[pagesRef.current.length - 1]
|
|
if (nextPage?.isSkeletonPage) {
|
|
loadBoundaryPage(nextPage.pageNum, 'next', scrollTarget)
|
|
}
|
|
},
|
|
[loadBoundaryPage]
|
|
)
|
|
|
|
const loadPreviousPage = useCallback(
|
|
(scrollTarget) => {
|
|
const previousPage = pagesRef.current[0]
|
|
if (previousPage?.isSkeletonPage) {
|
|
loadBoundaryPage(previousPage.pageNum, 'previous', scrollTarget)
|
|
}
|
|
},
|
|
[loadBoundaryPage]
|
|
)
|
|
|
|
const handleScroll = useCallback(
|
|
(e) => {
|
|
const { target } = e
|
|
const firstPage = pagesRef.current[0]
|
|
const lastPage = pagesRef.current[pagesRef.current.length - 1]
|
|
|
|
if (
|
|
lastPage?.isSkeletonPage &&
|
|
target.scrollHeight - target.scrollTop - target.clientHeight <=
|
|
skeletonsHeight + SCROLL_THRESHOLD
|
|
) {
|
|
loadNextPage(target)
|
|
} else if (
|
|
firstPage?.isSkeletonPage &&
|
|
target.scrollTop <= skeletonsHeight + SCROLL_THRESHOLD
|
|
) {
|
|
loadPreviousPage(target)
|
|
}
|
|
},
|
|
[loadNextPage, loadPreviousPage, skeletonsHeight]
|
|
)
|
|
|
|
const reloadLoadedPages = useCallback(
|
|
async ({ silent = false } = {}) => {
|
|
if (!silent) {
|
|
setLazyLoading(true)
|
|
}
|
|
try {
|
|
const loadedPages = pagesRef.current.filter(
|
|
(page) => !page.isSkeletonPage
|
|
)
|
|
for (let i = 0; i < loadedPages.length; i++) {
|
|
await fetchData(loadedPages[i].pageNum)
|
|
}
|
|
} finally {
|
|
if (!silent) {
|
|
setLazyLoading(false)
|
|
}
|
|
}
|
|
},
|
|
[fetchData]
|
|
)
|
|
|
|
const reload = useCallback(() => reloadLoadedPages(), [reloadLoadedPages])
|
|
const silentReload = useCallback(
|
|
() => reloadLoadedPages({ silent: true }),
|
|
[reloadLoadedPages]
|
|
)
|
|
|
|
reloadRef.current = reload
|
|
silentReloadRef.current = silentReload
|
|
|
|
const startEditing = useCallback(() => {
|
|
setIsEditing(true)
|
|
tableData.forEach((item) => {
|
|
if (!item.isSkeleton) {
|
|
setObjectActivity(item._id, type, 'editing')
|
|
}
|
|
})
|
|
}, [tableData, setObjectActivity, type])
|
|
|
|
const cancelEditing = useCallback(() => {
|
|
setIsEditing(false)
|
|
tableData.forEach((item) => {
|
|
if (!item.isSkeleton) {
|
|
clearObjectActivity(item._id, type)
|
|
}
|
|
})
|
|
}, [tableData, clearObjectActivity, type])
|
|
|
|
const handleUpdate = useCallback(async () => {
|
|
setEditLoading(true)
|
|
try {
|
|
const updates = await Promise.all(
|
|
Object.entries(rowFormsRef.current).map(async ([id, form]) => {
|
|
const values = await form.validateFields()
|
|
return { _id: id, ...values }
|
|
})
|
|
)
|
|
await updateMultipleObjects(type, updates)
|
|
setIsEditing(false)
|
|
reload()
|
|
tableData.forEach((item) => {
|
|
if (!item.isSkeleton) {
|
|
clearObjectActivity(item._id, type)
|
|
}
|
|
})
|
|
} catch (err) {
|
|
logger.error('Error updating objects:', err)
|
|
} finally {
|
|
setEditLoading(false)
|
|
}
|
|
}, [type, updateMultipleObjects, reload, tableData, clearObjectActivity])
|
|
|
|
const findTableItem = useCallback((id) => {
|
|
for (const page of pagesRef.current) {
|
|
const item = page.items?.find(
|
|
(entry) => String(entry._id) === String(id) && !entry.isSkeleton
|
|
)
|
|
if (item) return item
|
|
}
|
|
return null
|
|
}, [])
|
|
|
|
// Update event handler for real-time updates
|
|
const updateEventHandler = useCallback(
|
|
(id, updatedData) => {
|
|
const existingItem = findTableItem(id)
|
|
const changedKeys = getChangedKeys(existingItem, updatedData)
|
|
|
|
if (
|
|
updateAffectsFilterOrSort(
|
|
changedKeys,
|
|
subscriptionFilterRef.current,
|
|
{},
|
|
effectiveSorterRef.current
|
|
)
|
|
) {
|
|
silentReloadRef.current?.()
|
|
return
|
|
}
|
|
|
|
if (!existingItem) return
|
|
|
|
setPages((prevPages) =>
|
|
prevPages.map((page) => {
|
|
const updatedItems = page.items.map((item) => {
|
|
if (String(item._id) === String(id)) {
|
|
return { ...item, ...updatedData }
|
|
}
|
|
return item
|
|
})
|
|
return {
|
|
...page,
|
|
items: updatedItems
|
|
}
|
|
})
|
|
)
|
|
|
|
if (rowFormsRef.current[id]) {
|
|
rowFormsRef.current[id].setFieldsValue(updatedData)
|
|
}
|
|
},
|
|
[findTableItem]
|
|
)
|
|
|
|
// Store the latest updateEventHandler in a ref
|
|
updateEventHandlerRef.current = updateEventHandler
|
|
|
|
const newEventHandler = useCallback((params) => {
|
|
logger.debug('New event handler:', params)
|
|
silentReloadRef.current?.()
|
|
}, [])
|
|
|
|
// Effective filter for ObjectTableFilterContext, breadcrumb, and neighbors:
|
|
// view definition + any user-entered sidebar filters.
|
|
const effectiveFilter = useMemo(() => {
|
|
const userFilter = getActiveFilterValues(sidebarFilter)
|
|
if (!listViewId) return userFilter
|
|
return {
|
|
...getActiveFilterValues(listViewFilter || {}),
|
|
...userFilter
|
|
}
|
|
}, [listViewId, listViewFilter, sidebarFilter])
|
|
|
|
const effectiveSorter = useMemo(() => {
|
|
if (tableSorter?.field && tableSorter?.order) {
|
|
return { field: tableSorter.field, order: tableSorter.order }
|
|
}
|
|
if (listViewId && listViewSort?.field && listViewSort?.order) {
|
|
return { field: listViewSort.field, order: listViewSort.order }
|
|
}
|
|
return {}
|
|
}, [listViewId, listViewSort, tableSorter])
|
|
|
|
const subscriptionFilter = useMemo(
|
|
() => ({ ...masterFilter, ...effectiveFilter }),
|
|
[effectiveFilter, masterFilter]
|
|
)
|
|
|
|
const tableFilterContextValue = useMemo(
|
|
() => ({
|
|
filter: effectiveFilter,
|
|
masterFilter
|
|
}),
|
|
[effectiveFilter, masterFilter]
|
|
)
|
|
|
|
newEventHandlerRef.current = newEventHandler
|
|
subscriptionFilterRef.current = subscriptionFilter
|
|
effectiveSorterRef.current = effectiveSorter
|
|
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
|
|
|
|
// Cleanup subscriptions on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
|
|
subscribeToObjectTypeUpdatesRef.current()
|
|
subscribeToObjectTypeUpdatesRef.current = null
|
|
}
|
|
if (connected == true && subscribeToAllObjectUpdatesRef.current) {
|
|
subscribeToAllObjectUpdatesRef.current()
|
|
subscribeToAllObjectUpdatesRef.current = null
|
|
}
|
|
}
|
|
}, [connected])
|
|
|
|
// Subscribe to all object updates for this type
|
|
useEffect(() => {
|
|
if (connected !== true || !type) return
|
|
|
|
const unsubscribe = subscribeToAllObjectUpdates(type, (id, updateData) => {
|
|
updateEventHandlerRef.current(id, updateData)
|
|
})
|
|
subscribeToAllObjectUpdatesRef.current = unsubscribe
|
|
return () => {
|
|
if (unsubscribe) unsubscribe()
|
|
if (subscribeToAllObjectUpdatesRef.current === unsubscribe) {
|
|
subscribeToAllObjectUpdatesRef.current = null
|
|
}
|
|
}
|
|
}, [type, connected, subscribeToAllObjectUpdates])
|
|
|
|
useEffect(() => {
|
|
if (connected !== true) return
|
|
if (subscribedTypeRef.current === type) return
|
|
|
|
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
|
|
type,
|
|
subscriptionFilterRef.current,
|
|
(params) => newEventHandlerRef.current(params)
|
|
)
|
|
subscribeToObjectTypeUpdatesRef.current = unsubscribe
|
|
subscribedTypeRef.current = type
|
|
return () => {
|
|
if (unsubscribe) unsubscribe()
|
|
if (subscribeToObjectTypeUpdatesRef.current === unsubscribe) {
|
|
subscribeToObjectTypeUpdatesRef.current = null
|
|
}
|
|
if (subscribedTypeRef.current === type) {
|
|
subscribedTypeRef.current = null
|
|
}
|
|
}
|
|
}, [type, connected])
|
|
|
|
const updateData = useCallback(
|
|
(id, updatedData) => {
|
|
updateEventHandler(id, updatedData)
|
|
},
|
|
[updateEventHandler]
|
|
)
|
|
|
|
const loadPage = useCallback(
|
|
async (pageNum, filter = null, sorter = null) => {
|
|
const generation = beginDataReload()
|
|
clearTablePages()
|
|
if (isStaleDataLoad(generation)) return
|
|
|
|
const skeletonPage = createSkeletonPage(pageNum)
|
|
flushSync(() => {
|
|
setTablePages([skeletonPage])
|
|
setLoading(true)
|
|
})
|
|
if (isStaleDataLoad(generation)) return
|
|
|
|
try {
|
|
const firstResult = await fetchPage(pageNum, filter, sorter)
|
|
if (isStaleDataLoad(generation)) return
|
|
const loadedPages = [
|
|
{
|
|
pageNum,
|
|
items: firstResult.data || [],
|
|
hasMore: firstResult.hasMore
|
|
}
|
|
]
|
|
|
|
if (!isStaleDataLoad(generation)) {
|
|
setTablePages(createPageWindow(loadedPages, 'next'))
|
|
}
|
|
} catch (error) {
|
|
if (!isStaleDataLoad(generation)) {
|
|
logger.error(`Error loading page ${pageNum}:`, error)
|
|
setTablePages([])
|
|
}
|
|
} finally {
|
|
if (!isStaleDataLoad(generation)) {
|
|
setLoading(false)
|
|
// Data/column swap can also trigger a spurious Table onChange.
|
|
suppressTableChange()
|
|
}
|
|
}
|
|
},
|
|
[
|
|
beginDataReload,
|
|
clearTablePages,
|
|
createPageWindow,
|
|
createSkeletonPage,
|
|
fetchPage,
|
|
isStaleDataLoad,
|
|
setTablePages,
|
|
suppressTableChange
|
|
]
|
|
)
|
|
|
|
const loadInitialPage = useCallback(async () => {
|
|
// A selected view applies its own filter/sort via applyViewState.
|
|
// Do not load (or clear) filters here — that races and wipes the view.
|
|
if (getViewFromUrl() || listViewIdRef.current) {
|
|
return
|
|
}
|
|
|
|
const filter = getPersistedFilter()
|
|
const sorter = getPersistedSorter()
|
|
const activeFilter = getActiveFilterValues(filter)
|
|
const resolvedSorter = resolveSorter(sorter)
|
|
|
|
if (Object.keys(filter).length > 0) {
|
|
setSidebarFilter(filter)
|
|
}
|
|
if (sorter?.field) {
|
|
tableSorterRef.current = sorter
|
|
setTableSorter(sorter)
|
|
}
|
|
|
|
loadPage(
|
|
initialPage,
|
|
activeFilter,
|
|
resolvedSorter.field ? resolvedSorter : null
|
|
)
|
|
}, [
|
|
getPersistedFilter,
|
|
getPersistedSorter,
|
|
getViewFromUrl,
|
|
initialPage,
|
|
loadPage,
|
|
resolveSorter
|
|
])
|
|
|
|
// When a view becomes available, apply its definition once. Skip empty
|
|
// prop-lag updates so we never clear an already-applied view filter.
|
|
useEffect(() => {
|
|
if (!listViewId || activeObjectView) {
|
|
if (!listViewId) lastAppliedViewKeyRef.current = null
|
|
return
|
|
}
|
|
|
|
const viewFilter = getActiveFilterValues(listViewFilter || {})
|
|
const viewSort =
|
|
listViewSort?.field && listViewSort?.order
|
|
? { field: listViewSort.field, order: listViewSort.order }
|
|
: {}
|
|
const viewKey = `${listViewId}:${JSON.stringify(viewFilter)}:${JSON.stringify(viewSort)}`
|
|
|
|
if (lastAppliedViewKeyRef.current === viewKey) return
|
|
|
|
// Prop lag: listViewId is set but filter/sort props are still empty.
|
|
// Never overwrite a non-empty sidebar/query with {}.
|
|
const hasViewState =
|
|
Object.keys(viewFilter).length > 0 || Boolean(viewSort.field)
|
|
if (!hasViewState) return
|
|
|
|
lastAppliedViewKeyRef.current = viewKey
|
|
|
|
setSidebarFilter(viewFilter)
|
|
setTableSorter(viewSort)
|
|
tableSorterRef.current = viewSort
|
|
activeFilterRef.current = viewFilter
|
|
loadPage(
|
|
initialPage,
|
|
viewFilter,
|
|
viewSort.field ? viewSort : null
|
|
)
|
|
}, [
|
|
activeObjectView,
|
|
initialPage,
|
|
listViewFilter,
|
|
listViewId,
|
|
listViewSort,
|
|
loadPage
|
|
])
|
|
|
|
const applyViewState = useCallback(
|
|
(filterState, sorterState, options = {}) => {
|
|
let filter
|
|
let sorter
|
|
|
|
if (filterState == null && sorterState == null) {
|
|
// Ignore accidental All restores while a view is still selected
|
|
// (e.g. load race). Explicit All tab switches pass forceAll.
|
|
if (
|
|
!options.forceAll &&
|
|
(listViewIdRef.current || getViewFromUrl())
|
|
) {
|
|
return
|
|
}
|
|
|
|
listViewIdRef.current = null
|
|
listViewFilterRef.current = {}
|
|
lastAppliedViewKeyRef.current = null
|
|
filter = getAllTabPersistedFilter()
|
|
sorter = getAllTabPersistedSorter()
|
|
|
|
const resolvedSorter = resolveSorter(sorter)
|
|
const nextSorter = sorter?.field ? sorter : {}
|
|
const activeFilter = getActiveFilterValues(filter)
|
|
|
|
setSidebarFilter(filter)
|
|
tableSorterRef.current = nextSorter
|
|
setTableSorter(nextSorter)
|
|
activeFilterRef.current = activeFilter
|
|
persistAllTabTableState(filter, nextSorter)
|
|
loadPage(
|
|
initialPage,
|
|
activeFilter,
|
|
resolvedSorter.field ? resolvedSorter : null
|
|
)
|
|
return
|
|
}
|
|
|
|
// View tab: apply the view definition. Sidebar starts as the view
|
|
// filter so the UI matches; All-tab user settings must not leak in.
|
|
const viewId =
|
|
options.viewId !== undefined
|
|
? options.viewId
|
|
: listViewIdRef.current
|
|
const viewFilter =
|
|
options.viewFilter !== undefined
|
|
? options.viewFilter || {}
|
|
: filterState || {}
|
|
|
|
listViewIdRef.current = viewId
|
|
listViewFilterRef.current = viewFilter
|
|
|
|
filter = viewFilter
|
|
sorter =
|
|
options.viewSort?.field
|
|
? options.viewSort
|
|
: sorterState?.field
|
|
? sorterState
|
|
: {}
|
|
|
|
const resolvedSorter = resolveSorter(sorter)
|
|
const nextSorter = sorter?.field ? sorter : {}
|
|
const activeFilter = getActiveFilterValues(filter)
|
|
|
|
lastAppliedViewKeyRef.current = `${viewId || ''}:${JSON.stringify(activeFilter)}:${JSON.stringify(nextSorter)}`
|
|
|
|
setSidebarFilter(filter)
|
|
tableSorterRef.current = nextSorter
|
|
setTableSorter(nextSorter)
|
|
activeFilterRef.current = activeFilter
|
|
|
|
loadPage(
|
|
initialPage,
|
|
activeFilter,
|
|
resolvedSorter.field ? resolvedSorter : null
|
|
)
|
|
},
|
|
[
|
|
getAllTabPersistedFilter,
|
|
getAllTabPersistedSorter,
|
|
getViewFromUrl,
|
|
initialPage,
|
|
loadPage,
|
|
persistAllTabTableState,
|
|
resolveSorter
|
|
]
|
|
)
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
reload,
|
|
applyViewState,
|
|
setData: (newData) => {
|
|
setPages([{ pageNum: 1, items: newData }])
|
|
},
|
|
updateData,
|
|
startEditing,
|
|
cancelEditing,
|
|
handleUpdate,
|
|
isEditing,
|
|
editLoading
|
|
}))
|
|
|
|
useEffect(() => {
|
|
if (
|
|
connected == true &&
|
|
token != null &&
|
|
!pages.includes(initialPage) &&
|
|
!initialized
|
|
) {
|
|
loadInitialPage()
|
|
setInitialized(true)
|
|
}
|
|
}, [token, loadInitialPage, initialPage, pages, initialized, connected])
|
|
|
|
// Watch for changes in type and masterFilter, reset component state when they change
|
|
useEffect(() => {
|
|
const prevValues = prevValuesRef.current
|
|
|
|
const hasChanged =
|
|
prevValues.type !== type ||
|
|
JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter)
|
|
|
|
if (hasChanged) {
|
|
beginDataReload()
|
|
pagesRef.current = []
|
|
setPages([])
|
|
activeFilterRef.current = {}
|
|
tableSorterRef.current = {}
|
|
setSidebarFilter({})
|
|
setTableSorter({})
|
|
setInitialized(false)
|
|
setLoading(true)
|
|
setLazyLoading(false)
|
|
prevValuesRef.current = { type, masterFilter }
|
|
}
|
|
}, [type, masterFilter, beginDataReload])
|
|
|
|
useEffect(() => {
|
|
registerPageFilter(effectiveFilter)
|
|
registerObjectListFilter(effectiveFilter)
|
|
return () => registerPageFilter({})
|
|
}, [effectiveFilter, registerPageFilter, registerObjectListFilter])
|
|
|
|
useEffect(() => {
|
|
registerPageSorter(effectiveSorter)
|
|
registerObjectListSorter(effectiveSorter)
|
|
return () => registerPageSorter({})
|
|
}, [effectiveSorter, registerPageSorter, registerObjectListSorter])
|
|
|
|
const getFilterDropdown = ({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
visible,
|
|
propertyName,
|
|
propertyLabel
|
|
}) => (
|
|
<ColumnFilterDropdown
|
|
setSelectedKeys={setSelectedKeys}
|
|
selectedKeys={selectedKeys}
|
|
confirm={confirm}
|
|
clearFilters={clearFilters}
|
|
visible={visible}
|
|
propertyName={propertyName}
|
|
propertyLabel={propertyLabel}
|
|
modelType={type}
|
|
filter={effectiveFilter}
|
|
masterFilter={masterFilter}
|
|
/>
|
|
)
|
|
|
|
const handleTableChange = (pagination, filters, sorter) => {
|
|
if (isInternalTableChangeRef.current) return
|
|
|
|
const next = { ...sidebarFilter }
|
|
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
const expression = toFilterExpression(value)
|
|
if (expression !== undefined) {
|
|
next[key] = expression
|
|
} else {
|
|
delete next[key]
|
|
}
|
|
})
|
|
|
|
const nextSorter = {
|
|
field: sorter.columnKey,
|
|
order: sorter.order
|
|
}
|
|
|
|
setSidebarFilter(next)
|
|
setTableSorter(nextSorter)
|
|
const effective = buildEffectiveFilter(next)
|
|
activeFilterRef.current = effective
|
|
if (activeObjectViewRef.current) {
|
|
// Editing a view: keep filter/sort on the draft view only
|
|
onObjectViewFilterSortChangeRef.current?.(next, nextSorter)
|
|
} else if (!listViewIdRef.current) {
|
|
// All tab: persist personal filter/sort to URL/session
|
|
persistTableState(next, nextSorter)
|
|
}
|
|
loadPage(initialPage, effective, resolveSorter(nextSorter))
|
|
}
|
|
|
|
const handleSidebarFilterChange = useCallback(
|
|
(newSidebarFilter) => {
|
|
setSidebarFilter(newSidebarFilter)
|
|
const effective = buildEffectiveFilter(newSidebarFilter)
|
|
activeFilterRef.current = effective
|
|
if (activeObjectViewRef.current) {
|
|
onObjectViewFilterSortChangeRef.current?.(newSidebarFilter, tableSorter)
|
|
} else if (!listViewIdRef.current) {
|
|
persistFilter(newSidebarFilter)
|
|
}
|
|
loadPage(
|
|
initialPage,
|
|
effective,
|
|
resolveSorter(tableSorter)
|
|
)
|
|
},
|
|
[
|
|
buildEffectiveFilter,
|
|
initialPage,
|
|
loadPage,
|
|
persistFilter,
|
|
resolveSorter,
|
|
tableSorter
|
|
]
|
|
)
|
|
|
|
const handleSidebarSortChange = useCallback(
|
|
(newSorter) => {
|
|
const nextSorter = newSorter?.field
|
|
? { field: newSorter.field, order: newSorter.order }
|
|
: {}
|
|
suppressTableChange()
|
|
setTableSorter(nextSorter)
|
|
tableSorterRef.current = nextSorter
|
|
if (activeObjectViewRef.current) {
|
|
onObjectViewFilterSortChangeRef.current?.(sidebarFilter, nextSorter)
|
|
} else if (!listViewIdRef.current) {
|
|
persistSort(nextSorter)
|
|
}
|
|
loadPage(
|
|
initialPage,
|
|
buildEffectiveFilter(sidebarFilter),
|
|
resolveSorter(nextSorter)
|
|
)
|
|
},
|
|
[
|
|
buildEffectiveFilter,
|
|
initialPage,
|
|
loadPage,
|
|
persistSort,
|
|
resolveSorter,
|
|
sidebarFilter,
|
|
suppressTableChange
|
|
]
|
|
)
|
|
|
|
const modelProperties = getModelProperties(type)
|
|
// Table columns from model properties
|
|
const columnsWithSkeleton = [
|
|
{
|
|
title: lazyLoading ? <LoadingOutlined /> : cards ? model.icon : null,
|
|
key: 'icon',
|
|
width: 45,
|
|
fixed: 'left',
|
|
render: () => {
|
|
return <Flex justify='center'>{createElement(model.icon)}</Flex>
|
|
}
|
|
}
|
|
]
|
|
|
|
useEffect(() => {
|
|
pagesRef.current = pages
|
|
}, [pages])
|
|
|
|
useEffect(() => {
|
|
if (!expandHeight || cards) return
|
|
|
|
const findAntTableBody = (element) => {
|
|
if (!element) return null
|
|
if (element.classList?.contains('ant-table-body')) return element
|
|
for (const child of element.children) {
|
|
const found = findAntTableBody(child)
|
|
if (found) return found
|
|
}
|
|
return null
|
|
}
|
|
|
|
const root = tableRef.current?.nativeElement ?? tableRef.current
|
|
const tableBody = findAntTableBody(root)
|
|
if (!tableBody) return
|
|
|
|
tableBody.style.minHeight = adjustedScrollHeight
|
|
|
|
return () => {
|
|
tableBody.style.minHeight = ''
|
|
}
|
|
}, [expandHeight, adjustedScrollHeight, cards, loading, tableData])
|
|
|
|
// Add columns in the order specified by model.columns
|
|
model.columns.forEach((colName) => {
|
|
const prop = modelProperties.find((p) => p.name === colName)
|
|
if (prop) {
|
|
// Check if column should be visible based on visibleColumns prop
|
|
if (
|
|
Object.keys(visibleColumns).length > 0 &&
|
|
visibleColumns[prop.name] === false
|
|
) {
|
|
return // Skip this column if it's not visible
|
|
}
|
|
|
|
var fixed = prop.columnFixed || undefined
|
|
var width = 200
|
|
|
|
switch (prop.type) {
|
|
case 'text':
|
|
width = 200
|
|
break
|
|
case 'number':
|
|
width = 100
|
|
break
|
|
case 'dateTime':
|
|
width = 200
|
|
break
|
|
case 'state':
|
|
width = 200
|
|
break
|
|
case 'id':
|
|
width = 180
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
// Check if this property should be filterable based on model.filters
|
|
const isFilterable = model.filters && model.filters.includes(prop.name)
|
|
|
|
// Check if this property should be sortable based on model.sorters
|
|
const isSortable = model.sorters && model.sorters.includes(prop.name)
|
|
|
|
const columnConfig = {
|
|
sorter: isSortable ? { multiple: 1 } : undefined,
|
|
sortOrder:
|
|
tableSorter?.field === prop.name ? tableSorter.order : null,
|
|
title: prop.label,
|
|
width: prop.columnWidth || width,
|
|
fixed: isMobile ? undefined : fixed,
|
|
key: prop.name,
|
|
filterIcon: () => {
|
|
return (
|
|
<Tooltip title='Filter' listenParents={1}>
|
|
<FilterIcon />
|
|
</Tooltip>
|
|
)
|
|
},
|
|
render: (text, record) => {
|
|
if (record?.isSkeleton) {
|
|
return (
|
|
<Skeleton.Input
|
|
active
|
|
size='small'
|
|
style={{ width: '100%', height: 24.5 }}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<ObjectProperty
|
|
{...prop}
|
|
longId={false}
|
|
inTable={true}
|
|
objectData={record}
|
|
isEditing={isEditing}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (isFilterable && !Object.keys(masterFilter).includes(prop.name)) {
|
|
columnConfig.filterDropdown = ({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
visible
|
|
}) =>
|
|
getFilterDropdown({
|
|
setSelectedKeys,
|
|
selectedKeys,
|
|
confirm,
|
|
clearFilters,
|
|
visible,
|
|
propertyName: prop.name,
|
|
propertyLabel: prop.label
|
|
})
|
|
columnConfig.filteredValue = fromFilterExpression(
|
|
effectiveFilter[prop.name]
|
|
)
|
|
}
|
|
|
|
columnsWithSkeleton.push(columnConfig)
|
|
}
|
|
})
|
|
|
|
if (
|
|
showActions &&
|
|
rowActions.length > 0 &&
|
|
tableData.some((item) => !item?.isSkeleton)
|
|
) {
|
|
columnsWithSkeleton.push({
|
|
title: (
|
|
<Flex gap='small' align='center' justify='center'>
|
|
<ActionsIcon />
|
|
</Flex>
|
|
),
|
|
key: 'actions',
|
|
fixed: 'right',
|
|
width: 20 + rowActions.length * 30, // Adjust width based on number of actions
|
|
render: (record) => {
|
|
return renderActions(record)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Card view rendering
|
|
const [cardColSpan, setCardColSpan] = useState(24)
|
|
const cardsContainerNodeRef = useRef(null)
|
|
const cardsResizeObserverRef = useRef(null)
|
|
|
|
const measureCardColSpan = useCallback(() => {
|
|
const node = cardsContainerNodeRef.current
|
|
if (!node) return
|
|
const panel = node.closest('.ant-splitter-panel')
|
|
const width = (panel ?? node).getBoundingClientRect().width
|
|
if (width <= 0) return
|
|
const nextSpan = getCardColSpan(width)
|
|
setCardColSpan((prev) => (prev === nextSpan ? prev : nextSpan))
|
|
}, [])
|
|
|
|
const objectTableCardsContainerRef = useCallback(
|
|
(node) => {
|
|
cardsResizeObserverRef.current?.disconnect()
|
|
cardsResizeObserverRef.current = null
|
|
cardsContainerNodeRef.current = node
|
|
|
|
if (!node) return
|
|
|
|
let rafId = null
|
|
const scheduleMeasure = () => {
|
|
if (rafId != null) cancelAnimationFrame(rafId)
|
|
rafId = requestAnimationFrame(() => {
|
|
rafId = null
|
|
measureCardColSpan()
|
|
})
|
|
}
|
|
|
|
scheduleMeasure()
|
|
|
|
const observer = new ResizeObserver(scheduleMeasure)
|
|
const panel = node.closest('.ant-splitter-panel')
|
|
if (panel) {
|
|
observer.observe(panel)
|
|
} else {
|
|
observer.observe(node)
|
|
}
|
|
|
|
cardsResizeObserverRef.current = observer
|
|
},
|
|
[measureCardColSpan]
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
if (!cards) return
|
|
measureCardColSpan()
|
|
}, [cards, showFilterSidebar, showSortSidebar, measureCardColSpan])
|
|
|
|
useEffect(() => {
|
|
return () => cardsResizeObserverRef.current?.disconnect()
|
|
}, [])
|
|
|
|
// Card view: load when the leading skeleton card in a boundary page enters view
|
|
useLayoutEffect(() => {
|
|
if (!cards) return
|
|
const container = cardsContainerNodeRef.current
|
|
if (!container) return
|
|
const scrollEl = container.querySelector('.simplebar-content-wrapper')
|
|
if (!scrollEl) return
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) continue
|
|
const boundary = entry.target.dataset.skeletonBoundary
|
|
if (boundary === 'next') {
|
|
loadNextPage(scrollEl)
|
|
} else if (boundary === 'previous') {
|
|
loadPreviousPage(scrollEl)
|
|
}
|
|
}
|
|
},
|
|
{
|
|
root: scrollEl,
|
|
rootMargin: `${SCROLL_THRESHOLD}px`,
|
|
threshold: 0
|
|
}
|
|
)
|
|
|
|
container
|
|
.querySelectorAll('[data-skeleton-boundary]')
|
|
.forEach((el) => observer.observe(el))
|
|
|
|
return () => observer.disconnect()
|
|
}, [cards, skeletonBoundaryIds, loadNextPage, loadPreviousPage])
|
|
|
|
const renderCards = () => {
|
|
return (
|
|
<ScrollBox
|
|
style={{
|
|
maxHeight: `100%`
|
|
}}
|
|
>
|
|
<div className='objectTableCards'>
|
|
<Row gutter={[16, 16]} key={`${cardColSpan}-${tableListKey}`}>
|
|
{tableData.map((record) => {
|
|
if (record?._id == undefined) {
|
|
return null
|
|
}
|
|
|
|
const skeletonBoundary =
|
|
record._id === skeletonBoundaryIds.next
|
|
? 'next'
|
|
: record._id === skeletonBoundaryIds.previous
|
|
? 'previous'
|
|
: undefined
|
|
|
|
return (
|
|
<Col span={cardColSpan} key={record._id}>
|
|
<div
|
|
style={{ width: '100%', overflow: 'hidden' }}
|
|
data-skeleton-boundary={skeletonBoundary}
|
|
>
|
|
<RowForm
|
|
record={record}
|
|
isEditing={isEditing}
|
|
onRegister={registerForm}
|
|
>
|
|
<Flex align={'center'} vertical gap={'middle'}>
|
|
<ObjectCard
|
|
isSkeleton={record?.isSkeleton || false}
|
|
model={model}
|
|
modelProperties={modelProperties}
|
|
visibleColumns={visibleColumns}
|
|
record={record}
|
|
isEditing={isEditing}
|
|
rowActions={rowActions}
|
|
renderActions={renderActions}
|
|
/>
|
|
</Flex>
|
|
</RowForm>
|
|
</div>
|
|
</Col>
|
|
)
|
|
})}
|
|
</Row>
|
|
</div>
|
|
</ScrollBox>
|
|
)
|
|
}
|
|
|
|
const components = useMemo(
|
|
() => ({
|
|
body: {
|
|
row: EditableRow
|
|
}
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const onRow = useCallback(
|
|
(record) => ({
|
|
record,
|
|
isEditing,
|
|
onRegister: registerForm
|
|
}),
|
|
[isEditing, registerForm]
|
|
)
|
|
|
|
const tableContent = (
|
|
<Flex
|
|
gap={'middle'}
|
|
vertical
|
|
style={{ flex: 1, minWidth: 0, minHeight: 0 }}
|
|
>
|
|
<Splitter
|
|
className={'farmcontrol-splitter large'}
|
|
onResize={measureCardColSpan}
|
|
onResizeEnd={measureCardColSpan}
|
|
>
|
|
<Splitter.Panel>
|
|
{cards ? (
|
|
<div
|
|
className='objectTableCardsContainer'
|
|
ref={objectTableCardsContainerRef}
|
|
>
|
|
<Spin
|
|
indicator={<LoadingOutlined />}
|
|
spinning={loading}
|
|
style={{ height: '100%' }}
|
|
>
|
|
{renderCards()}
|
|
</Spin>
|
|
</div>
|
|
) : (
|
|
<Table
|
|
key={tableListKey}
|
|
ref={tableRef}
|
|
dataSource={tableData}
|
|
columns={columnsWithSkeleton}
|
|
className='dashboard-table'
|
|
pagination={false}
|
|
scroll={{ y: adjustedScrollHeight }}
|
|
rowKey='_id'
|
|
loading={{
|
|
spinning: loading,
|
|
indicator: <LoadingOutlined spin />
|
|
}}
|
|
onScroll={handleScroll}
|
|
onChange={handleTableChange}
|
|
showSorterTooltip={false}
|
|
style={{ height: '100%' }}
|
|
size={size}
|
|
components={components}
|
|
onRow={onRow}
|
|
/>
|
|
)}
|
|
</Splitter.Panel>
|
|
{(showSortSidebar || showFilterSidebar) && (
|
|
<Splitter.Panel defaultSize={'25%'}>
|
|
<Flex
|
|
vertical
|
|
gap='large'
|
|
style={{ height: '100%', minHeight: 0 }}
|
|
>
|
|
{showSortSidebar && (
|
|
<SortSidebar
|
|
type={type}
|
|
sorter={effectiveSorter}
|
|
onSortChange={handleSidebarSortChange}
|
|
stretchHeight={showSortSidebar && !showFilterSidebar}
|
|
/>
|
|
)}
|
|
{showFilterSidebar && (
|
|
<Flex style={{ flex: 1, minHeight: 0 }}>
|
|
<FilterSidebar
|
|
type={type}
|
|
filter={effectiveFilter}
|
|
onFilterChange={handleSidebarFilterChange}
|
|
masterFilter={masterFilter}
|
|
/>
|
|
</Flex>
|
|
)}
|
|
</Flex>
|
|
</Splitter.Panel>
|
|
)}
|
|
</Splitter>
|
|
</Flex>
|
|
)
|
|
|
|
return (
|
|
<ObjectTableFilterContext.Provider value={tableFilterContextValue}>
|
|
{tableContent}
|
|
</ObjectTableFilterContext.Provider>
|
|
)
|
|
}
|
|
)
|
|
|
|
ObjectTable.displayName = 'ObjectTable'
|
|
|
|
ObjectTable.propTypes = {
|
|
type: PropTypes.string.isRequired,
|
|
pageSize: PropTypes.number,
|
|
scrollHeight: PropTypes.string,
|
|
onDataChange: PropTypes.func,
|
|
initialPage: PropTypes.number,
|
|
cards: PropTypes.bool,
|
|
cardRenderer: PropTypes.func,
|
|
visibleColumns: PropTypes.object,
|
|
masterFilter: PropTypes.object,
|
|
size: PropTypes.string,
|
|
onStateChange: PropTypes.func,
|
|
showFilterSidebar: PropTypes.bool,
|
|
showSortSidebar: PropTypes.bool,
|
|
expandHeight: PropTypes.bool,
|
|
showActions: PropTypes.bool,
|
|
saveFilterInSession: PropTypes.bool,
|
|
saveFilterInUrl: PropTypes.bool,
|
|
useFilterInSession: PropTypes.bool,
|
|
useFilterInUrl: PropTypes.bool,
|
|
saveSortInSession: PropTypes.bool,
|
|
saveSortInUrl: PropTypes.bool,
|
|
useSortInSession: PropTypes.bool,
|
|
useSortInUrl: PropTypes.bool,
|
|
onRowAction: PropTypes.func
|
|
}
|
|
|
|
export default ObjectTable
|