import {
forwardRef,
useImperativeHandle,
useRef,
useEffect,
useState,
useCallback,
createElement
} from 'react'
import {
Table,
message,
Skeleton,
Card,
Row,
Col,
Descriptions,
Flex,
Spin,
Button,
Input,
Space,
Tooltip
} 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 XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import { useNavigate } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel)
const ObjectTable = forwardRef(
(
{
type,
pageSize = 25,
scrollHeight = 'calc(var(--unit-100vh) - 270px)',
onDataChange,
initialPage = 1,
cards = false,
visibleColumns = {},
masterFilter = {}
},
ref
) => {
const { token } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext)
const {
fetchObjects,
connected,
subscribeToObjectUpdates,
subscribeToObjectTypeUpdates
} = useContext(ApiServerContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate()
var adjustedScrollHeight = scrollHeight
if (isMobile) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 316px)'
}
if (cards) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 280px)'
}
if (isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 244px)'
}
if (isMobile && isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)'
}
if (cards && isElectron) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 260px)'
}
const [, contextHolder] = message.useMessage()
const tableRef = useRef(null)
const model = getModelByName(type)
const [tableFilter, setTableFilter] = useState({})
const [tableSorter, setTableSorter] = useState({})
const [initialized, setInitialized] = useState(false)
// Table state
const [pages, setPages] = useState([])
const pagesRef = useRef(pages)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const subscribedIdsRef = useRef([])
// const [typeSubscribed, setTypeSubscribed] = useState(false)
const unsubscribesRef = useRef([])
const updateEventHandlerRef = useRef()
const subscribeToObjectTypeUpdatesRef = useRef(null)
const rowActions =
model.actions?.filter((action) => action.row == true) || []
const createSkeletonData = useCallback(() => {
return Array(pageSize)
.fill(null)
.map(() => ({
_id: `skeleton-${Math.random().toString(36).substring(2, 15)}`,
isSkeleton: true
}))
}, [pageSize])
const renderActions = (objectData) => {
return (
{rowActions.map((action, index) => {
var disabled = false
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled = action.disabled(objectData)
} else {
disabled = action.disabled
}
}
return (
)
}
disabled={disabled}
type={'text'}
size={'small'}
onClick={() => {
if (action.url) {
navigate(action.url(objectData._id))
}
}}
/>
)
})}
)
}
const fetchData = useCallback(
async (pageNum = 1, filter = null, sorter = null) => {
if (filter == null) {
filter = tableFilter
} else {
setTableFilter(filter)
}
if (sorter == null) {
sorter = tableSorter
} else {
setTableSorter({
field: sorter.field,
order: sorter.order
})
}
console.log('Fetching data...')
try {
const result = await fetchObjects(type, {
page: pageNum,
limit: pageSize,
filter: { ...filter, ...masterFilter },
sorter,
onDataChange
})
setHasMore(result.hasMore)
setPages((prev) => {
const existingPageIndex = prev.findIndex(
(p) => p.pageNum === pageNum
)
logger.debug(prev.map((p) => p.pageNum))
if (existingPageIndex !== -1) {
// Update existing page
const newPages = [...prev]
newPages[existingPageIndex] = { pageNum, items: result.data }
return newPages
}
// If page doesn't exist, return unchanged
return prev
})
setLoading(false)
setLazyLoading(false)
return result.data || []
} catch (error) {
setPages((prev) =>
prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
)
setLoading(false)
setLazyLoading(false)
throw error
}
},
[
type,
masterFilter,
pageSize,
tableFilter,
tableSorter,
onDataChange,
fetchObjects
]
)
const loadNextPage = useCallback(() => {
const highestPage = Math.max(...pages.map((p) => p.pageNum))
const nextPage = highestPage + 1
if (hasMore) {
setPages((prev) => {
const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
const minPage = Math.min(...filteredPages.map((p) => p.pageNum))
const relevantPages = filteredPages.filter(
(p) => p.pageNum !== minPage
)
return [
...relevantPages,
{ pageNum: nextPage, items: createSkeletonData() }
]
})
fetchData(nextPage)
}
}, [pages, createSkeletonData, fetchData, hasMore])
const loadPreviousPage = useCallback(() => {
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
if (prevPage > 0) {
setPages((prev) => {
const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
const maxPage = Math.max(...filteredPages.map((p) => p.pageNum))
const relevantPages = filteredPages.filter(
(p) => p.pageNum !== maxPage
)
return [
{ pageNum: prevPage, items: createSkeletonData() },
...relevantPages
]
})
fetchData(prevPage)
}
}, [pages, createSkeletonData, fetchData])
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
// Load more data when scrolling down
if (scrollHeight - scrollTop - clientHeight < 100 && hasMore) {
setTimeout(() => {
target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
logger.debug('Loading next page...')
loadNextPage()
}
// Load previous data when scrolling up
if (scrollTop < 100 && prevPage > 0) {
setTimeout(() => {
target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
logger.debug('Loading previous page...')
loadPreviousPage()
}
},
[loadNextPage, loadPreviousPage, hasMore, pages]
)
const reload = useCallback(async () => {
setLazyLoading(true)
for (let i = 0; i < pagesRef.current.length; i++) {
const page = pagesRef.current[i]
await fetchData(page.pageNum)
}
}, [fetchData])
// 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
}
})
)
}, [])
// Store the latest updateEventHandler in a ref
updateEventHandlerRef.current = updateEventHandler
const newEventHandler = useCallback(() => {
reload()
}, [reload])
// 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,
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 &&
subscribeToObjectTypeUpdatesRef.current == null
) {
subscribeToObjectTypeUpdatesRef.current = subscribeToObjectTypeUpdates(
type,
newEventHandler
)
}
}, [type, subscribeToObjectTypeUpdates, connected, newEventHandler])
const updateData = useCallback(
(_id, updatedData) => {
updateEventHandler({ _id, ...updatedData })
},
[updateEventHandler]
)
const loadPage = useCallback(
async (pageNum, filter = null, sorter = null) => {
// Create initial page with skeletons
setPages([{ pageNum: pageNum, items: createSkeletonData() }])
const items = await fetchData(pageNum, filter, sorter)
if (items.length >= 25) {
setPages((prev) => {
// Remove any existing page with the same pageNum
const filtered = prev.filter((p) => p.pageNum !== pageNum + 1)
return [
...filtered,
{ pageNum: pageNum + 1, items: createSkeletonData() }
]
})
await fetchData(pageNum + 1, filter, sorter)
}
},
[createSkeletonData, fetchData]
)
const loadInitialPage = useCallback(async () => {
loadPage(initialPage)
}, [initialPage, loadPage])
useImperativeHandle(ref, () => ({
reload,
setData: (newData) => {
setPages([{ pageNum: 1, items: newData }])
},
updateData
}))
useEffect(() => {
if (token != null && !pages.includes(initialPage) && !initialized) {
loadInitialPage()
setInitialized(true)
}
}, [token, loadInitialPage, initialPage, pages, initialized])
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 newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPages([])
setLoading(true)
loadPage(initialPage, newFilters, {
field: sorter.field,
order: sorter.order
})
}
const modelProperties = getModelProperties(type)
// Table columns from model properties
const columnsWithSkeleton = [
{
title: lazyLoading ? : cards ? model.icon : null,
key: 'icon',
width: 45,
fixed: 'left',
render: () => createElement(model.icon)
}
]
useEffect(() => {
pagesRef.current = pages
}, [pages])
// 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,
title: prop.label,
dataIndex: prop.name,
width: prop.columnWidth || width,
fixed: fixed,
key: prop.name,
render: (text, record) => {
if (record?.isSkeleton) {
return (
)
}
return (
)
}
}
// Add filter configuration if the property is filterable and not in masterFilter
if (isFilterable && !Object.keys(masterFilter).includes(prop.name)) {
columnConfig.filterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: prop.label
})
// Remove local filtering - let the server handle it
columnConfig.filtered = false
}
columnsWithSkeleton.push(columnConfig)
}
})
if (rowActions.length > 0) {
columnsWithSkeleton.push({
title: (
{'Actions'}
),
key: 'actions',
fixed: 'right',
width: 80 + rowActions.length * 40, // Adjust width based on number of actions
render: (record) => {
return renderActions(record)
}
})
}
// Flatten pages array for table display
const tableData = pages.flatMap((page) => page.items)
// Card view rendering
const cardsContainerRef = useRef(null)
// Card view scroll handler
useEffect(() => {
if (!cards) return
const container = cardsContainerRef.current
if (!container) return
const handleCardsScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
// Load more data when scrolling down
if (
scrollHeight - scrollTop - clientHeight < 100 &&
hasMore &&
!lazyLoading
) {
setTimeout(() => {
e.target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
loadNextPage()
}
// Load previous data when scrolling up
if (scrollTop < 100 && prevPage > 0 && !lazyLoading) {
setTimeout(() => {
e.target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
loadPreviousPage()
}
}
container.addEventListener('scroll', handleCardsScroll)
return () => container.removeEventListener('scroll', handleCardsScroll)
}, [cards, pages, hasMore, lazyLoading, loadNextPage, loadPreviousPage])
const renderCards = () => {
return (
{tableData.map((record) => (
{(() => {
const descriptionItems = []
// Add columns in the order specified by model.columns (same logic as table)
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
}
descriptionItems.push(
)
}
})
// Add actions if they exist (same as table)
if (rowActions.length > 0) {
descriptionItems.push(
{renderActions(record)}
)
}
return descriptionItems
})()}
))}
)
}
return (
<>
{contextHolder}
}}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
style={{ height: '100%' }}
size={isElectron ? 'small' : 'middle'}
/>
{cards ? (
} spinning={loading}>
{renderCards()}
) : null}
>
)
}
)
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
}
export default ObjectTable