Refactor ObjectTable component to improve data handling and loading logic. Introduce skeleton page creation for better user experience during data fetching. Optimize page management and scrolling behavior, enhancing performance and maintainability.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-07-18 19:56:37 +01:00
parent cd30be7677
commit edce328880

View File

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