From 4779fcf6f170c648b39a911e421fde0e974268b9 Mon Sep 17 00:00:00 2001 From: Nite Knite Date: Sat, 20 May 2023 21:55:47 +0800 Subject: [PATCH] feature: infinite scroll (#119) Add infinite scroll support to app list and dataset list. --- web/app/(commonLayout)/_layout-client.tsx | 13 +++--- web/app/(commonLayout)/apps/Apps.tsx | 41 ++++++++++++++--- web/app/(commonLayout)/apps/NewAppCard.tsx | 14 +++--- web/app/(commonLayout)/apps/NewAppDialog.tsx | 5 ++- web/app/(commonLayout)/datasets/Datasets.tsx | 41 +++++++++++++---- .../datasets/NewDatasetCard.tsx | 8 ++-- web/app/components/base/dialog/index.tsx | 2 +- web/context/app-context.ts | 27 ----------- web/context/app-context.tsx | 45 +++++++++++++++++++ web/models/app.ts | 4 ++ web/models/datasets.ts | 4 ++ web/service/apps.ts | 4 +- 12 files changed, 147 insertions(+), 61 deletions(-) delete mode 100644 web/context/app-context.ts create mode 100644 web/context/app-context.tsx diff --git a/web/app/(commonLayout)/_layout-client.tsx b/web/app/(commonLayout)/_layout-client.tsx index 8624091de2..799f8985d3 100644 --- a/web/app/(commonLayout)/_layout-client.tsx +++ b/web/app/(commonLayout)/_layout-client.tsx @@ -1,5 +1,5 @@ 'use client' -import type { FC } from 'react' +import { FC, useRef } from 'react' import React, { useEffect, useState } from 'react' import { usePathname, useRouter, useSelectedLayoutSegments } from 'next/navigation' import useSWR, { SWRConfig } from 'swr' @@ -8,7 +8,7 @@ import { fetchAppList } from '@/service/apps' import { fetchDatasets } from '@/service/datasets' import { fetchLanggeniusVersion, fetchUserProfile, logout } from '@/service/common' import Loading from '@/app/components/base/loading' -import AppContext from '@/context/app-context' +import { AppContextProvider } from '@/context/app-context' import DatasetsContext from '@/context/datasets-context' import type { LangGeniusVersionResponse, UserProfileResponse } from '@/models/common' @@ -23,6 +23,7 @@ const CommonLayout: FC = ({ children }) => { const pattern = pathname.replace(/.*\/app\//, '') const [idOrMethod] = pattern.split('/') const isNotDetailPage = idOrMethod === 'list' + const pageContainerRef = useRef(null) const appId = isNotDetailPage ? '' : idOrMethod @@ -71,14 +72,14 @@ const CommonLayout: FC = ({ children }) => { - - -
+ + +
{children}
- +
) } diff --git a/web/app/(commonLayout)/apps/Apps.tsx b/web/app/(commonLayout)/apps/Apps.tsx index fd989edde4..aa3ac28458 100644 --- a/web/app/(commonLayout)/apps/Apps.tsx +++ b/web/app/(commonLayout)/apps/Apps.tsx @@ -1,23 +1,50 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' +import useSWRInfinite from 'swr/infinite' +import { debounce } from 'lodash-es' import AppCard from './AppCard' import NewAppCard from './NewAppCard' -import { useAppContext } from '@/context/app-context' +import { AppListResponse } from '@/models/app' +import { fetchAppList } from '@/service/apps' +import { useSelector } from '@/context/app-context' + +const getKey = (pageIndex: number, previousPageData: AppListResponse) => { + if (!pageIndex || previousPageData.has_more) + return { url: 'apps', params: { page: pageIndex + 1, limit: 30 } } + return null +} const Apps = () => { - const { apps, mutateApps } = useAppContext() + const { data, isLoading, setSize, mutate } = useSWRInfinite(getKey, fetchAppList, { revalidateFirstPage: false }) + const loadingStateRef = useRef(false) + const pageContainerRef = useSelector(state => state.pageContainerRef) + const anchorRef = useRef(null) useEffect(() => { - mutateApps() + loadingStateRef.current = isLoading + }, [isLoading]) + + useEffect(() => { + const onScroll = debounce(() => { + if (!loadingStateRef.current) { + const { scrollTop, clientHeight } = pageContainerRef.current! + const anchorOffset = anchorRef.current!.offsetTop + if (anchorOffset - scrollTop - clientHeight < 100) { + setSize(size => size + 1) + } + } + }, 50) + + pageContainerRef.current?.addEventListener('scroll', onScroll) + return () => pageContainerRef.current?.removeEventListener('scroll', onScroll) }, []) return ( - ) } diff --git a/web/app/(commonLayout)/apps/NewAppCard.tsx b/web/app/(commonLayout)/apps/NewAppCard.tsx index ddbb0f03b9..f8cfb4062c 100644 --- a/web/app/(commonLayout)/apps/NewAppCard.tsx +++ b/web/app/(commonLayout)/apps/NewAppCard.tsx @@ -1,16 +1,20 @@ 'use client' -import { useState } from 'react' +import { forwardRef, useState } from 'react' import classNames from 'classnames' import { useTranslation } from 'react-i18next' import style from '../list.module.css' import NewAppDialog from './NewAppDialog' -const CreateAppCard = () => { +export type CreateAppCardProps = { + onSuccess?: () => void +} + +const CreateAppCard = forwardRef(({ onSuccess }, ref) => { const { t } = useTranslation() const [showNewAppDialog, setShowNewAppDialog] = useState(false) return ( - setShowNewAppDialog(true)}> + setShowNewAppDialog(true)}>
@@ -20,9 +24,9 @@ const CreateAppCard = () => {
{/*
{t('app.createFromConfigFile')}
*/} - setShowNewAppDialog(false)} /> + setShowNewAppDialog(false)} />
) -} +}) export default CreateAppCard diff --git a/web/app/(commonLayout)/apps/NewAppDialog.tsx b/web/app/(commonLayout)/apps/NewAppDialog.tsx index 10966ba4a4..e378560dd4 100644 --- a/web/app/(commonLayout)/apps/NewAppDialog.tsx +++ b/web/app/(commonLayout)/apps/NewAppDialog.tsx @@ -21,10 +21,11 @@ import EmojiPicker from '@/app/components/base/emoji-picker' type NewAppDialogProps = { show: boolean + onSuccess?: () => void onClose?: () => void } -const NewAppDialog = ({ show, onClose }: NewAppDialogProps) => { +const NewAppDialog = ({ show, onSuccess, onClose }: NewAppDialogProps) => { const router = useRouter() const { notify } = useContext(ToastContext) const { t } = useTranslation() @@ -79,6 +80,8 @@ const NewAppDialog = ({ show, onClose }: NewAppDialogProps) => { mode: isWithTemplate ? templates.data[selectedTemplateIndex].mode : newAppMode!, config: isWithTemplate ? templates.data[selectedTemplateIndex].model_config : undefined, }) + if (onSuccess) + onSuccess() if (onClose) onClose() notify({ type: 'success', message: t('app.newApp.appCreated') }) diff --git a/web/app/(commonLayout)/datasets/Datasets.tsx b/web/app/(commonLayout)/datasets/Datasets.tsx index b044547748..31e38d7fc0 100644 --- a/web/app/(commonLayout)/datasets/Datasets.tsx +++ b/web/app/(commonLayout)/datasets/Datasets.tsx @@ -1,24 +1,49 @@ 'use client' -import { useEffect } from 'react' -import useSWR from 'swr' -import { DataSet } from '@/models/datasets'; +import { useEffect, useRef } from 'react' +import useSWRInfinite from 'swr/infinite' +import { debounce } from 'lodash-es'; +import { DataSetListResponse } from '@/models/datasets'; import NewDatasetCard from './NewDatasetCard' import DatasetCard from './DatasetCard'; import { fetchDatasets } from '@/service/datasets'; +import { useSelector } from '@/context/app-context'; + +const getKey = (pageIndex: number, previousPageData: DataSetListResponse) => { + if (!pageIndex || previousPageData.has_more) + return { url: 'datasets', params: { page: pageIndex + 1, limit: 30 } } + return null +} const Datasets = () => { - // const { datasets, mutateDatasets } = useAppContext() - const { data: datasetList, mutate: mutateDatasets } = useSWR({ url: '/datasets', params: { page: 1 } }, fetchDatasets) + const { data, isLoading, setSize, mutate } = useSWRInfinite(getKey, fetchDatasets, { revalidateFirstPage: false }) + const loadingStateRef = useRef(false) + const pageContainerRef = useSelector(state => state.pageContainerRef) + const anchorRef = useRef(null) useEffect(() => { - mutateDatasets() + loadingStateRef.current = isLoading + }, [isLoading]) + + useEffect(() => { + const onScroll = debounce(() => { + if (!loadingStateRef.current) { + const { scrollTop, clientHeight } = pageContainerRef.current! + const anchorOffset = anchorRef.current!.offsetTop + if (anchorOffset - scrollTop - clientHeight < 100) { + setSize(size => size + 1) + } + } + }, 50) + + pageContainerRef.current?.addEventListener('scroll', onScroll) + return () => pageContainerRef.current?.removeEventListener('scroll', onScroll) }, []) return ( ) } diff --git a/web/app/(commonLayout)/datasets/NewDatasetCard.tsx b/web/app/(commonLayout)/datasets/NewDatasetCard.tsx index a3f6282c97..72f6b18dcc 100644 --- a/web/app/(commonLayout)/datasets/NewDatasetCard.tsx +++ b/web/app/(commonLayout)/datasets/NewDatasetCard.tsx @@ -1,16 +1,16 @@ 'use client' -import { useState } from 'react' +import { forwardRef, useState } from 'react' import classNames from 'classnames' import { useTranslation } from 'react-i18next' import style from '../list.module.css' -const CreateAppCard = () => { +const CreateAppCard = forwardRef((_, ref) => { const { t } = useTranslation() const [showNewAppDialog, setShowNewAppDialog] = useState(false) return ( - +
@@ -23,6 +23,6 @@ const CreateAppCard = () => { {/*
{t('app.createFromConfigFile')}
*/}
) -} +}) export default CreateAppCard diff --git a/web/app/components/base/dialog/index.tsx b/web/app/components/base/dialog/index.tsx index 9e208d55ec..aaf7edea63 100644 --- a/web/app/components/base/dialog/index.tsx +++ b/web/app/components/base/dialog/index.tsx @@ -33,7 +33,7 @@ const CustomDialog = ({ const close = useCallback(() => onClose?.(), [onClose]) return ( - + void - userProfile: UserProfileResponse - mutateUserProfile: () => void -} - -const AppContext = createContext({ - apps: [], - mutateApps: () => { }, - userProfile: { - id: '', - name: '', - email: '', - }, - mutateUserProfile: () => { }, -}) - -export const useAppContext = () => useContext(AppContext) - -export default AppContext diff --git a/web/context/app-context.tsx b/web/context/app-context.tsx new file mode 100644 index 0000000000..90cfc5ec11 --- /dev/null +++ b/web/context/app-context.tsx @@ -0,0 +1,45 @@ +'use client' + +import { createContext, useContext, useContextSelector } from 'use-context-selector' +import type { App } from '@/types/app' +import type { UserProfileResponse } from '@/models/common' +import { createRef, FC, PropsWithChildren } from 'react' + +export const useSelector = (selector: (value: AppContextValue) => T): T => + useContextSelector(AppContext, selector); + +export type AppContextValue = { + apps: App[] + mutateApps: () => void + userProfile: UserProfileResponse + mutateUserProfile: () => void + pageContainerRef: React.RefObject, + useSelector: typeof useSelector, +} + +const AppContext = createContext({ + apps: [], + mutateApps: () => { }, + userProfile: { + id: '', + name: '', + email: '', + }, + mutateUserProfile: () => { }, + pageContainerRef: createRef(), + useSelector, +}) + +export type AppContextProviderProps = PropsWithChildren<{ + value: Omit +}> + +export const AppContextProvider: FC = ({ value, children }) => ( + + {children} + +) + +export const useAppContext = () => useContext(AppContext) + +export default AppContext diff --git a/web/models/app.ts b/web/models/app.ts index ddafdfbc72..8c5bfd0fab 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -61,6 +61,10 @@ export type SiteConfig = { export type AppListResponse = { data: App[] + has_more: boolean + limit: number + page: number + total: number } export type AppDetailResponse = App diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 8f1206024f..70a4c13ebe 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -29,6 +29,10 @@ export type File = { export type DataSetListResponse = { data: DataSet[] + has_more: boolean + limit: number + page: number + total: number } export type IndexingEstimateResponse = { diff --git a/web/service/apps.ts b/web/service/apps.ts index e9f619787e..f06b7c0ff4 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -4,8 +4,8 @@ import type { ApikeysListResponse, AppDailyConversationsResponse, AppDailyEndUse import type { CommonResponse } from '@/models/common' import type { AppMode, ModelConfig } from '@/types/app' -export const fetchAppList: Fetcher }> = ({ params }) => { - return get('apps', params) as Promise +export const fetchAppList: Fetcher }> = ({ url, params }) => { + return get(url, { params }) as Promise } export const fetchAppDetail: Fetcher = ({ url, id }) => {