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 (