diff --git a/apps/web/src/api/buster_rest/search/queryRequests.ts b/apps/web/src/api/buster_rest/search/queryRequests.ts index 1a154c0a4..8b56a69a6 100644 --- a/apps/web/src/api/buster_rest/search/queryRequests.ts +++ b/apps/web/src/api/buster_rest/search/queryRequests.ts @@ -1,7 +1,13 @@ import type { SearchTextResponse } from '@buster/server-shared/search'; -import { keepPreviousData, type UseQueryOptions, useQuery } from '@tanstack/react-query'; +import { + keepPreviousData, + type UseQueryOptions, + useInfiniteQuery, + useQuery, +} from '@tanstack/react-query'; +import { useState } from 'react'; import type { ApiError } from '@/api/errors'; -import { searchQueryKeys } from '@/api/query_keys/search'; +import { getSearchResultInfinite, searchQueryKeys } from '@/api/query_keys/search'; import { search } from './requests'; export const useSearch = ( @@ -16,3 +22,27 @@ export const useSearch = ( placeholderData: keepPreviousData, }); }; + +export const useSearchInfinite = ( + params: Pick[0], 'page_size' | 'assetTypes'> = { + page_size: 5, + assetTypes: ['chat'], + } +) => { + const [searchQuery, setSearchQuery] = useState(''); + + const queryResult = useInfiniteQuery({ + queryKey: ['search', 'results', 'infinite', params] as const, + staleTime: 1000 * 30, // 30 seconds, + queryFn: ({ pageParam = 1 }) => search({ query: searchQuery, page: pageParam, ...params }), + getNextPageParam: (lastPage) => { + if (!lastPage.pagination.has_more) { + return undefined; + } + return lastPage.pagination.page + 1; + }, + initialPageParam: 1, + }); + + return { ...queryResult, setSearchQuery, searchQuery }; +}; diff --git a/apps/web/src/api/query-helpers/index.ts b/apps/web/src/api/query-helpers/index.ts new file mode 100644 index 000000000..22d9cef76 --- /dev/null +++ b/apps/web/src/api/query-helpers/index.ts @@ -0,0 +1 @@ +export { useInfiniteScroll } from './useInfiniteScroll'; diff --git a/apps/web/src/api/query-helpers/useInfiniteScroll.ts b/apps/web/src/api/query-helpers/useInfiniteScroll.ts new file mode 100644 index 000000000..022882d56 --- /dev/null +++ b/apps/web/src/api/query-helpers/useInfiniteScroll.ts @@ -0,0 +1,130 @@ +import type { SearchPaginatedResponse } from '@buster/server-shared'; +import type { + InfiniteData, + QueryKey, + UseInfiniteQueryOptions, + UseInfiniteQueryResult, +} from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; +import type { ApiError } from '@/api/errors'; + +/** + * Configuration for infinite scroll behavior + */ +const InfiniteScrollConfigSchema = { + /** + * Distance in pixels from the bottom to trigger next page fetch + * @default 100 + */ + scrollThreshold: 100, +} as const; + +type InfiniteScrollConfig = { + scrollThreshold?: number; +}; + +/** + * Hook options extending react-query's infinite query options + */ +type UseInfiniteScrollOptions = Omit< + UseInfiniteQueryOptions< + SearchPaginatedResponse, + TError, + InfiniteData>, + QueryKey, + number + >, + 'getNextPageParam' | 'initialPageParam' +> & { + /** + * Infinite scroll configuration + */ + scrollConfig?: InfiniteScrollConfig; +}; + +/** + * Return type for useInfiniteScroll hook + */ +type UseInfiniteScrollResult = UseInfiniteQueryResult< + InfiniteData>, + TError +> & { + /** + * Ref to attach to the scrollable container element + */ + scrollContainerRef: React.RefObject; + /** + * Flattened array of all results from all pages + */ + allResults: TData[]; +}; + +/** + * Reusable infinite scroll hook that combines react-query's useInfiniteQuery + * with automatic scroll detection and pagination. + * + * @example + * ```tsx + * const { scrollContainerRef, allResults, isLoading } = useInfiniteScroll({ + * queryKey: ['search', searchQuery], + * queryFn: ({ pageParam = 1 }) => searchApi({ query: searchQuery, page: pageParam }), + * }); + * + * return ( + *
+ * {allResults.map(item => )} + * {isLoading &&
Loading...
} + *
+ * ); + * ``` + */ +export function useInfiniteScroll( + options: UseInfiniteScrollOptions +): UseInfiniteScrollResult { + const { scrollConfig, ...queryOptions } = options; + const scrollThreshold = + scrollConfig?.scrollThreshold ?? InfiniteScrollConfigSchema.scrollThreshold; + + const scrollContainerRef = useRef(null); + + const queryResult = useInfiniteQuery({ + ...queryOptions, + getNextPageParam: (lastPage) => { + if (!lastPage.pagination.has_more) { + return undefined; + } + return lastPage.pagination.page + 1; + }, + initialPageParam: 1, + }); + + const { fetchNextPage, hasNextPage, isFetchingNextPage } = queryResult; + + // Combine all pages into a single array of results + const allResults = queryResult.data?.pages.flatMap((page) => page.data) ?? []; + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + // Trigger when user is within scrollThreshold pixels of the bottom + if (scrollHeight - scrollTop - clientHeight < scrollThreshold) { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + } + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, [hasNextPage, isFetchingNextPage, fetchNextPage, scrollThreshold]); + + return { + ...queryResult, + scrollContainerRef, + allResults, + }; +} diff --git a/apps/web/src/api/query_keys/search.ts b/apps/web/src/api/query_keys/search.ts index 944612157..06e2832ab 100644 --- a/apps/web/src/api/query_keys/search.ts +++ b/apps/web/src/api/query_keys/search.ts @@ -1,10 +1,24 @@ import type { SearchTextRequest, SearchTextResponse } from '@buster/server-shared'; -import { queryOptions } from '@tanstack/react-query'; +import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; +import type { search } from '../buster_rest/search'; export const getSearchResult = (params: SearchTextRequest) => queryOptions({ queryKey: ['search', 'results', params] as const, - staleTime: 1000 * 15, // 15 seconds, + staleTime: 1000 * 30, // 30 seconds, + }); + +export const getSearchResultInfinite = () => + infiniteQueryOptions({ + queryKey: ['search', 'results', 'infinite'] as const, + staleTime: 1000 * 30, // 30 seconds, + getNextPageParam: (lastPage) => { + if (!lastPage.pagination.has_more) { + return undefined; + } + return lastPage.pagination.page + 1; + }, + initialPageParam: 1, }); export const searchQueryKeys = { diff --git a/apps/web/src/components/features/input/BusterChatInput/BusterChatInput.tsx b/apps/web/src/components/features/input/BusterChatInput/BusterChatInput.tsx index 3374f41d7..c31485e97 100644 --- a/apps/web/src/components/features/input/BusterChatInput/BusterChatInput.tsx +++ b/apps/web/src/components/features/input/BusterChatInput/BusterChatInput.tsx @@ -23,8 +23,8 @@ export const BusterChatInput: React.FC<{ } }); - const onSubmit: BusterChatInputProps['onSubmit'] = useMemoizedFn((d) => { - onStartNewChat({ prompt: d.transformedValue, mode: d.mode }); + const onSubmit: BusterChatInputProps['onSubmit'] = useMemoizedFn(({ transformedValue, mode }) => { + return onStartNewChat({ prompt: transformedValue, mode }); }); return ( diff --git a/apps/web/src/components/features/input/BusterChatInput/BusterChatInputBase.tsx b/apps/web/src/components/features/input/BusterChatInput/BusterChatInputBase.tsx index e2048d8b8..94ac8fa7c 100644 --- a/apps/web/src/components/features/input/BusterChatInput/BusterChatInputBase.tsx +++ b/apps/web/src/components/features/input/BusterChatInput/BusterChatInputBase.tsx @@ -87,26 +87,28 @@ export const BusterChatInputBase: React.FC = React.memo( return [shortcutsMentionsSuggestions]; }, [shortcutsMentionsSuggestions]); - const onSubmitPreflight = (valueProp?: ReturnType) => { - if (submitting) { - console.warn('Input is submitting'); - return; - } + const onSubmitPreflight = useMemoizedFn( + (valueProp?: ReturnType) => { + if (submitting) { + console.warn('Input is submitting'); + return; + } - const value = valueProp || mentionInputSuggestionsRef.current?.getValue?.(); - if (!value) { - console.warn('Value is not defined'); - return; - } + const value = valueProp || mentionInputSuggestionsRef.current?.getValue?.(); + if (!value) { + console.warn('Value is not defined'); + return; + } - if (disabled || !value || !value.transformedValue) { - console.warn('Input is disabled or value is not defined'); - openInfoMessage('Please enter a question or type ‘/’ for shortcuts...'); - return; - } + if (disabled || !value || !value.transformedValue) { + console.warn('Input is disabled or value is not defined'); + openInfoMessage('Please enter a question or type ‘/’ for shortcuts...'); + return; + } - onSubmit({ ...value, mode }); - }; + onSubmit({ ...value, mode }); + } + ); const onSuggestionItemClick = useMemoizedFn((d: MentionInputSuggestionsOnSelectParams) => { if (d.addValueToInput) { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a81c4bbde..c2df03b6c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as EmbedDashboardDashboardIdRouteImport } from './routes/embed/da import { Route as EmbedChatChatIdRouteImport } from './routes/embed/chat.$chatId' import { Route as AppSettingsRestricted_layoutRouteImport } from './routes/app/_settings/_restricted_layout' import { Route as AppSettingsPermissionsRouteImport } from './routes/app/_settings/_permissions' +import { Route as AppAppTestPaginationRouteImport } from './routes/app/_app/test-pagination' import { Route as AppAppNewUserRouteImport } from './routes/app/_app/new-user' import { Route as AppAppHomeRouteImport } from './routes/app/_app/home' import { Route as AppAppAssetRouteImport } from './routes/app/_app/_asset' @@ -292,6 +293,11 @@ const AppSettingsPermissionsRoute = AppSettingsPermissionsRouteImport.update({ id: '/_permissions', getParentRoute: () => AppSettingsRoute, } as any) +const AppAppTestPaginationRoute = AppAppTestPaginationRouteImport.update({ + id: '/test-pagination', + path: '/test-pagination', + getParentRoute: () => AppAppRoute, +} as any) const AppAppNewUserRoute = AppAppNewUserRouteImport.update({ id: '/new-user', path: '/new-user', @@ -1188,6 +1194,7 @@ export interface FileRoutesByFullPath { '/app/': typeof AppIndexRoute '/app/home': typeof AppAppHomeRouteWithChildren '/app/new-user': typeof AppAppNewUserRouteWithChildren + '/app/test-pagination': typeof AppAppTestPaginationRoute '/embed/chat/$chatId': typeof EmbedChatChatIdRouteWithChildren '/embed/dashboard/$dashboardId': typeof EmbedDashboardDashboardIdRoute '/embed/metric/$metricId': typeof EmbedMetricMetricIdRoute @@ -1320,6 +1327,7 @@ export interface FileRoutesByTo { '/auth/logout': typeof AuthLogoutRoute '/auth/reset-password': typeof AuthResetPasswordRoute '/info/getting-started': typeof InfoGettingStartedRoute + '/app/test-pagination': typeof AppAppTestPaginationRoute '/embed/dashboard/$dashboardId': typeof EmbedDashboardDashboardIdRoute '/embed/metric/$metricId': typeof EmbedMetricMetricIdRoute '/embed/report/$reportId': typeof EmbedReportReportIdRoute @@ -1436,6 +1444,7 @@ export interface FileRoutesById { '/app/_app/_asset': typeof AppAppAssetRouteWithChildren '/app/_app/home': typeof AppAppHomeRouteWithChildren '/app/_app/new-user': typeof AppAppNewUserRouteWithChildren + '/app/_app/test-pagination': typeof AppAppTestPaginationRoute '/app/_settings/_permissions': typeof AppSettingsPermissionsRouteWithChildren '/app/_settings/_restricted_layout': typeof AppSettingsRestricted_layoutRouteWithChildren '/embed/chat/$chatId': typeof EmbedChatChatIdRouteWithChildren @@ -1591,6 +1600,7 @@ export interface FileRouteTypes { | '/app/' | '/app/home' | '/app/new-user' + | '/app/test-pagination' | '/embed/chat/$chatId' | '/embed/dashboard/$dashboardId' | '/embed/metric/$metricId' @@ -1723,6 +1733,7 @@ export interface FileRouteTypes { | '/auth/logout' | '/auth/reset-password' | '/info/getting-started' + | '/app/test-pagination' | '/embed/dashboard/$dashboardId' | '/embed/metric/$metricId' | '/embed/report/$reportId' @@ -1838,6 +1849,7 @@ export interface FileRouteTypes { | '/app/_app/_asset' | '/app/_app/home' | '/app/_app/new-user' + | '/app/_app/test-pagination' | '/app/_settings/_permissions' | '/app/_settings/_restricted_layout' | '/embed/chat/$chatId' @@ -2141,6 +2153,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppSettingsPermissionsRouteImport parentRoute: typeof AppSettingsRoute } + '/app/_app/test-pagination': { + id: '/app/_app/test-pagination' + path: '/test-pagination' + fullPath: '/app/test-pagination' + preLoaderRoute: typeof AppAppTestPaginationRouteImport + parentRoute: typeof AppAppRoute + } '/app/_app/new-user': { id: '/app/_app/new-user' path: '/new-user' @@ -3610,6 +3629,7 @@ interface AppAppRouteChildren { AppAppAssetRoute: typeof AppAppAssetRouteWithChildren AppAppHomeRoute: typeof AppAppHomeRouteWithChildren AppAppNewUserRoute: typeof AppAppNewUserRouteWithChildren + AppAppTestPaginationRoute: typeof AppAppTestPaginationRoute AppAppDatasetsDatasetIdRoute: typeof AppAppDatasetsDatasetIdRouteWithChildren AppAppChatsIndexRoute: typeof AppAppChatsIndexRoute AppAppCollectionsIndexRoute: typeof AppAppCollectionsIndexRoute @@ -3624,6 +3644,7 @@ const AppAppRouteChildren: AppAppRouteChildren = { AppAppAssetRoute: AppAppAssetRouteWithChildren, AppAppHomeRoute: AppAppHomeRouteWithChildren, AppAppNewUserRoute: AppAppNewUserRouteWithChildren, + AppAppTestPaginationRoute: AppAppTestPaginationRoute, AppAppDatasetsDatasetIdRoute: AppAppDatasetsDatasetIdRouteWithChildren, AppAppChatsIndexRoute: AppAppChatsIndexRoute, AppAppCollectionsIndexRoute: AppAppCollectionsIndexRoute, diff --git a/apps/web/src/routes/app/_app/test-pagination.tsx b/apps/web/src/routes/app/_app/test-pagination.tsx new file mode 100644 index 000000000..6732d1b0d --- /dev/null +++ b/apps/web/src/routes/app/_app/test-pagination.tsx @@ -0,0 +1,73 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { useEffect, useRef } from 'react'; +import { search, useSearchInfinite } from '@/api/buster_rest/search'; + +export const Route = createFileRoute('/app/_app/test-pagination')({ + component: RouteComponent, +}); + +function RouteComponent() { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useSearchInfinite(); + const scrollContainerRef = useRef(null); + + // Combine all pages into a single array of results + const allResults = data?.pages.flatMap((page) => page.data) ?? []; + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + // Trigger when user is within 100px of the bottom + if (scrollHeight - scrollTop - clientHeight < 100) { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + } + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( +
+

Search Results

+ + {isLoading &&
Loading...
} + + {allResults.length > 0 && ( +
+ {allResults.map((result, index) => ( +
+ {JSON.stringify(result)} +
+ ))} + {isFetchingNextPage && ( +
Loading more...
+ )} +
+ )} + + {hasNextPage && ( + + )} + + {!hasNextPage && allResults.length > 0 && ( +
No more results
+ )} +
+ ); +}