mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
🗨️ fix: Prompts Pagination (#9385)
* 🗨️ fix: Prompts Pagination
* ci: Simplify user middleware setup in prompt tests
This commit is contained in:
parent
3a47deac07
commit
460eac36f6
13 changed files with 536 additions and 237 deletions
|
@ -11,9 +11,9 @@ import store from '~/store';
|
|||
|
||||
export default function FilterPrompts({ className = '' }: { className?: string }) {
|
||||
const localize = useLocalize();
|
||||
const { setName } = usePromptGroupsContext();
|
||||
const { name, setName } = usePromptGroupsContext();
|
||||
const { categories } = useCategories('h-4 w-4');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [displayName, setDisplayName] = useState(name || '');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory);
|
||||
|
||||
|
@ -60,13 +60,26 @@ export default function FilterPrompts({ className = '' }: { className?: string }
|
|||
[setCategory],
|
||||
);
|
||||
|
||||
// Sync displayName with name prop when it changes externally
|
||||
useEffect(() => {
|
||||
setDisplayName(name || '');
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayName === '') {
|
||||
// Clear immediately when empty
|
||||
setName('');
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setIsSearching(false);
|
||||
setName(displayName); // Debounced setName call
|
||||
}, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [displayName]);
|
||||
}, [displayName, setName]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full gap-2 text-text-primary', className)}>
|
||||
|
@ -84,7 +97,6 @@ export default function FilterPrompts({ className = '' }: { className?: string }
|
|||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setName(e.target.value);
|
||||
}}
|
||||
isSearching={isSearching}
|
||||
placeholder={localize('com_ui_filter_prompts_name')}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
|
||||
import ManagePrompts from '~/components/Prompts/ManagePrompts';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import List from '~/components/Prompts/Groups/List';
|
||||
import PanelNavigation from './PanelNavigation';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function GroupSidePanel({
|
||||
|
@ -19,38 +19,33 @@ export default function GroupSidePanel({
|
|||
const location = useLocation();
|
||||
const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
|
||||
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
|
||||
const {
|
||||
nextPage,
|
||||
prevPage,
|
||||
isFetching,
|
||||
hasNextPage,
|
||||
groupsQuery,
|
||||
promptGroups,
|
||||
hasPreviousPage,
|
||||
} = usePromptGroupsContext();
|
||||
|
||||
const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } =
|
||||
usePromptGroupsContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mr-2 flex h-auto w-auto min-w-72 flex-col gap-2 lg:w-1/4 xl:w-1/4',
|
||||
'flex h-full w-full flex-col gap-2 md:mr-2 md:w-auto md:min-w-72 lg:w-1/4 xl:w-1/4',
|
||||
isDetailView === true && isSmallerScreen ? 'hidden' : '',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<div className={cn('flex-grow overflow-y-auto', isChatRoute ? '' : 'px-2 md:px-0')}>
|
||||
<List groups={promptGroups} isChatRoute={isChatRoute} isLoading={!!groupsQuery.isLoading} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
{isChatRoute && <ManagePrompts className="select-none" />}
|
||||
<div className={cn(isChatRoute ? '' : 'px-2 pb-3 pt-2 md:px-0')}>
|
||||
<PanelNavigation
|
||||
nextPage={nextPage}
|
||||
prevPage={prevPage}
|
||||
isFetching={isFetching}
|
||||
onPrevious={prevPage}
|
||||
onNext={nextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isChatRoute={isChatRoute}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
/>
|
||||
isLoading={groupsQuery.isFetching}
|
||||
isChatRoute={isChatRoute}
|
||||
>
|
||||
{isChatRoute && <ManagePrompts className="select-none" />}
|
||||
</PanelNavigation>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -3,42 +3,51 @@ import { Button, ThemeSelector } from '@librechat/client';
|
|||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function PanelNavigation({
|
||||
prevPage,
|
||||
nextPage,
|
||||
hasPreviousPage,
|
||||
onPrevious,
|
||||
onNext,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
hasPreviousPage,
|
||||
isLoading,
|
||||
isChatRoute,
|
||||
children,
|
||||
}: {
|
||||
prevPage: () => void;
|
||||
nextPage: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetching: boolean;
|
||||
isLoading?: boolean;
|
||||
isChatRoute: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}</div>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2"
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => prevPage()} disabled={!hasPreviousPage}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={!hasPreviousPage || isLoading}
|
||||
aria-label={localize('com_ui_prev')}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => nextPage()}
|
||||
disabled={!hasNextPage || isFetching}
|
||||
onClick={onNext}
|
||||
disabled={!hasNextPage || isLoading}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ export default function PromptsAccordion() {
|
|||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
|
||||
<FilterPrompts setName={groupsNav.setName} className="items-center justify-center" />
|
||||
<FilterPrompts className="items-center justify-center" />
|
||||
<div className="flex w-full flex-row items-center justify-end">
|
||||
<AutoSendPrompt className="text-xs dark:text-white" />
|
||||
</div>
|
||||
|
|
|
@ -39,7 +39,7 @@ export default function PromptsView() {
|
|||
<DashBreadcrumb />
|
||||
<div className="flex w-full flex-grow flex-row divide-x overflow-hidden dark:divide-gray-600">
|
||||
<GroupSidePanel isDetailView={isDetailView}>
|
||||
<div className="mx-2 mt-1 flex flex-row items-center justify-between">
|
||||
<div className="mt-1 flex flex-row items-center justify-between px-2 md:px-2">
|
||||
<FilterPrompts />
|
||||
</div>
|
||||
</GroupSidePanel>
|
||||
|
|
|
@ -400,22 +400,27 @@ export const usePromptGroupsInfiniteQuery = (
|
|||
params?: t.TPromptGroupsWithFilterRequest,
|
||||
config?: UseInfiniteQueryOptions<t.PromptGroupListResponse, unknown>,
|
||||
) => {
|
||||
const { name, pageSize, category, ...rest } = params || {};
|
||||
const { name, pageSize, category } = params || {};
|
||||
return useInfiniteQuery<t.PromptGroupListResponse, unknown>(
|
||||
[QueryKeys.promptGroups, name, category, pageSize],
|
||||
({ pageParam = '1' }) =>
|
||||
dataService.getPromptGroups({
|
||||
...rest,
|
||||
({ pageParam }) => {
|
||||
const queryParams: t.TPromptGroupsWithFilterRequest = {
|
||||
name,
|
||||
category: category || '',
|
||||
pageNumber: pageParam?.toString(),
|
||||
pageSize: (pageSize || 10).toString(),
|
||||
}),
|
||||
limit: (pageSize || 10).toString(),
|
||||
};
|
||||
|
||||
// Only add cursor if it's a valid string
|
||||
if (pageParam && typeof pageParam === 'string') {
|
||||
queryParams.cursor = pageParam;
|
||||
}
|
||||
|
||||
return dataService.getPromptGroups(queryParams);
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
const currentPageNumber = Number(lastPage.pageNumber);
|
||||
const totalPages = Number(lastPage.pages);
|
||||
return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined;
|
||||
// Use cursor-based pagination - ensure we return a valid cursor or undefined
|
||||
return lastPage.has_more && lastPage.after ? lastPage.after : undefined;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
|
|
|
@ -1,92 +1,108 @@
|
|||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { usePromptGroupsInfiniteQuery } from '~/data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import debounce from 'lodash/debounce';
|
||||
import store from '~/store';
|
||||
|
||||
export default function usePromptGroupsNav() {
|
||||
const queryClient = useQueryClient();
|
||||
const category = useRecoilValue(store.promptsCategory);
|
||||
const [pageSize] = useRecoilState(store.promptsPageSize);
|
||||
const [category] = useRecoilState(store.promptsCategory);
|
||||
const [name, setName] = useRecoilState(store.promptsName);
|
||||
const [pageSize, setPageSize] = useRecoilState(store.promptsPageSize);
|
||||
const [pageNumber, setPageNumber] = useRecoilState(store.promptsPageNumber);
|
||||
|
||||
const maxPageNumberReached = useRef(1);
|
||||
const prevFiltersRef = useRef({ name, category, pageSize });
|
||||
// Track current page index and cursor history
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
const cursorHistoryRef = useRef<Array<string | null>>([null]); // Start with null for first page
|
||||
|
||||
useEffect(() => {
|
||||
if (pageNumber > 1 && pageNumber > maxPageNumberReached.current) {
|
||||
maxPageNumberReached.current = pageNumber;
|
||||
}
|
||||
}, [pageNumber]);
|
||||
const prevFiltersRef = useRef({ name, category });
|
||||
|
||||
const groupsQuery = usePromptGroupsInfiniteQuery({
|
||||
name,
|
||||
pageSize,
|
||||
category,
|
||||
pageNumber: pageNumber + '',
|
||||
});
|
||||
|
||||
// Get the current page data
|
||||
const currentPageData = useMemo(() => {
|
||||
if (!groupsQuery.data?.pages || groupsQuery.data.pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Ensure we don't go out of bounds
|
||||
const pageIndex = Math.min(currentPageIndex, groupsQuery.data.pages.length - 1);
|
||||
return groupsQuery.data.pages[pageIndex];
|
||||
}, [groupsQuery.data?.pages, currentPageIndex]);
|
||||
|
||||
// Get prompt groups for current page
|
||||
const promptGroups = useMemo(() => {
|
||||
return currentPageData?.promptGroups || [];
|
||||
}, [currentPageData]);
|
||||
|
||||
// Calculate pagination state
|
||||
const hasNextPage = useMemo(() => {
|
||||
if (!currentPageData) return false;
|
||||
|
||||
// If we're not on the last loaded page, we have a next page
|
||||
if (currentPageIndex < (groupsQuery.data?.pages?.length || 0) - 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we're on the last loaded page, check if there are more from backend
|
||||
return currentPageData.has_more || false;
|
||||
}, [currentPageData, currentPageIndex, groupsQuery.data?.pages?.length]);
|
||||
|
||||
const hasPreviousPage = currentPageIndex > 0;
|
||||
const currentPage = currentPageIndex + 1;
|
||||
const totalPages = hasNextPage ? currentPage + 1 : currentPage;
|
||||
|
||||
// Navigate to next page
|
||||
const nextPage = useCallback(async () => {
|
||||
if (!hasNextPage) return;
|
||||
|
||||
const nextPageIndex = currentPageIndex + 1;
|
||||
|
||||
// Check if we need to load more data
|
||||
if (nextPageIndex >= (groupsQuery.data?.pages?.length || 0)) {
|
||||
// We need to fetch the next page
|
||||
const result = await groupsQuery.fetchNextPage();
|
||||
if (result.isSuccess && result.data?.pages) {
|
||||
// Update cursor history with the cursor for the next page
|
||||
const lastPage = result.data.pages[result.data.pages.length - 2]; // Get the page before the newly fetched one
|
||||
if (lastPage?.after && !cursorHistoryRef.current.includes(lastPage.after)) {
|
||||
cursorHistoryRef.current.push(lastPage.after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentPageIndex(nextPageIndex);
|
||||
}, [currentPageIndex, hasNextPage, groupsQuery]);
|
||||
|
||||
// Navigate to previous page
|
||||
const prevPage = useCallback(() => {
|
||||
if (!hasPreviousPage) return;
|
||||
setCurrentPageIndex(currentPageIndex - 1);
|
||||
}, [currentPageIndex, hasPreviousPage]);
|
||||
|
||||
// Reset when filters change
|
||||
useEffect(() => {
|
||||
const filtersChanged =
|
||||
prevFiltersRef.current.name !== name ||
|
||||
prevFiltersRef.current.category !== category ||
|
||||
prevFiltersRef.current.pageSize !== pageSize;
|
||||
prevFiltersRef.current.name !== name || prevFiltersRef.current.category !== category;
|
||||
|
||||
if (!filtersChanged) {
|
||||
return;
|
||||
if (filtersChanged) {
|
||||
setCurrentPageIndex(0);
|
||||
cursorHistoryRef.current = [null];
|
||||
prevFiltersRef.current = { name, category };
|
||||
}
|
||||
maxPageNumberReached.current = 1;
|
||||
setPageNumber(1);
|
||||
|
||||
// Only reset queries if we're not already on page 1
|
||||
// This prevents double queries when filters change
|
||||
if (pageNumber !== 1) {
|
||||
queryClient.invalidateQueries([QueryKeys.promptGroups, name, category, pageSize]);
|
||||
}
|
||||
|
||||
prevFiltersRef.current = { name, category, pageSize };
|
||||
}, [pageSize, name, category, setPageNumber, pageNumber, queryClient]);
|
||||
|
||||
const promptGroups = useMemo(() => {
|
||||
return groupsQuery.data?.pages[pageNumber - 1 + '']?.promptGroups || [];
|
||||
}, [groupsQuery.data, pageNumber]);
|
||||
|
||||
const nextPage = () => {
|
||||
setPageNumber((prev) => prev + 1);
|
||||
groupsQuery.hasNextPage && groupsQuery.fetchNextPage();
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
setPageNumber((prev) => prev - 1);
|
||||
groupsQuery.hasPreviousPage && groupsQuery.fetchPreviousPage();
|
||||
};
|
||||
|
||||
const isFetching = groupsQuery.isFetchingNextPage;
|
||||
const hasNextPage = !!groupsQuery.hasNextPage || maxPageNumberReached.current > pageNumber;
|
||||
const hasPreviousPage = !!groupsQuery.hasPreviousPage || pageNumber > 1;
|
||||
|
||||
const debouncedSetName = useMemo(
|
||||
() =>
|
||||
debounce((nextValue: string) => {
|
||||
setName(nextValue);
|
||||
}, 850),
|
||||
[setName],
|
||||
);
|
||||
}, [name, category]);
|
||||
|
||||
return {
|
||||
name,
|
||||
setName: debouncedSetName,
|
||||
promptGroups,
|
||||
groupsQuery,
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
isFetching,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
hasNextPage,
|
||||
groupsQuery,
|
||||
promptGroups,
|
||||
hasPreviousPage,
|
||||
isFetching: groupsQuery.isFetching,
|
||||
name,
|
||||
setName,
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue