From 5e41fd9d12fafcd52634b6471aad9be065102078 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Tue, 18 Feb 2025 12:53:47 +0300 Subject: [PATCH] Replace pagination with lazy loading in dstack UI #2245 --- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useInfiniteScroll.ts | 118 ++++++++++++++++++ .../src/pages/Runs/List/hooks/useRunsData.ts | 89 +------------ frontend/src/pages/Runs/List/index.tsx | 16 +-- 4 files changed, 127 insertions(+), 97 deletions(-) create mode 100644 frontend/src/hooks/useInfiniteScroll.ts diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 44e21ed43..cc5aa6ac6 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -4,6 +4,7 @@ export { useBreadcrumbs } from './useBreadcrumbs'; export { useNotifications } from './useNotifications'; export { useHelpPanel } from './useHelpPanel'; export { usePermissionGuard } from './usePermissionGuard'; +export { useInfiniteScroll } from './useInfiniteScroll'; // cloudscape export { useCollection } from '@cloudscape-design/collection-hooks'; diff --git a/frontend/src/hooks/useInfiniteScroll.ts b/frontend/src/hooks/useInfiniteScroll.ts new file mode 100644 index 000000000..8c96fc884 --- /dev/null +++ b/frontend/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,118 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { UseLazyQuery /*, UseQueryStateOptions*/ } from '@reduxjs/toolkit/dist/query/react/buildHooks'; +import { QueryDefinition } from '@reduxjs/toolkit/query'; + +const SCROLL_POSITION_GAP = 400; + +type InfinityListArgs = Partial> & { + prev_submitted_at?: string; +}; + +type ListResponse = DataItem[]; + +type UseInfinityParams = { + useLazyQuery: UseLazyQuery, any>>; + args: { limit?: number } & Args; + // options?: UseQueryStateOptions, Record>; +}; + +type WithSubmittedAt = { submitted_at: string }; + +export const useInfiniteScroll = ({ + useLazyQuery, + // options, + args, +}: UseInfinityParams) => { + const [data, setData] = useState>([]); + const scrollElement = useRef(document.documentElement); + const isLoadingRef = useRef(false); + const lastRequestParams = useRef(undefined); + const [disabledMore, setDisabledMore] = useState(false); + const { limit, ...argsProp } = args; + + const [getItems, { isLoading, isFetching }] = useLazyQuery({ ...args } as Args); + + const getDataRequest = (params: Args) => { + lastRequestParams.current = params; + + return getItems({ + limit, + ...params, + } as Args).unwrap(); + }; + + const getEmptyList = () => { + isLoadingRef.current = true; + + setData([]); + + getDataRequest(argsProp as Args).then((result) => { + setDisabledMore(false); + setData(result as ListResponse); + isLoadingRef.current = false; + }); + }; + + useEffect(() => { + getEmptyList(); + }, Object.values(argsProp)); + + const getMore = async () => { + if (isLoadingRef.current || disabledMore) { + return; + } + + try { + isLoadingRef.current = true; + const result = await getDataRequest({ + ...argsProp, + prev_submitted_at: data[data.length - 1].submitted_at, + } as Args); + + if (result.length > 0) { + setData((prev) => [...prev, ...result]); + } else { + setDisabledMore(true); + } + } catch (e) { + console.log(e); + } + + isLoadingRef.current = false; + }; + + useLayoutEffect(() => { + const element = scrollElement.current; + + if (isLoadingRef.current || !data.length) return; + + if (element.scrollHeight - element.clientHeight <= 0) { + getMore().catch(console.log); + } + }, [data]); + + const onScroll = useCallback(() => { + if (disabledMore || isLoadingRef.current) { + return; + } + + const element = scrollElement.current; + + const scrollPositionFromBottom = element.scrollHeight - (element.clientHeight + element.scrollTop); + + if (scrollPositionFromBottom < SCROLL_POSITION_GAP) { + getMore().catch(console.log); + } + }, [disabledMore, getMore]); + + useEffect(() => { + document.addEventListener('scroll', onScroll); + + return () => { + document.removeEventListener('scroll', onScroll); + }; + }, [onScroll]); + + return { data, isLoading: isLoading || (data.length === 0 && isFetching), refreshList: getEmptyList } as const; +}; diff --git a/frontend/src/pages/Runs/List/hooks/useRunsData.ts b/frontend/src/pages/Runs/List/hooks/useRunsData.ts index f9a5b8510..225338bf3 100644 --- a/frontend/src/pages/Runs/List/hooks/useRunsData.ts +++ b/frontend/src/pages/Runs/List/hooks/useRunsData.ts @@ -1,89 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; -import { orderBy as _orderBy } from 'lodash'; - import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; +import { useInfiniteScroll } from 'hooks'; import { useLazyGetRunsQuery } from 'services/run'; export const useRunsData = ({ project_name, only_active }: TRunsRequestParams) => { - const [data, setData] = useState([]); - const [pagesCount, setPagesCount] = useState(1); - const [disabledNext, setDisabledNext] = useState(false); - const lastRequestParams = useRef(undefined); - - const [getRuns, { isLoading, isFetching }] = useLazyGetRunsQuery(); - - const getRunsRequest = (params?: Partial) => { - lastRequestParams.current = params; - - return getRuns({ - project_name, - only_active, - limit: DEFAULT_TABLE_PAGE_SIZE, - ...params, - }).unwrap(); - }; - - const refreshList = () => { - getRunsRequest(lastRequestParams.current).then((result) => { - setDisabledNext(false); - setData(result); - }); - }; - - useEffect(() => { - getRunsRequest().then((result) => { - setPagesCount(1); - setDisabledNext(false); - setData(result); - }); - }, [project_name, only_active]); - - const nextPage = async () => { - if (data.length === 0 || disabledNext) { - return; - } - - try { - const result = await getRunsRequest({ - prev_submitted_at: data[data.length - 1].submitted_at, - prev_run_id: data[data.length - 1].id, - }); - - if (result.length > 0) { - setPagesCount((count) => count + 1); - setData(result); - } else { - setDisabledNext(true); - } - } catch (e) { - console.log(e); - } - }; - - const prevPage = async () => { - if (pagesCount === 1) { - return; - } - - try { - const result = await getRunsRequest({ - prev_submitted_at: data[0].submitted_at, - prev_run_id: data[0].id, - ascending: true, - }); - - setDisabledNext(false); - - if (result.length > 0) { - setPagesCount((count) => count - 1); - setData(_orderBy(result, ['submitted_at'], ['desc'])); - } else { - setPagesCount(1); - } - } catch (e) { - console.log(e); - } - }; + const { data, isLoading, refreshList } = useInfiniteScroll({ + useLazyQuery: useLazyGetRunsQuery, + args: { project_name, only_active, limit: DEFAULT_TABLE_PAGE_SIZE }, + }); - return { data, pagesCount, disabledNext, isLoading: isLoading || isFetching, nextPage, prevPage, refreshList }; + return { data, isLoading, refreshList }; }; diff --git a/frontend/src/pages/Runs/List/index.tsx b/frontend/src/pages/Runs/List/index.tsx index 4281f60d4..3f658ba89 100644 --- a/frontend/src/pages/Runs/List/index.tsx +++ b/frontend/src/pages/Runs/List/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; -import { Button, FormField, Header, Pagination, SelectCSD, SpaceBetween, Table, Toggle } from 'components'; +import { Button, FormField, Header, SelectCSD, SpaceBetween, Table, Toggle } from 'components'; import { useBreadcrumbs, useCollection } from 'hooks'; import { ROUTES } from 'routes'; @@ -39,13 +39,11 @@ export const RunList: React.FC = () => { localStorePrefix: 'administration-run-list-page', }); - const { data, isLoading, disabledNext, pagesCount, nextPage, prevPage, refreshList } = useRunsData({ + const { data, isLoading, refreshList } = useRunsData({ project_name: selectedProject?.value, only_active: onlyActive, }); - const isDisabledPagination = isLoading || data.length === 0; - const isDisabledClearFilter = !selectedProject && !onlyActive; const { stopRuns, isStopping } = useStopRuns(); @@ -172,16 +170,6 @@ export const RunList: React.FC = () => { } - pagination={ - - } /> ); };