import {
forwardRef,
useImperativeHandle,
useRef,
useEffect,
useLayoutEffect,
useState,
useCallback,
useMemo,
createElement
} from 'react'
import {
Table,
Skeleton,
Row,
Col,
Flex,
Spin,
Button,
Input,
Space,
Tooltip,
Form,
Splitter
} 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 XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import { useLocation } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
import { useActions } from '../context/ActionsContext'
import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox'
import { useTableStatePersistence } from '../context/TableStateContext'
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 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 (
)
}
RowForm.propTypes = {
record: PropTypes.object,
isEditing: PropTypes.bool,
onRegister: PropTypes.func,
children: PropTypes.node
}
const EditableRow = ({ record, isEditing, onRegister, ...props }) => {
return (
)
}
EditableRow.propTypes = {
record: PropTypes.object,
isEditing: PropTypes.bool,
onRegister: PropTypes.func
}
const ObjectTable = forwardRef(
(
{
type,
pageSize = 25,
scrollHeight = 'calc(var(--unit-100vh) - 258px)',
onDataChange,
initialPage = 1,
cards = false,
visibleColumns = {},
masterFilter = {},
size = 'middle',
onStateChange,
showFilterSidebar = false,
expandHeight = false,
showActions = true,
saveFilterInSession = false,
saveFilterInUrl = false,
useFilterInSession = false,
useFilterInUrl = false,
saveSortInSession = false,
saveSortInUrl = false,
useSortInSession = false,
useSortInUrl = false,
onRowAction
},
ref
) => {
const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions()
const onStateChangeRef = useRef(onStateChange)
useEffect(() => {
onStateChangeRef.current = onStateChange
}, [onStateChange])
const {
fetchObjects,
connected,
subscribeToObjectUpdates,
subscribeToObjectTypeUpdates,
updateMultipleObjects,
setObjectActivity,
clearObjectActivity
} = useContext(ApiServerContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const location = useLocation()
const {
getPersistedFilter,
getPersistedSorter,
persistFilter,
persistTableState,
registerPageFilter,
getActiveFilterValues
} = useTableStatePersistence({
scope: type,
pagePath: location.pathname,
saveFilterInSession,
saveFilterInUrl,
useFilterInSession,
useFilterInUrl,
saveSortInSession,
saveSortInUrl,
useSortInSession,
useSortInUrl
})
var adjustedScrollHeight = scrollHeight
if (isMobile) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)'
}
if (cards) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)'
}
if (isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 238px)'
}
if (isMobile && isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)'
}
if (cards && isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 258px)'
}
const tableRef = useRef(null)
const model = getModelByName(type)
const activeFilterRef = useRef({})
const tableSorterRef = useRef({})
const [sidebarFilter, setSidebarFilter] = useState({})
const [tableSorter, setTableSorter] = useState({})
const [initialized, setInitialized] = useState(false)
// Table state
const [pages, setPages] = useState([])
const pagesRef = useRef(pages)
const tableData = useMemo(
() => pages.flatMap((page) => page.items),
[pages]
)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editLoading, setEditLoading] = useState(false)
const rowFormsRef = useRef({})
const registerForm = useCallback((id, form) => {
if (form) {
rowFormsRef.current[id] = form
} else {
delete rowFormsRef.current[id]
}
}, [])
useEffect(() => {
onStateChangeRef.current?.({ isEditing, editLoading })
}, [isEditing, editLoading])
const subscribedIdsRef = useRef([])
// const [typeSubscribed, setTypeSubscribed] = useState(false)
const unsubscribesRef = useRef([])
const updateEventHandlerRef = useRef()
const subscribeToObjectTypeUpdatesRef = useRef(null)
const subscribedTypeRef = useRef(null)
const newEventHandlerRef = useRef()
const subscriptionFilterRef = useRef()
const subscribeToObjectTypeUpdatesFnRef = useRef(
subscribeToObjectTypeUpdates
)
const prevValuesRef = useRef({ type, masterFilter })
const rowActions =
model.actions?.filter((action) => action.row == true) || []
const createSkeletonData = useCallback(
(pageNum) => {
return Array(pageSize)
.fill(null)
.map((_, index) => ({
_id: `skeleton-${pageNum}-${index}`,
isSkeleton: true
}))
},
[pageSize]
)
const createSkeletonPage = useCallback(
(pageNum) => ({
pageNum,
items: createSkeletonData(pageNum),
isSkeletonPage: true
}),
[createSkeletonData]
)
const loadingPagesRef = useRef(new Set())
const pendingScrollAnchorRef = useRef(null)
const renderActions = (objectData) => {
return (
{rowActions.map((action, index) => {
var disabled = false
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled = action.disabled({
...objectData,
_user: userProfile
})
} else {
disabled = action.disabled
}
}
return (
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
onClick={() => {
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
)
})}
)
}
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: { ...filter, ...masterFilter },
sorter,
onDataChange
})
},
[type, masterFilter, pageSize, onDataChange, fetchObjects]
)
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 sortedPages = [...loadedPages].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) => {
try {
const result = await fetchPage(pageNum, filter, sorter)
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) {
setLoading(false)
throw error
}
},
[fetchPage]
)
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
)
loadingPagesRef.current.add(pageNum)
setLazyLoading(true)
logger.debug(`Loading ${direction} page...`)
try {
const result = await fetchPage(pageNum)
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 = prev
.filter((page) => !page.isSkeletonPage)
.concat(loadedPage)
return createPageWindow(loadedPages, direction)
})
} catch (error) {
logger.error(`Error loading page ${pageNum}:`, error)
} finally {
loadingPagesRef.current.delete(pageNum)
if (loadingPagesRef.current.size === 0) {
setLazyLoading(false)
}
}
},
[captureScrollAnchor, createPageWindow, fetchPage]
)
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 reload = useCallback(async () => {
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 {
setLazyLoading(false)
}
}, [fetchData])
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])
// Update event handler for real-time updates
const updateEventHandler = useCallback((id, updatedData) => {
setPages((prevPages) =>
prevPages.map((page) => {
const updatedItems = page.items.map((item) => {
if (item._id === id) {
return { ...item, ...updatedData }
}
return item
})
return {
...page,
items: updatedItems
}
})
)
if (rowFormsRef.current[id]) {
rowFormsRef.current[id].setFieldsValue(updatedData)
}
}, [])
// Store the latest updateEventHandler in a ref
updateEventHandlerRef.current = updateEventHandler
const newEventHandler = useCallback(
(params) => {
logger.debug('New event handler:', params)
reload()
},
[reload]
)
const subscriptionFilter = useMemo(() => {
const active = {}
Object.entries(sidebarFilter).forEach(([k, v]) => {
if (v !== '' && v !== undefined) active[k] = v
})
return { ...active, ...masterFilter }
}, [sidebarFilter, masterFilter])
newEventHandlerRef.current = newEventHandler
subscriptionFilterRef.current = subscriptionFilter
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
// Subscribe to real-time updates for all items
useEffect(() => {
if (pages.length > 0 && connected == true) {
// Get all non-skeleton item IDs from all pages
const allItemIds = pages
.flatMap((page) => page.items || [])
.filter((item) => !item.isSkeleton)
.map((item) => item._id)
.filter(Boolean)
// Find new items that need subscription
const newItemIds = allItemIds.filter(
(id) => !subscribedIdsRef.current.includes(id)
)
// Subscribe to new items only
newItemIds.forEach((itemId) => {
const unsubscribe = subscribeToObjectUpdates(
itemId?.toLowerCase(),
type,
(updateData) => {
updateEventHandlerRef.current(itemId, updateData)
}
)
subscribedIdsRef.current.push(itemId)
if (unsubscribe) {
unsubscribesRef.current.push(unsubscribe)
}
})
// Clean up subscriptions for items that are no longer in the pages
const currentSubscribedIds = subscribedIdsRef.current
const itemsToUnsubscribe = currentSubscribedIds.filter(
(id) => !allItemIds.includes(id)
)
itemsToUnsubscribe.forEach((itemId) => {
const index = subscribedIdsRef.current.indexOf(itemId)
if (index > -1) {
subscribedIdsRef.current.splice(index, 1)
const unsubscribe = unsubscribesRef.current[index]
if (unsubscribe) {
unsubscribe()
}
unsubscribesRef.current.splice(index, 1)
}
})
}
}, [pages, type, subscribeToObjectUpdates, connected])
// Cleanup effect for component unmount
useEffect(() => {
return () => {
if (connected == true && unsubscribesRef.current) {
// Clean up all subscriptions when component unmounts
unsubscribesRef.current.forEach((unsubscribe) => {
if (unsubscribe) unsubscribe()
})
unsubscribesRef.current = []
subscribedIdsRef.current = []
// Clean up type subscription
}
if (connected == true && subscribeToObjectTypeUpdatesRef.current) {
subscribeToObjectTypeUpdatesRef.current()
subscribeToObjectTypeUpdatesRef.current = null
}
}
}, [connected])
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) => {
setPages([createSkeletonPage(pageNum)])
setLoading(true)
try {
const firstResult = await fetchPage(pageNum, filter, sorter)
const loadedPages = [
{
pageNum,
items: firstResult.data || [],
hasMore: firstResult.hasMore
}
]
if (firstResult.hasMore) {
setPages([...loadedPages, createSkeletonPage(pageNum + 1)])
const secondResult = await fetchPage(pageNum + 1)
loadedPages.push({
pageNum: pageNum + 1,
items: secondResult.data || [],
hasMore: secondResult.hasMore
})
}
setPages(createPageWindow(loadedPages, 'next'))
} catch (error) {
logger.error(`Error loading page ${pageNum}:`, error)
setPages([])
} finally {
setLoading(false)
}
},
[createPageWindow, createSkeletonPage, fetchPage]
)
const loadInitialPage = useCallback(async () => {
const filter = getPersistedFilter()
const sorter = getPersistedSorter()
const activeFilter = getActiveFilterValues(filter)
if (Object.keys(filter).length > 0) {
setSidebarFilter(filter)
}
if (sorter.field) {
tableSorterRef.current = sorter
setTableSorter(sorter)
}
loadPage(initialPage, activeFilter, sorter.field ? sorter : null)
}, [getPersistedFilter, getPersistedSorter, initialPage, loadPage])
useImperativeHandle(ref, () => ({
reload,
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
// Deep comparison for objects, simple comparison for primitives
const hasChanged =
prevValues.type !== type ||
JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter)
if (hasChanged) {
setPages([])
activeFilterRef.current = {}
tableSorterRef.current = {}
setSidebarFilter({})
setTableSorter({})
setInitialized(false)
setLoading(true)
setLazyLoading(false)
prevValuesRef.current = { type, masterFilter }
}
}, [type, masterFilter])
useEffect(() => {
registerPageFilter(sidebarFilter)
return () => registerPageFilter({})
}, [sidebarFilter, registerPageFilter])
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
)
}
const handleTableChange = (pagination, filters, sorter) => {
const next = { ...sidebarFilter }
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
next[key] = value[0]
} else {
delete next[key]
}
})
const nextSorter = {
field: sorter.columnKey,
order: sorter.order
}
setSidebarFilter(next)
setTableSorter(nextSorter)
persistTableState(next, nextSorter)
setPages([])
setLoading(true)
loadPage(initialPage, getActiveFilterValues(next), nextSorter)
}
const handleSidebarFilterChange = useCallback(
(newSidebarFilter) => {
setSidebarFilter(newSidebarFilter)
persistFilter(newSidebarFilter)
setPages([])
setLoading(true)
loadPage(initialPage, getActiveFilterValues(newSidebarFilter))
},
[initialPage, loadPage, persistFilter]
)
const modelProperties = getModelProperties(type)
// Table columns from model properties
const columnsWithSkeleton = [
{
title: lazyLoading ? : cards ? model.icon : null,
key: 'icon',
width: 45,
fixed: 'left',
render: () => {
return {createElement(model.icon)}
}
}
]
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
},
render: (text, record) => {
if (record?.isSkeleton) {
return (
)
}
return (
)
}
}
if (isFilterable && !Object.keys(masterFilter).includes(prop.name)) {
columnConfig.filterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: prop.label
})
const sidebarVal = sidebarFilter[prop.name]
columnConfig.filteredValue =
sidebarVal !== undefined && sidebarVal !== '' ? [sidebarVal] : null
}
columnsWithSkeleton.push(columnConfig)
}
})
if (
showActions &&
rowActions.length > 0 &&
tableData.some((item) => !item?.isSkeleton)
) {
columnsWithSkeleton.push({
title: (
),
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, 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 (
{tableData.map((record) => {
if (record?._id == undefined) {
return null
}
const skeletonBoundary =
record._id === skeletonBoundaryIds.next
? 'next'
: record._id === skeletonBoundaryIds.previous
? 'previous'
: undefined
return (
)
})}
)
}
const components = useMemo(
() => ({
body: {
row: EditableRow
}
}),
[]
)
const onRow = useCallback(
(record) => ({
record,
isEditing,
onRegister: registerForm
}),
[isEditing, registerForm]
)
const tableContent = (
{cards ? (
}
spinning={loading}
style={{ height: '100%' }}
>
{renderCards()}
) : (
}}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
style={{ height: '100%' }}
size={size}
components={components}
onRow={onRow}
/>
)}
{showFilterSidebar && (
)}
)
return tableContent
}
)
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,
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