Compare commits

...

2 Commits

2 changed files with 394 additions and 278 deletions

View File

@ -1,13 +1,5 @@
import { useEffect, useState, useContext, useMemo } from 'react'
import {
Card,
Segmented,
Flex,
Popover,
DatePicker,
Button,
Space
} from 'antd'
import { Card, Segmented, Flex, Popover, DatePicker, Button, Space } from 'antd'
import {
ResponsiveContainer,
BarChart,
@ -25,9 +17,9 @@ import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import dayjs from 'dayjs'
import { useThemeContext } from '../context/ThemeContext'
import LoadingPlaceholder from './LoadingPlaceholder'
import MissingPlaceholder from './MissingPlaceholder'
import CheckIcon from '../../Icons/CheckIcon'
import { LoadingOutlined } from '@ant-design/icons'
const HistoryDisplay = ({
objectType,
@ -291,15 +283,17 @@ const HistoryDisplay = ({
})
const chartRows = Array.from(
chartData.reduce((acc, item) => {
const existing = acc.get(item.date) || {
date: item.date,
dateFormatted: item.dateFormatted
}
existing[item.category] = item.value
acc.set(item.date, existing)
return acc
}, new Map()).values()
chartData
.reduce((acc, item) => {
const existing = acc.get(item.date) || {
date: item.date,
dateFormatted: item.dateFormatted
}
existing[item.category] = item.value
acc.set(item.date, existing)
return acc
}, new Map())
.values()
).sort((a, b) => new Date(a.date) - new Date(b.date))
const customTimeRangeContent = (
@ -334,89 +328,99 @@ const HistoryDisplay = ({
style={{ width: '100%' }}
styles={{ body: { padding: '12px', ...styles } }}
>
{!startDate && !endDate && (
<Flex justify='space-between'>
<Flex align='center' gap='5px'>
<Popover
content={customTimeRangeContent}
trigger='hover'
arrow={false}
placement='bottomLeft'
styles={{ body: { borderRadius: '22.5px' } }}
>
<Flex gap='small' vertical>
{!startDate && !endDate && (
<Flex justify='space-between'>
<Flex align='center' gap='5px'>
<Popover
content={customTimeRangeContent}
trigger='hover'
arrow={false}
placement='bottomLeft'
styles={{ body: { borderRadius: '22.5px' } }}
>
<Segmented
size='small'
options={[{ label: 'Custom', value: 'custom' }]}
value={timeRange}
onChange={setTimeRange}
disabled={loading}
/>
</Popover>
</Flex>
<Flex gap='middle'>
{loading == true && <LoadingOutlined />}
<Segmented
size='small'
options={[{ label: 'Custom', value: 'custom' }]}
options={[
{ label: '24hr', value: '24hrs' },
{ label: '12hr', value: '12hrs' },
{ label: '8hr', value: '8hrs' },
{ label: '4hr', value: '4hrs' },
{ label: '1hr', value: '1hrs' }
]}
value={timeRange}
onChange={setTimeRange}
disabled={loading}
/>
</Popover>
</Flex>
</Flex>
<Segmented
size='small'
options={[
{ label: '24hr', value: '24hrs' },
{ label: '12hr', value: '12hrs' },
{ label: '8hr', value: '8hrs' },
{ label: '4hr', value: '4hrs' },
{ label: '1hr', value: '1hrs' }
]}
value={timeRange}
onChange={setTimeRange}
disabled={loading}
/>
</Flex>
)}
{loading == true && (
<Flex justify='center' align='center' style={{ height: `${height}px` }}>
<LoadingPlaceholder message='Loading history data...' />
</Flex>
)}
{chartRows.length > 0 && (
<div style={{ width: '100%', height: `${height}px` }}>
<ResponsiveContainer width='100%' height='100%'>
<BarChart data={chartRows}>
<CartesianGrid
strokeDasharray='3 3'
stroke={isDarkMode ? '#303030' : '#f0f0f0'}
/>
<XAxis
dataKey='dateFormatted'
tick={{ fill: isDarkMode ? '#d9d9d9' : '#595959', fontSize: 12 }}
/>
<YAxis
tick={{ fill: isDarkMode ? '#d9d9d9' : '#595959', fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
border: `1px solid ${isDarkMode ? '#303030' : '#f0f0f0'}`,
borderRadius: 8
}}
/>
<Legend />
{modelStats.map((statDef, index) => {
const category = statDef.label || statDef.name
return (
<Bar
key={category}
dataKey={category}
stackId='history'
fill={colorRange[index] || colors.default}
/>
)
})}
</BarChart>
</ResponsiveContainer>
</div>
)}
{loading == false && chartRows.length == 0 && (
<Flex justify='center' align='center' style={{ height: `${height}px` }}>
<MissingPlaceholder message='No data available.' />
</Flex>
)}
)}
{chartRows.length > 0 && (
<div style={{ width: '100%', height: `${height}px` }}>
<ResponsiveContainer width='100%' height='100%'>
<BarChart data={chartRows}>
<CartesianGrid
strokeDasharray='3 3'
stroke={isDarkMode ? '#303030' : '#f0f0f0'}
/>
<XAxis
dataKey='dateFormatted'
tick={{
fill: isDarkMode ? '#d9d9d9' : '#595959',
fontSize: 12
}}
/>
<YAxis
tick={{
fill: isDarkMode ? '#d9d9d9' : '#595959',
fontSize: 12
}}
/>
<Tooltip
contentStyle={{
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
border: `1px solid ${isDarkMode ? '#303030' : '#f0f0f0'}`,
borderRadius: 8
}}
/>
<Legend />
{modelStats.map((statDef, index) => {
const category = statDef.label || statDef.name
return (
<Bar
key={category}
dataKey={category}
stackId='history'
fill={colorRange[index] || colors.default}
/>
)
})}
</BarChart>
</ResponsiveContainer>
</div>
)}
{loading == false && chartRows.length == 0 && (
<Flex
justify='center'
align='center'
style={{ height: `${height}px` }}
>
<MissingPlaceholder message='No data available.' />
</Flex>
)}
</Flex>
</Card>
)
}

View File

@ -3,6 +3,7 @@ import {
useImperativeHandle,
useRef,
useEffect,
useLayoutEffect,
useState,
useCallback,
useMemo,
@ -49,6 +50,7 @@ const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49
const RowForm = ({ record, isEditing, onRegister, children }) => {
const [form] = Form.useForm()
@ -152,10 +154,12 @@ const ObjectTable = forwardRef(
// Table state
const [pages, setPages] = useState([])
const pagesRef = useRef(pages)
const [hasMore, setHasMore] = useState(true)
const tableData = useMemo(
() => pages.flatMap((page) => page.items),
[pages]
)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [tableData, setTableData] = useState([])
const [isEditing, setIsEditing] = useState(false)
const [editLoading, setEditLoading] = useState(false)
@ -182,37 +186,26 @@ const ObjectTable = forwardRef(
const rowActions =
model.actions?.filter((action) => action.row == true) || []
const createSkeletonData = useCallback(() => {
const createSkeletonData = useCallback((pageNum) => {
return Array(pageSize)
.fill(null)
.map(() => ({
_id: `skeleton-${Math.random().toString(36).substring(2, 15)}`,
.map((_, index) => ({
_id: `skeleton-${pageNum}-${index}`,
isSkeleton: true
}))
}, [pageSize])
const scrollOperationTimingOut = useRef(false)
const scrollOperationTimeout = useRef(null)
const createSkeletonPage = useCallback(
(pageNum) => ({
pageNum,
items: createSkeletonData(pageNum),
isSkeletonPage: true
}),
[createSkeletonData]
)
const runScrollOperation = useCallback((operation) => {
if (scrollOperationTimingOut.current) return
scrollOperationTimingOut.current = true
operation()
scrollOperationTimeout.current = setTimeout(() => {
scrollOperationTimingOut.current = false
scrollOperationTimeout.current = null
}, 150)
}, [])
useEffect(() => {
return () => {
if (scrollOperationTimeout.current) {
clearTimeout(scrollOperationTimeout.current)
}
}
}, [])
const loadingPagesRef = useRef(new Set())
const pendingScrollAnchorRef = useRef(null)
const renderActions = (objectData) => {
return (
@ -255,7 +248,7 @@ const ObjectTable = forwardRef(
)
}
const fetchData = useCallback(
const fetchPage = useCallback(
async (pageNum = 1, filter = null, sorter = null) => {
if (filter == null) {
filter = activeFilterRef.current
@ -270,148 +263,266 @@ const ObjectTable = forwardRef(
order: sorter.order
}
}
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
)
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
}
return fetchObjects(type, {
page: pageNum,
limit: pageSize,
filter: { ...filter, ...masterFilter },
sorter,
onDataChange
})
},
[type, masterFilter, pageSize, onDataChange, fetchObjects]
)
const loadNextPage = useCallback(() => {
const highestPage = Math.max(...pages.map((p) => p.pageNum))
const nextPage = highestPage + 1
if (hasMore && lazyLoading == false) {
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, lazyLoading])
const skeletonsHeight = useMemo(() => {
return SKELETON_HEIGHT * pageSize
}, [pageSize])
const loadPreviousPage = useCallback(() => {
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
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 (prevPage > 0 && lazyLoading == false) {
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, lazyLoading])
if (firstPage?.pageNum > 1) {
nextPages.push(createSkeletonPage(firstPage.pageNum - 1))
}
nextPages.push(...visiblePages)
if (lastPage?.hasMore) {
nextPages.push(createSkeletonPage(lastPage.pageNum + 1))
}
const scrollDown = useCallback(
(target, scrollHeight) => {
setTimeout(() => {
target.scrollTop = scrollHeight / 2 - target.clientHeight
}, 0)
setLazyLoading(true)
logger.debug('Loading next page...')
loadNextPage()
return nextPages
},
[loadNextPage]
[createSkeletonPage]
)
const scrollUp = useCallback(
(target, scrollHeight) => {
setTimeout(() => {
target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
logger.debug('Loading previous page...')
loadPreviousPage()
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
}
},
[loadPreviousPage]
[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 scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
const firstPage = pagesRef.current[0]
const lastPage = pagesRef.current[pagesRef.current.length - 1]
// Load more data when scrolling down
if (
scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD &&
hasMore
lastPage?.isSkeletonPage &&
target.scrollHeight - target.scrollTop - target.clientHeight <=
skeletonsHeight + SCROLL_THRESHOLD
) {
runScrollOperation(() => scrollDown(target, scrollHeight))
}
// Load previous data when scrolling up
if (scrollTop < SCROLL_THRESHOLD && prevPage > 0) {
runScrollOperation(() => scrollUp(target, scrollHeight))
loadNextPage(target)
} else if (
firstPage?.isSkeletonPage &&
target.scrollTop <= skeletonsHeight + SCROLL_THRESHOLD
) {
loadPreviousPage(target)
}
},
[hasMore, pages, runScrollOperation, scrollDown, scrollUp]
[loadNextPage, loadPreviousPage, skeletonsHeight]
)
const reload = useCallback(async () => {
setLazyLoading(true)
for (let i = 0; i < pagesRef.current.length; i++) {
const page = pagesRef.current[i]
await fetchData(page.pageNum)
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])
@ -601,24 +712,41 @@ const ObjectTable = forwardRef(
const loadPage = useCallback(
async (pageNum, filter = null, sorter = null) => {
// Create initial page with skeletons
setPages([{ pageNum: pageNum, items: createSkeletonData() }])
setPages([createSkeletonPage(pageNum)])
setLoading(true)
const items = await fetchData(pageNum, filter, sorter)
try {
const firstResult = await fetchPage(pageNum, filter, sorter)
const loadedPages = [
{
pageNum,
items: firstResult.data || [],
hasMore: firstResult.hasMore
}
]
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)
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)
}
},
[createSkeletonData, fetchData]
[createPageWindow, createSkeletonPage, fetchPage]
)
const loadInitialPage = useCallback(async () => {
@ -667,7 +795,6 @@ const ObjectTable = forwardRef(
setInitialized(false)
setLoading(true)
setLazyLoading(false)
setHasMore(true)
prevValuesRef.current = { type, masterFilter }
}
}, [type, masterFilter])
@ -727,8 +854,6 @@ const ObjectTable = forwardRef(
}
})
console.log('filters--', filters)
setSidebarFilter(next)
setPages([])
setLoading(true)
@ -766,11 +891,6 @@ const ObjectTable = forwardRef(
pagesRef.current = pages
}, [pages])
// Flatten pages array for table display
useEffect(() => {
setTableData(pages.flatMap((page) => page.items))
}, [pages])
useEffect(() => {
if (!expandHeight || cards) return
@ -911,35 +1031,27 @@ const ObjectTable = forwardRef(
const handleCardsScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
const firstPage = pagesRef.current[0]
const lastPage = pagesRef.current[pagesRef.current.length - 1]
// Load more data when scrolling down
if (
lastPage?.isSkeletonPage &&
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)
} else if (
firstPage?.isSkeletonPage &&
scrollTop < 100 &&
!lazyLoading
) {
loadPreviousPage()
}
}
container.addEventListener('scroll', handleCardsScroll)
return () => container.removeEventListener('scroll', handleCardsScroll)
}, [cards, pages, hasMore, lazyLoading, loadNextPage, loadPreviousPage])
}, [cards, pages, lazyLoading, loadNextPage, loadPreviousPage])
const renderCards = () => {
return (