mirror of https://github.com/buster-so/buster.git
make query memoized
This commit is contained in:
parent
ffde4ef59c
commit
9a9b5fb674
|
@ -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 = <T = SearchTextResponse>(
|
||||
|
@ -16,3 +22,27 @@ export const useSearch = <T = SearchTextResponse>(
|
|||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSearchInfinite = (
|
||||
params: Pick<Parameters<typeof search>[0], 'page_size' | 'assetTypes'> = {
|
||||
page_size: 5,
|
||||
assetTypes: ['chat'],
|
||||
}
|
||||
) => {
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
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 };
|
||||
};
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { useInfiniteScroll } from './useInfiniteScroll';
|
|
@ -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<TData, TError = ApiError> = Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
SearchPaginatedResponse<TData>,
|
||||
TError,
|
||||
InfiniteData<SearchPaginatedResponse<TData>>,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
'getNextPageParam' | 'initialPageParam'
|
||||
> & {
|
||||
/**
|
||||
* Infinite scroll configuration
|
||||
*/
|
||||
scrollConfig?: InfiniteScrollConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return type for useInfiniteScroll hook
|
||||
*/
|
||||
type UseInfiniteScrollResult<TData, TError = ApiError> = UseInfiniteQueryResult<
|
||||
InfiniteData<SearchPaginatedResponse<TData>>,
|
||||
TError
|
||||
> & {
|
||||
/**
|
||||
* Ref to attach to the scrollable container element
|
||||
*/
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* 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 (
|
||||
* <div ref={scrollContainerRef} style={{ height: '500px', overflow: 'auto' }}>
|
||||
* {allResults.map(item => <Item key={item.id} data={item} />)}
|
||||
* {isLoading && <div>Loading...</div>}
|
||||
* </div>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useInfiniteScroll<TData, TError = ApiError>(
|
||||
options: UseInfiniteScrollOptions<TData, TError>
|
||||
): UseInfiniteScrollResult<TData, TError> {
|
||||
const { scrollConfig, ...queryOptions } = options;
|
||||
const scrollThreshold =
|
||||
scrollConfig?.scrollThreshold ?? InfiniteScrollConfigSchema.scrollThreshold;
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(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,
|
||||
};
|
||||
}
|
|
@ -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<SearchTextResponse>({
|
||||
queryKey: ['search', 'results', params] as const,
|
||||
staleTime: 1000 * 15, // 15 seconds,
|
||||
staleTime: 1000 * 30, // 30 seconds,
|
||||
});
|
||||
|
||||
export const getSearchResultInfinite = () =>
|
||||
infiniteQueryOptions<SearchTextResponse>({
|
||||
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 = {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -87,26 +87,28 @@ export const BusterChatInputBase: React.FC<BusterChatInputProps> = React.memo(
|
|||
return [shortcutsMentionsSuggestions];
|
||||
}, [shortcutsMentionsSuggestions]);
|
||||
|
||||
const onSubmitPreflight = (valueProp?: ReturnType<MentionInputSuggestionsRef['getValue']>) => {
|
||||
if (submitting) {
|
||||
console.warn('Input is submitting');
|
||||
return;
|
||||
}
|
||||
const onSubmitPreflight = useMemoizedFn(
|
||||
(valueProp?: ReturnType<MentionInputSuggestionsRef['getValue']>) => {
|
||||
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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="p-4 max-h-[500px] overflow-y-auto border border-red-500"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<h1 className="text-2xl font-bold mb-4">Search Results</h1>
|
||||
|
||||
{isLoading && <div>Loading...</div>}
|
||||
|
||||
{allResults.length > 0 && (
|
||||
<div className="space-y-2 ">
|
||||
{allResults.map((result, index) => (
|
||||
<div key={`${result.assetId}-${index}`} className="p-4 border rounded">
|
||||
{JSON.stringify(result)}
|
||||
</div>
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<div className="p-4 text-center text-gray-500">Loading more...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded disabled:bg-gray-300"
|
||||
>
|
||||
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasNextPage && allResults.length > 0 && (
|
||||
<div className="mt-4 text-gray-500">No more results</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue