From b09100e3f3fda6d6622b280f0dfa0ea369e0bcf2 Mon Sep 17 00:00:00 2001 From: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com> Date: Wed, 6 Dec 2023 19:05:00 +0545 Subject: [PATCH] Implement table record virtualizer back (#2839) Co-authored-by: gitstart-twenty Co-authored-by: v1b3m Co-authored-by: Thiago Nascimbeni --- .../components/RecordTableBody.tsx | 52 +++++--- .../utilities/virtualizer/RenderIfVisible.tsx | 112 ++++++++++++++++++ 2 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 front/src/modules/ui/utilities/virtualizer/RenderIfVisible.tsx diff --git a/front/src/modules/ui/object/record-table/components/RecordTableBody.tsx b/front/src/modules/ui/object/record-table/components/RecordTableBody.tsx index f84c52155..d3dd474f2 100644 --- a/front/src/modules/ui/object/record-table/components/RecordTableBody.tsx +++ b/front/src/modules/ui/object/record-table/components/RecordTableBody.tsx @@ -1,3 +1,4 @@ +import { useContext } from 'react'; import { useInView } from 'react-intersection-observer'; import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil'; @@ -12,6 +13,8 @@ import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable'; import { isFetchingRecordTableDataState } from '@/ui/object/record-table/states/isFetchingRecordTableDataState'; import { tableRowIdsState } from '@/ui/object/record-table/states/tableRowIdsState'; import { getRecordTableScopedStates } from '@/ui/object/record-table/utils/getRecordTableScopedStates'; +import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import RenderIfVisible from '@/ui/utilities/virtualizer/RenderIfVisible'; export const RecordTableBody = () => { const { scopeId } = useRecordTable(); @@ -41,36 +44,49 @@ export const RecordTableBody = () => { const isFetchingRecordTableData = useRecoilValue( isFetchingRecordTableDataState, ); + const lastRowId = tableRowIds[tableRowIds.length - 1]; + const scrollWrapperRef = useContext(ScrollWrapperContext); + if (isFetchingRecordTableData) { return <>; } return ( - + <> {tableRowIds.map((rowId, rowIndex) => ( - 30 - ? lastTableRowRef - : undefined - } - rowId={rowId} - /> + + 30 + ? lastTableRowRef + : undefined + } + rowId={rowId} + /> + ))} - {isFetchingMoreObjects && ( - - - Loading more... - - - )} - + + {isFetchingMoreObjects && ( + + + Loading more... + + + )} + + ); }; diff --git a/front/src/modules/ui/utilities/virtualizer/RenderIfVisible.tsx b/front/src/modules/ui/utilities/virtualizer/RenderIfVisible.tsx new file mode 100644 index 000000000..e2c446252 --- /dev/null +++ b/front/src/modules/ui/utilities/virtualizer/RenderIfVisible.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +type RenderIfVisibleProps = { + /** + * Whether the element should be visible initially or not. + * Useful e.g. for always setting the first N items to visible. + * Default: false + */ + initialVisible?: boolean; + /** An estimate of the element's height */ + defaultHeight?: number; + /** How far outside the viewport in pixels should elements be considered visible? */ + visibleOffset?: number; + /** Should the element stay rendered after it becomes visible? */ + stayRendered?: boolean; + root?: HTMLElement | null; + /** E.g. 'span', 'tbody'. Default = 'div' */ + rootElement?: string; + rootElementClass?: string; + /** E.g. 'span', 'tr'. Default = 'div' */ + placeholderElement?: string; + placeholderElementClass?: string; + children: React.ReactNode; +}; + +const RenderIfVisible = ({ + initialVisible = false, + defaultHeight = 300, + visibleOffset = 1000, + stayRendered = false, + root = null, + rootElement = 'div', + rootElementClass = '', + placeholderElement = 'div', + placeholderElementClass = '', + children, +}: RenderIfVisibleProps) => { + const [isVisible, setIsVisible] = useState(initialVisible); + + // eslint-disable-next-line twenty/no-state-useref + const wasVisible = useRef(initialVisible); + // eslint-disable-next-line twenty/no-state-useref + const placeholderHeight = useRef(defaultHeight); + const intersectionRef = useRef(null); + + // Set visibility with intersection observer + useEffect(() => { + if (intersectionRef.current) { + const localRef = intersectionRef.current; + const observer = new IntersectionObserver( + (entries) => { + // Before switching off `isVisible`, set the height of the placeholder + if (!entries[0].isIntersecting) { + placeholderHeight.current = localRef!.offsetHeight; + } + if (typeof window !== undefined && window.requestIdleCallback) { + window.requestIdleCallback( + () => setIsVisible(entries[0].isIntersecting), + { + timeout: 600, + }, + ); + } else { + setIsVisible(entries[0].isIntersecting); + } + }, + { root, rootMargin: `${visibleOffset}px 0px ${visibleOffset}px 0px` }, + ); + + observer.observe(localRef); + return () => { + if (localRef) { + observer.unobserve(localRef); + } + }; + } + return () => {}; + }, [root, visibleOffset]); + + useEffect(() => { + if (isVisible) { + wasVisible.current = true; + } + }, [isVisible]); + + const placeholderStyle = { height: placeholderHeight.current }; + const rootClasses = useMemo( + () => `renderIfVisible ${rootElementClass}`, + [rootElementClass], + ); + const placeholderClasses = useMemo( + () => `renderIfVisible-placeholder ${placeholderElementClass}`, + [placeholderElementClass], + ); + + // eslint-disable-next-line react/no-children-prop + return React.createElement(rootElement, { + children: + isVisible || (stayRendered && wasVisible.current) ? ( + <>{children} + ) : ( + React.createElement(placeholderElement, { + className: placeholderClasses, + style: placeholderStyle, + }) + ), + ref: intersectionRef, + className: rootClasses, + }); +}; + +export default RenderIfVisible;