mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Add pagination to task list (#156)
Co-authored-by: matt <matt@roocode.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
44ee729540
commit
fb3df3a976
9 changed files with 568 additions and 34 deletions
|
|
@ -395,19 +395,29 @@ const taskSchema = z.object({
|
|||
|
||||
export type TaskWithUser = z.infer<typeof taskSchema> & { user: User };
|
||||
|
||||
export type TasksResult = {
|
||||
tasks: TaskWithUser[];
|
||||
hasMore: boolean;
|
||||
nextCursor?: number;
|
||||
};
|
||||
|
||||
export const getTasks = async ({
|
||||
orgId,
|
||||
userId,
|
||||
taskId,
|
||||
allowCrossUserAccess = false,
|
||||
skipAuth = false,
|
||||
limit = 20,
|
||||
cursor,
|
||||
}: {
|
||||
orgId?: string | null;
|
||||
userId?: string | null;
|
||||
taskId?: string | null;
|
||||
allowCrossUserAccess?: boolean;
|
||||
skipAuth?: boolean;
|
||||
}): Promise<TaskWithUser[]> => {
|
||||
limit?: number;
|
||||
cursor?: number;
|
||||
}): Promise<TasksResult> => {
|
||||
let effectiveUserId = userId;
|
||||
|
||||
if (!skipAuth) {
|
||||
|
|
@ -422,7 +432,7 @@ export const getTasks = async ({
|
|||
// For personal accounts, query by userId instead of orgId
|
||||
// Exception: when skipAuth is true (for public shares), we can query without userId
|
||||
if (!orgId && !effectiveUserId && !skipAuth) {
|
||||
return []; // Personal accounts must have a userId unless we're skipping auth
|
||||
return { tasks: [], hasMore: false }; // Personal accounts must have a userId unless we're skipping auth
|
||||
}
|
||||
|
||||
const userFilter = effectiveUserId ? 'AND e.userId = {userId: String}' : '';
|
||||
|
|
@ -441,12 +451,13 @@ export const getTasks = async ({
|
|||
? 'orgId IS NULL'
|
||||
: 'orgId = {orgId: String}';
|
||||
|
||||
const queryParams: Record<string, string | string[]> = {
|
||||
const queryParams: Record<string, string | string[] | number> = {
|
||||
types: [
|
||||
TelemetryEventName.TASK_CREATED,
|
||||
TelemetryEventName.TASK_COMPLETED,
|
||||
TelemetryEventName.LLM_COMPLETION,
|
||||
],
|
||||
limit: limit + 1, // Request one extra to determine hasMore
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
|
|
@ -461,6 +472,18 @@ export const getTasks = async ({
|
|||
queryParams.taskId = taskId;
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
queryParams.cursor = cursor;
|
||||
}
|
||||
|
||||
// TODO: Handle same-timestamp edge cases
|
||||
// Currently using only timestamp as cursor, but this can miss/duplicate tasks
|
||||
// if multiple tasks have the same timestamp at page boundaries.
|
||||
// Future improvement: use composite cursor (timestamp, taskId, userId)
|
||||
const havingFilter = cursor
|
||||
? 'HAVING MIN(e.timestamp) < {cursor: Int32}'
|
||||
: '';
|
||||
|
||||
const results = await analytics.query({
|
||||
query: `
|
||||
WITH first_messages AS (
|
||||
|
|
@ -497,7 +520,9 @@ export const getTasks = async ({
|
|||
${userFilter}
|
||||
${taskFilter}
|
||||
GROUP BY 1, 2
|
||||
${havingFilter}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT {limit: Int32}
|
||||
`,
|
||||
format: 'JSONEachRow',
|
||||
query_params: queryParams,
|
||||
|
|
@ -507,9 +532,25 @@ export const getTasks = async ({
|
|||
|
||||
const users = await getUsersById(tasks.map(({ userId }) => userId));
|
||||
|
||||
return tasks
|
||||
const taskWithUsers = tasks
|
||||
.map((usage) => ({ ...usage, user: users[usage.userId] }))
|
||||
.filter((usage): usage is TaskWithUser => !!usage.user);
|
||||
|
||||
// Calculate hasMore and nextCursor using limit + 1 pattern
|
||||
const hasMore = taskWithUsers.length === limit + 1;
|
||||
const nextCursor =
|
||||
hasMore && taskWithUsers.length > 0
|
||||
? taskWithUsers[limit - 1]?.timestamp // Use the last item we'll return, not the extra one
|
||||
: undefined;
|
||||
|
||||
// Slice down to the requested limit
|
||||
const finalTasks = hasMore ? taskWithUsers.slice(0, limit) : taskWithUsers;
|
||||
|
||||
return {
|
||||
tasks: finalTasks,
|
||||
hasMore,
|
||||
nextCursor,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ export async function canShareTask(taskId: string): Promise<{
|
|||
// Handle personal context
|
||||
if (!orgId) {
|
||||
// Personal users can only share tasks they created
|
||||
const tasks = await getTasks({ taskId, orgId: null, userId });
|
||||
const task = tasks[0];
|
||||
const result = await getTasks({ taskId, orgId: null, userId });
|
||||
const task = result.tasks[0];
|
||||
|
||||
if (!task || task.userId !== userId) {
|
||||
return {
|
||||
|
|
@ -77,13 +77,13 @@ export async function canShareTask(taskId: string): Promise<{
|
|||
// Organization context - existing logic
|
||||
// Admins can share any task in the organization
|
||||
if (orgRole === 'org:admin') {
|
||||
const tasks = await getTasks({
|
||||
const result = await getTasks({
|
||||
taskId,
|
||||
orgId,
|
||||
allowCrossUserAccess: true,
|
||||
});
|
||||
|
||||
const task = tasks[0];
|
||||
const task = result.tasks[0];
|
||||
|
||||
if (!task) {
|
||||
return { canShare: false, error: 'Task not found' };
|
||||
|
|
@ -93,8 +93,8 @@ export async function canShareTask(taskId: string): Promise<{
|
|||
}
|
||||
|
||||
// Members can only share tasks they created
|
||||
const tasks = await getTasks({ taskId, orgId });
|
||||
const task = tasks[0];
|
||||
const result = await getTasks({ taskId, orgId });
|
||||
const task = result.tasks[0];
|
||||
|
||||
// Additional check: ensure the task belongs to the requesting user
|
||||
if (task && task.userId !== userId) {
|
||||
|
|
@ -295,13 +295,13 @@ export async function getTaskByShareToken(token: string): Promise<{
|
|||
// Get task data based on visibility
|
||||
// For organization shares, we need to skip auth since the viewer might be a different user
|
||||
// but they're authorized to view this share within their organization
|
||||
const tasks = await getTasks({
|
||||
const result = await getTasks({
|
||||
taskId: share.taskId,
|
||||
orgId: share.orgId, // Will be null for personal shares
|
||||
allowCrossUserAccess: true,
|
||||
skipAuth: true, // Skip auth for both public and organization shares since we've already validated access above
|
||||
});
|
||||
const task = tasks[0];
|
||||
const task = result.tasks[0];
|
||||
|
||||
if (!task) {
|
||||
return null;
|
||||
|
|
@ -528,13 +528,13 @@ export async function getSharedTaskMessages(
|
|||
// For public shares, no auth check needed
|
||||
|
||||
// Get the task to get the userId
|
||||
const tasks = await getTasks({
|
||||
const result = await getTasks({
|
||||
taskId: share.taskId,
|
||||
orgId: share.orgId,
|
||||
allowCrossUserAccess: true,
|
||||
skipAuth: true, // We've already validated access above
|
||||
});
|
||||
const task = tasks[0];
|
||||
const task = result.tasks[0];
|
||||
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { useAuth } from '@clerk/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { TaskWithUser } from '@/actions/analytics';
|
||||
import { getTasks } from '@/actions/analytics';
|
||||
|
||||
import { useRealtimePolling } from '@/hooks/useRealtimePolling';
|
||||
import { Skeleton } from '@/components/ui';
|
||||
import { getTasks } from '@/actions/analytics';
|
||||
import { Skeleton, CursorPaginationControls } from '@/components/ui';
|
||||
import { TaskCard } from '@/components/usage';
|
||||
import { useCursorPagination } from '@/hooks/usePagination';
|
||||
|
||||
import type { Filter } from './types';
|
||||
|
||||
|
|
@ -26,28 +28,46 @@ export const Tasks = ({
|
|||
const { orgId } = useAuth();
|
||||
const polling = useRealtimePolling({ enabled: true, interval: 5000 });
|
||||
|
||||
const { data = [], isPending } = useQuery({
|
||||
// Initialize cursor-based pagination
|
||||
const pagination = useCursorPagination(100);
|
||||
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: [
|
||||
'getTasks',
|
||||
'getTasksPaginated',
|
||||
orgId,
|
||||
userRole === 'member' ? currentUserId : null,
|
||||
!orgId,
|
||||
pagination.currentCursor,
|
||||
pagination.pageSize,
|
||||
],
|
||||
queryFn: () =>
|
||||
getTasks({
|
||||
orgId,
|
||||
userId: userRole === 'member' ? currentUserId : undefined,
|
||||
limit: pagination.pageSize,
|
||||
cursor: pagination.currentCursor,
|
||||
}),
|
||||
enabled: true, // Run for both personal and organization context
|
||||
...polling,
|
||||
});
|
||||
|
||||
// Update cursor when we get new data
|
||||
useEffect(() => {
|
||||
if (data?.nextCursor) {
|
||||
pagination.setNextCursor(data.nextCursor);
|
||||
}
|
||||
}, [data?.nextCursor, pagination]);
|
||||
|
||||
// Note: The pagination hook automatically handles total updates via the pagination controls
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
const allTasks = data?.tasks || [];
|
||||
|
||||
if (!filter) {
|
||||
return data;
|
||||
return allTasks;
|
||||
}
|
||||
|
||||
return data.filter((task) => {
|
||||
return allTasks.filter((task) => {
|
||||
if (filter.type === 'userId') {
|
||||
return task.userId === filter.value;
|
||||
} else if (filter.type === 'model') {
|
||||
|
|
@ -57,7 +77,7 @@ export const Tasks = ({
|
|||
}
|
||||
return false;
|
||||
});
|
||||
}, [filter, data]);
|
||||
}, [filter, data?.tasks]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
|
|
@ -81,15 +101,22 @@ export const Tasks = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.taskId}
|
||||
task={task}
|
||||
onFilter={userRole === 'member' ? undefined : onFilter}
|
||||
onTaskSelected={onTaskSelected}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.taskId}
|
||||
task={task}
|
||||
onFilter={userRole === 'member' ? undefined : onFilter}
|
||||
onTaskSelected={onTaskSelected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cursor Pagination Controls */}
|
||||
<div className="flex justify-center mt-6">
|
||||
<CursorPaginationControls pagination={pagination} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -53,10 +53,9 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
// Verify user has access to the task
|
||||
const tasks = await getTasks({ orgId, userId });
|
||||
const task = tasks.find((t) => t.taskId === taskId);
|
||||
const tasksResult = await getTasks({ orgId, userId, taskId });
|
||||
|
||||
if (!task) {
|
||||
if (tasksResult.tasks.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Task not found or access denied' },
|
||||
{ status: 404 },
|
||||
|
|
|
|||
56
apps/web/src/components/ui/CursorPaginationControls.tsx
Normal file
56
apps/web/src/components/ui/CursorPaginationControls.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import * as React from 'react';
|
||||
import { Button } from './button';
|
||||
import type { CursorPaginationControls as CursorPaginationHook } from '@/hooks/usePagination';
|
||||
|
||||
interface CursorPaginationControlsProps {
|
||||
pagination: CursorPaginationHook;
|
||||
className?: string;
|
||||
showPageInfo?: boolean;
|
||||
}
|
||||
|
||||
export const CursorPaginationControls: React.FC<
|
||||
CursorPaginationControlsProps
|
||||
> = ({ pagination, className = '', showPageInfo = true }) => {
|
||||
const {
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
currentPageIndex,
|
||||
} = pagination;
|
||||
|
||||
// Don't show pagination if we're on the first page and there's no next page
|
||||
if (currentPageIndex === 0 && !hasNextPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center space-x-2 ${className}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={previousPage}
|
||||
disabled={!hasPreviousPage}
|
||||
className="px-3 py-2"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{showPageInfo && (
|
||||
<span className="text-sm text-muted-foreground px-4">
|
||||
Page {currentPageIndex + 1}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={nextPage}
|
||||
disabled={!hasNextPage}
|
||||
className="px-3 py-2"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
apps/web/src/components/ui/PaginationControls.tsx
Normal file
127
apps/web/src/components/ui/PaginationControls.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import * as React from 'react';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from './pagination';
|
||||
import type { PaginationControls as PaginationHook } from '@/hooks/usePagination';
|
||||
|
||||
interface PaginationControlsProps {
|
||||
pagination: PaginationHook;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PaginationControls: React.FC<PaginationControlsProps> = ({
|
||||
pagination,
|
||||
className,
|
||||
}) => {
|
||||
const {
|
||||
page,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
} = pagination;
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate page numbers to display
|
||||
const getPageNumbers = () => {
|
||||
const delta = 2; // Number of pages to show on each side of current page
|
||||
const range = [];
|
||||
const rangeWithDots = [];
|
||||
|
||||
// Always include first page
|
||||
range.push(1);
|
||||
|
||||
// Add pages around current page
|
||||
for (
|
||||
let i = Math.max(2, page - delta);
|
||||
i <= Math.min(totalPages - 1, page + delta);
|
||||
i++
|
||||
) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
// Always include last page if it's not already included
|
||||
if (totalPages > 1) {
|
||||
range.push(totalPages);
|
||||
}
|
||||
|
||||
// Remove duplicates and sort
|
||||
const uniqueRange = Array.from(new Set(range)).sort((a, b) => a - b);
|
||||
|
||||
// Add ellipsis where there are gaps
|
||||
let lastPage = 0;
|
||||
for (const pageNum of uniqueRange) {
|
||||
if (pageNum - lastPage > 1) {
|
||||
rangeWithDots.push('ellipsis');
|
||||
}
|
||||
rangeWithDots.push(pageNum);
|
||||
lastPage = pageNum;
|
||||
}
|
||||
|
||||
return rangeWithDots;
|
||||
};
|
||||
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
return (
|
||||
<Pagination className={className}>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (hasPreviousPage) {
|
||||
previousPage();
|
||||
}
|
||||
}}
|
||||
className={!hasPreviousPage ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{pageNumbers.map((pageNum, index) => (
|
||||
<PaginationItem key={index}>
|
||||
{pageNum === 'ellipsis' ? (
|
||||
<PaginationEllipsis />
|
||||
) : (
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
goToPage(pageNum as number);
|
||||
}}
|
||||
isActive={pageNum === page}
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (hasNextPage) {
|
||||
nextPage();
|
||||
}
|
||||
}}
|
||||
className={!hasNextPage ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
};
|
||||
|
|
@ -9,6 +9,9 @@ export * from './dropdown-menu';
|
|||
export * from './form';
|
||||
export * from './input';
|
||||
export * from './label';
|
||||
export * from './pagination';
|
||||
export * from './PaginationControls';
|
||||
export * from './CursorPaginationControls';
|
||||
export * from './popover';
|
||||
export * from './separator';
|
||||
export * from './skeleton';
|
||||
|
|
|
|||
114
apps/web/src/components/ui/pagination.tsx
Normal file
114
apps/web/src/components/ui/pagination.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = 'Pagination';
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<'ul'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn('flex flex-row items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PaginationContent.displayName = 'PaginationContent';
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<'li'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn('', className)} {...props} />
|
||||
));
|
||||
PaginationItem.displayName = 'PaginationItem';
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
} & React.ComponentProps<'a'>;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size: _size = 'icon',
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 w-10',
|
||||
isActive && 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = 'PaginationLink';
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn('gap-1 pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = 'PaginationPrevious';
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn('gap-1 pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = 'PaginationNext';
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
};
|
||||
167
apps/web/src/hooks/usePagination.ts
Normal file
167
apps/web/src/hooks/usePagination.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
|
||||
export interface CursorPaginationControls {
|
||||
pageSize: number;
|
||||
cursors: number[];
|
||||
currentPageIndex: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
currentCursor: number | undefined;
|
||||
nextPage: () => void;
|
||||
previousPage: () => void;
|
||||
reset: () => void;
|
||||
setNextCursor: (cursor: number | undefined) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
}
|
||||
|
||||
export const useCursorPagination = (
|
||||
initialPageSize: number = 20,
|
||||
): CursorPaginationControls => {
|
||||
const [pageSize, setPageSize] = useState(initialPageSize);
|
||||
const [cursors, setCursors] = useState<number[]>([]);
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
|
||||
const currentCursor = cursors[currentPageIndex];
|
||||
const hasNextPage = currentPageIndex < cursors.length - 1;
|
||||
const hasPreviousPage = currentPageIndex > 0;
|
||||
|
||||
const nextPage = useCallback(() => {
|
||||
if (hasNextPage) {
|
||||
setCurrentPageIndex((prev) => prev + 1);
|
||||
}
|
||||
}, [hasNextPage]);
|
||||
|
||||
const previousPage = useCallback(() => {
|
||||
if (hasPreviousPage) {
|
||||
setCurrentPageIndex((prev) => prev - 1);
|
||||
}
|
||||
}, [hasPreviousPage]);
|
||||
|
||||
const setNextCursor = useCallback(
|
||||
(cursor: number | undefined) => {
|
||||
if (cursor !== undefined) {
|
||||
setCursors((prev) => {
|
||||
// Only add the cursor if it's not already the next one
|
||||
const nextIndex = currentPageIndex + 1;
|
||||
if (prev[nextIndex] !== cursor) {
|
||||
const newCursors = [...prev];
|
||||
newCursors[nextIndex] = cursor;
|
||||
// Remove any cursors beyond this point (in case data changed)
|
||||
return newCursors.slice(0, nextIndex + 1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentPageIndex],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCursors([]);
|
||||
setCurrentPageIndex(0);
|
||||
}, []);
|
||||
|
||||
const handleSetPageSize = useCallback(
|
||||
(size: number) => {
|
||||
setPageSize(size);
|
||||
reset(); // Reset pagination when page size changes
|
||||
},
|
||||
[reset],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
pageSize,
|
||||
cursors,
|
||||
currentPageIndex,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
currentCursor,
|
||||
nextPage,
|
||||
previousPage,
|
||||
reset,
|
||||
setNextCursor,
|
||||
setPageSize: handleSetPageSize,
|
||||
}),
|
||||
[
|
||||
pageSize,
|
||||
cursors,
|
||||
currentPageIndex,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
currentCursor,
|
||||
nextPage,
|
||||
previousPage,
|
||||
reset,
|
||||
setNextCursor,
|
||||
handleSetPageSize,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
// Keep the original hook for backward compatibility
|
||||
export interface PaginationConfig {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PaginationControls {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
offset: number;
|
||||
goToPage: (page: number) => void;
|
||||
nextPage: () => void;
|
||||
previousPage: () => void;
|
||||
setPageSize: (size: number) => void;
|
||||
}
|
||||
|
||||
export const usePagination = (
|
||||
initialPageSize: number = 20,
|
||||
total: number = 0,
|
||||
): PaginationControls => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(initialPageSize);
|
||||
|
||||
const pagination = useMemo(() => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const hasNextPage = page < totalPages;
|
||||
const hasPreviousPage = page > 1;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
offset,
|
||||
goToPage: (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
setPage(newPage);
|
||||
}
|
||||
},
|
||||
nextPage: () => {
|
||||
if (hasNextPage) {
|
||||
setPage(page + 1);
|
||||
}
|
||||
},
|
||||
previousPage: () => {
|
||||
if (hasPreviousPage) {
|
||||
setPage(page - 1);
|
||||
}
|
||||
},
|
||||
setPageSize: (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1); // Reset to first page when changing page size
|
||||
},
|
||||
};
|
||||
}, [page, pageSize, total]);
|
||||
|
||||
return pagination;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue