Tom Butcher 5e6b4213ab Enhance ObjectKanban and ObjectTable Components with Column Resizing and Drag-and-Drop Functionality
- Implemented dynamic column resizing in ObjectKanban and ObjectKanbanColumn, allowing users to adjust column widths for better layout control.
- Added drag-and-drop support for reordering columns in ObjectKanbanHeader, improving user interaction and customization of the kanban view.
- Updated CSS styles in App.css to accommodate new resizing handles and visual feedback during drag-and-drop actions.
- Refactored ObjectKanban and related components to manage column states and widths effectively, ensuring a responsive and user-friendly experience.
2026-09-03 14:27:34 +01:00

2200 lines
66 KiB
JavaScript

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 (
<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,
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 (
<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
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
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 (isKanban) {
await kanbanRef.current?.reload?.()
return
}
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, isKanban]
)
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) => idsEqual(entry._id, 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)
// Always merge into the local item when it already exists.
if (existingItem) {
setPages((prevPages) =>
prevPages.map((page) => {
const updatedItems = page.items.map((item) => {
if (idsEqual(item._id, id)) {
// Keep the row's original _id casing; update payloads often
// arrive lowercased from the NATS subject.
return { ...item, ...updatedData, _id: item._id }
}
return item
})
return {
...page,
items: updatedItems
}
})
)
if (rowFormsRef.current[id]) {
rowFormsRef.current[id].setFieldsValue(updatedData)
} else if (
existingItem._id != null &&
rowFormsRef.current[existingItem._id]
) {
rowFormsRef.current[existingItem._id].setFieldsValue(updatedData)
}
}
if (
updateAffectsFilterOrSort(
existingItem,
updatedData,
subscriptionFilterRef.current,
{},
effectiveSorterRef.current
)
) {
silentReloadRef.current?.()
}
},
[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)
const sidebarMatchesView = sidebarViewIdRef.current === listViewId
if (!listViewId || activeObjectView) {
if (!listViewId && !sidebarMatchesView) return {}
return userFilter
}
const viewFilter = getActiveFilterValues(listViewFilter || {})
if (!sidebarMatchesView) {
return viewFilter
}
return {
...viewFilter,
...userFilter
}
}, [activeObjectView, listViewId, listViewFilter, sidebarFilter])
const effectiveSorter = useMemo(() => {
const sorterMatchesView = sidebarViewIdRef.current === listViewId
if (sorterMatchesView && 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(
() => ({ ...resolvedMasterFilter, ...effectiveFilter }),
[effectiveFilter, resolvedMasterFilter]
)
const tableFilterContextValue = useMemo(
() => ({
filter: effectiveFilter,
masterFilter: resolvedMasterFilter
}),
[effectiveFilter, resolvedMasterFilter]
)
newEventHandlerRef.current = newEventHandler
subscriptionFilterRef.current = subscriptionFilter
effectiveSorterRef.current = effectiveSorter
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
// Cleanup subscriptions on unmount. Kanban owns its own subscriptions.
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 (list/cards only)
useEffect(() => {
if (isKanban || 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, isKanban])
useEffect(() => {
if (isKanban || 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, isKanban])
const updateData = useCallback(
(id, updatedData) => {
updateEventHandler(id, updatedData)
},
[updateEventHandler]
)
const loadPage = useCallback(
async (pageNum, filter = null, sorter = null, options = {}) => {
const silent = options.silent === true
if (isKanban) {
const generation = dataLoadGenerationRef.current + 1
dataLoadGenerationRef.current = generation
const activeFilter = filter != null ? filter : activeFilterRef.current
const resolvedSorter =
sorter != null
? resolveSorter(sorter)
: resolveSorter(tableSorterRef.current)
const sorterForLoad = resolvedSorter?.field ? resolvedSorter : {}
if (!silent) {
setLoading(true)
setLazyLoading(false)
} else {
setLoading(false)
setLazyLoading(true)
}
try {
let loadKanban = kanbanRef.current?.load
if (!loadKanban) {
await new Promise((resolve) => requestAnimationFrame(resolve))
if (generation !== dataLoadGenerationRef.current) return
loadKanban = kanbanRef.current?.load
}
if (!loadKanban) return
await loadKanban(activeFilter, sorterForLoad, { silent })
} catch (error) {
logger.error('Error loading kanban view:', error)
} finally {
if (generation === dataLoadGenerationRef.current) {
if (!silent) {
setLoading(false)
} else {
setLazyLoading(false)
}
}
}
return
}
if (silent) {
const generation = dataLoadGenerationRef.current + 1
dataLoadGenerationRef.current = generation
loadingPagesRef.current.clear()
pendingScrollAnchorRef.current = null
setLoading(false)
setLazyLoading(true)
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'))
suppressTableChange()
}
} catch (error) {
if (!isStaleDataLoad(generation)) {
logger.error(`Error loading page ${pageNum}:`, error)
}
} finally {
if (!isStaleDataLoad(generation)) {
setLazyLoading(false)
}
}
return
}
const generation = beginDataReload()
clearTablePages()
if (isStaleDataLoad(generation)) return
const skeletonPage = createSkeletonPage(pageNum)
setTablePages([skeletonPage])
setLoading(true)
setLazyLoading(false)
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,
isKanban,
resolveSorter
]
)
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) {
assignSidebarToView(listViewIdRef.current)
setSidebarFilter(filter)
}
if (sorter?.field) {
tableSorterRef.current = sorter
setTableSorter(sorter)
}
loadPage(
initialPage,
activeFilter,
resolvedSorter.field ? resolvedSorter : null
)
}, [
assignSidebarToView,
getPersistedFilter,
getPersistedSorter,
getViewFromUrl,
initialPage,
loadPage,
resolveSorter
])
const loadInitialPageRef = useRef(loadInitialPage)
loadInitialPageRef.current = loadInitialPage
// 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
assignSidebarToView(listViewId)
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
assignSidebarToView(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)}`
assignSidebarToView(viewId)
setSidebarFilter(filter)
tableSorterRef.current = nextSorter
setTableSorter(nextSorter)
activeFilterRef.current = activeFilter
loadPage(
initialPage,
activeFilter,
resolvedSorter.field ? resolvedSorter : null
)
},
[
assignSidebarToView,
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 || initialized) {
return
}
let cancelled = false
const run = async () => {
await loadInitialPageRef.current()
if (!cancelled) {
setInitialized(true)
}
}
run()
return () => {
cancelled = true
}
}, [token, initialized, connected])
useEffect(() => {
const viewModeKey = `${viewMode.type}:${viewMode?.settings?.categoryProperty || ''}`
if (connected !== true || token == null || !initialized) {
prevViewModeKeyRef.current = viewModeKey
return
}
if (prevViewModeKeyRef.current === viewModeKey) return
prevViewModeKeyRef.current = viewModeKey
if (isKanban && !viewMode?.settings?.categoryProperty) return
loadPage(
initialPage,
activeFilterRef.current,
tableSorterRef.current?.field ? tableSorterRef.current : null
)
}, [
isKanban,
connected,
token,
initialized,
loadPage,
initialPage,
viewMode.type,
viewMode?.settings?.categoryProperty
])
// 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(resolvedMasterFilter)
if (hasChanged) {
beginDataReload()
pagesRef.current = []
setPages([])
activeFilterRef.current = {}
tableSorterRef.current = {}
assignSidebarToView(null)
setSidebarFilter({})
setTableSorter({})
setInitialized(false)
setLoading(true)
setLazyLoading(false)
prevValuesRef.current = { type, masterFilter: resolvedMasterFilter }
}
}, [type, resolvedMasterFilter, 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={resolvedMasterFilter}
/>
)
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)
assignSidebarToView(listViewIdRef.current)
setTableSorter(nextSorter)
if (activeObjectViewRef.current) {
listViewFilterRef.current = next
// 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)
}
const effective = buildEffectiveFilter(next)
activeFilterRef.current = effective
loadPage(initialPage, effective, resolveSorter(nextSorter), {
silent: true
})
}
const handleSidebarFilterChange = useCallback(
(newSidebarFilter) => {
setSidebarFilter(newSidebarFilter)
assignSidebarToView(listViewIdRef.current)
if (activeObjectViewRef.current) {
listViewFilterRef.current = newSidebarFilter
onObjectViewFilterSortChangeRef.current?.(
newSidebarFilter,
tableSorter
)
} else if (!listViewIdRef.current) {
persistFilter(newSidebarFilter)
}
const effective = buildEffectiveFilter(newSidebarFilter)
activeFilterRef.current = effective
loadPage(initialPage, effective, resolveSorter(tableSorter), {
silent: true
})
},
[
assignSidebarToView,
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)
}
const effective = buildEffectiveFilter(sidebarFilter)
loadPage(initialPage, effective, resolveSorter(nextSorter), {
silent: true
})
},
[
buildEffectiveFilter,
initialPage,
loadPage,
persistSort,
resolveSorter,
sidebarFilter,
suppressTableChange
]
)
const modelProperties = getModelProperties(type)
// Table columns from model properties
const columnsWithSkeleton = [
{
title: lazyLoading ? <LoadingOutlined /> : isCards ? 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 || isCards || isKanban) 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,
isCards,
isKanban,
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(resolvedMasterFilter).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 (!isCards) return
measureCardColSpan()
}, [isCards, showFilterSidebar, showSortSidebar, measureCardColSpan])
useEffect(() => {
return () => cardsResizeObserverRef.current?.disconnect()
}, [])
// Card view: load when the leading skeleton card in a boundary page enters view
useLayoutEffect(() => {
if (!isCards) 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()
}, [isCards, skeletonBoundaryIds, loadNextPage, loadPreviousPage])
const renderCards = () => {
return (
<ScrollBox
style={{
height: '100%',
flex: 1,
minHeight: 0
}}
>
<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}
lazyLoading={lazyLoading}
isEditing={isEditing}
rowActions={rowActions}
renderActions={renderActions}
/>
</Flex>
</RowForm>
</div>
</Col>
)
})}
</Row>
{lazyLoading && tableData.length <= 0 && (
<LoadingPlaceholder
message='Loading, please wait...'
hasBackground={true}
hasBorder={false}
height='260px'
/>
)}
</div>
</ScrollBox>
)
}
const components = useMemo(
() => ({
body: {
row: EditableRow
}
}),
[]
)
const onRow = useCallback(
(record) => ({
record,
isEditing,
onRegister: registerForm
}),
[isEditing, registerForm]
)
useEffect(() => {
console.log('loading', loading)
}, [loading])
const tableContent = (
<Flex
gap={'middle'}
vertical
style={{ flex: 1, minWidth: 0, minHeight: 0 }}
>
<Splitter
className={'farmcontrol-splitter large'}
onResize={measureCardColSpan}
onResizeEnd={measureCardColSpan}
>
<Splitter.Panel>
{isKanban ? (
<div className='objectKanbanWrap'>
<Spin spinning={loading}>
<ObjectKanban
ref={kanbanRef}
type={type}
categoryProperty={viewMode.settings?.categoryProperty}
baseFilter={effectiveFilter}
masterFilter={resolvedMasterFilter}
sorter={effectiveSorter}
lazyLoading={lazyLoading}
pageSize={pageSize}
model={model}
modelProperties={modelProperties}
visibleColumns={visibleColumns}
isEditing={isEditing}
rowActions={rowActions}
renderActions={renderActions}
columns={viewMode.settings?.columns}
onColumnsChange={(columns) => {
objectListView?.setViewMode?.({
...viewMode,
settings: {
...viewMode.settings,
columns
}
})
}}
/>
</Spin>
</div>
) : isCards ? (
<div
className='objectTableCardsContainer'
ref={objectTableCardsContainerRef}
>
<Spin spinning={loading}>{renderCards()}</Spin>
</div>
) : (
<Spin spinning={loading}>
<Table
key={tableListKey}
ref={tableRef}
dataSource={tableData}
columns={columnsWithSkeleton}
className='dashboard-table'
pagination={false}
scroll={{ y: adjustedScrollHeight }}
rowKey='_id'
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
style={{ height: '100%' }}
size={size}
components={components}
onRow={onRow}
/>
</Spin>
)}
</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={
activeObjectView ? sidebarFilter : effectiveFilter
}
onFilterChange={handleSidebarFilterChange}
masterFilter={resolvedMasterFilter}
/>
</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,
viewMode: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
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