From edce32888094b2002b62a3f36d3551de1c10895d Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 18 Jul 2026 19:56:37 +0100 Subject: [PATCH] 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. --- .../Dashboard/common/ObjectTable.jsx | 478 +++++++++++------- 1 file changed, 295 insertions(+), 183 deletions(-) diff --git a/src/components/Dashboard/common/ObjectTable.jsx b/src/components/Dashboard/common/ObjectTable.jsx index 8fce195..2819d1e 100644 --- a/src/components/Dashboard/common/ObjectTable.jsx +++ b/src/components/Dashboard/common/ObjectTable.jsx @@ -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 (