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,
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 (