diff --git a/dashboard/src/api/control-layer/client.ts b/dashboard/src/api/control-layer/client.ts index 3b4e96689..8da33b2fc 100644 --- a/dashboard/src/api/control-layer/client.ts +++ b/dashboard/src/api/control-layer/client.ts @@ -796,7 +796,9 @@ const paymentsApi = { if (!response.ok) { const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to create payment: ${response.status}`); + throw new Error( + errorData.message || `Failed to create payment: ${response.status}`, + ); } return response.json(); @@ -1046,13 +1048,13 @@ const filesApi = { // Get file content as JSONL (supports limit/offset query params) // Returns content, whether there are more results, and the last line number - async getContent( + async getFileContent( id: string, - options?: { limit?: number; offset?: number }, + options?: { limit?: number; skip?: number }, ): Promise<{ content: string; incomplete: boolean; lastLine: number }> { const params = new URLSearchParams(); if (options?.limit) params.set("limit", options.limit.toString()); - if (options?.offset) params.set("offset", options.offset.toString()); + if (options?.skip) params.set("skip", options.skip.toString()); const url = `/ai/v1/files/${id}/content${params.toString() ? "?" + params.toString() : ""}`; const response = await fetch(url); diff --git a/dashboard/src/api/control-layer/hooks.ts b/dashboard/src/api/control-layer/hooks.ts index 0dbfe69d8..2d4d62c2f 100644 --- a/dashboard/src/api/control-layer/hooks.ts +++ b/dashboard/src/api/control-layer/hooks.ts @@ -694,14 +694,28 @@ export function useFile(id: string) { }); } -// Deprecated: Use dwctlApi.files.getContent() with useQuery directly instead -// export function useFileRequests(id: string, options?: FileRequestsListQuery) { -// return useQuery({ -// queryKey: queryKeys.files.requestsList(id, options || {}), -// queryFn: () => dwctlApi.files.getContent(id, {limit: options?.limit, offset: options?.skip}), -// enabled: !!id, -// }); -// } +export function useFileContent( + id: string, + options?: { limit?: number; skip?: number }, +) { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: queryKeys.files.requestsList(id, options || {}), + queryFn: () => dwctlApi.files.getFileContent(id, options), + enabled: !!id, + // Prefetch next page + select: (data) => { + if (data.incomplete && options?.limit && options?.skip) { + queryClient.prefetchQuery({ + queryKey: queryKeys.files.requestsList(id, options), + queryFn: () => dwctlApi.files.getFileContent(id, options), + }); + } + return data; + }, + }); +} export function useUploadFile() { const queryClient = useQueryClient(); @@ -928,7 +942,7 @@ export function useProcessPayment(options?: { return useMutation({ mutationKey: ["payments", "process"], mutationFn: async (sessionId: string) => { - await dwctlApi.payments.process(sessionId) + await dwctlApi.payments.process(sessionId); }, onSuccess: () => { // Refetch user data to update balance after successful payment diff --git a/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx b/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx index 56147ffaf..bc4b16f3f 100644 --- a/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx +++ b/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx @@ -185,18 +185,24 @@ describe("Batches - Pagination", () => { ).toBeInTheDocument(); }); - // Verify we're on page 1 - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + // Verify we're on page 1 - look for the active pagination link + const activePage = within(container).getByRole("link", { + current: "page", + }); + expect(activePage).toHaveTextContent("1"); - // Click Next button - const nextButton = within(container).getByRole("button", { - name: /Next/i, + // Click Next button - uses aria-label "Go to next page" + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); // Verify page 2 is shown await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); // Verify the correct cursor was used (last item from page 1) @@ -280,28 +286,34 @@ describe("Batches - Pagination", () => { }); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); // Now click Previous - should go back to page 1 using cursor history - const prevButton = within(container).getByRole("button", { - name: /Previous/i, + const prevButton = within(container).getByRole("link", { + name: /go to previous page/i, }); await user.click(prevButton); // Should be back on page 1 await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage1 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage1).toHaveTextContent("1"); }); - // The previous button should now be disabled (we're on page 0) - expect(prevButton).toBeDisabled(); + // The previous button should have pointer-events-none class (disabled) + expect(prevButton).toHaveClass("pointer-events-none"); }); it("should show First button only when on page 2 or higher", async () => { @@ -385,37 +397,43 @@ describe("Batches - Pagination", () => { // Page 1: No First button expect( - within(container).queryByRole("button", { name: /First/i }), + within(container).queryByRole("link", { name: /First/i }), ).not.toBeInTheDocument(); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); - // Page 2: Still no First button (only shows on page 3+) + // Page 2: First button should now appear (currentPage > 1) expect( - within(container).queryByRole("button", { name: /First/i }), - ).not.toBeInTheDocument(); + within(container).getByRole("link", { name: /First/i }), + ).toBeInTheDocument(); // Navigate to page 3 - const nextButton2 = within(container).getByRole("button", { - name: /Next/i, + const nextButton2 = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton2); await waitFor(() => { - expect(within(container).getByText(/Page 3/i)).toBeInTheDocument(); + const activePage3 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage3).toHaveTextContent("3"); }); - // Page 3: First button should appear + // Page 3: First button should still appear expect( - within(container).getByRole("button", { name: /First/i }), + within(container).getByRole("link", { name: /First/i }), ).toBeInTheDocument(); }); @@ -512,36 +530,45 @@ describe("Batches - Pagination", () => { }); // Navigate to page 2, then page 3 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); - const nextButton2 = within(container).getByRole("button", { - name: /Next/i, + const nextButton2 = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton2); await waitFor(() => { - expect(within(container).getByText(/Page 3/i)).toBeInTheDocument(); + const activePage3 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage3).toHaveTextContent("3"); }); // Click First button - const firstButton = within(container).getByRole("button", { + const firstButton = within(container).getByRole("link", { name: /First/i, }); await user.click(firstButton); // Should be back on page 1 await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage1 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage1).toHaveTextContent("1"); }); // First button should no longer be visible expect( - within(container).queryByRole("button", { name: /First/i }), + within(container).queryByRole("link", { name: /First/i }), ).not.toBeInTheDocument(); }); @@ -616,13 +643,16 @@ describe("Batches - Pagination", () => { }); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); // Change page size by clicking the combobox trigger @@ -643,13 +673,17 @@ describe("Batches - Pagination", () => { // Should reset to page 1 after changing page size await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage1 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage1).toHaveTextContent("1"); }); - // Previous button should be disabled (back at page 1) - expect( - within(container).getByRole("button", { name: /Previous/i }), - ).toBeDisabled(); + // Previous button should have pointer-events-none class (disabled) + const prevButton = within(container).getByRole("link", { + name: /go to previous page/i, + }); + expect(prevButton).toHaveClass("pointer-events-none"); }); }); @@ -714,17 +748,23 @@ describe("Batches - Pagination", () => { ); await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage = within(container).getByRole("link", { + current: "page", + }); + expect(activePage).toHaveTextContent("1"); }); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); }); @@ -788,31 +828,40 @@ describe("Batches - Pagination", () => { ); await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage = within(container).getByRole("link", { + current: "page", + }); + expect(activePage).toHaveTextContent("1"); }); // Navigate to page 2 - const nextButton = within(container).getByRole("button", { - name: /Next/i, + const nextButton = within(container).getByRole("link", { + name: /go to next page/i, }); await user.click(nextButton); await waitFor(() => { - expect(within(container).getByText(/Page 2/i)).toBeInTheDocument(); + const activePage2 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage2).toHaveTextContent("2"); }); // Navigate back to page 1 - const prevButton = within(container).getByRole("button", { - name: /Previous/i, + const prevButton = within(container).getByRole("link", { + name: /go to previous page/i, }); await user.click(prevButton); await waitFor(() => { - expect(within(container).getByText(/Page 1/i)).toBeInTheDocument(); + const activePage1 = within(container).getByRole("link", { + current: "page", + }); + expect(activePage1).toHaveTextContent("1"); }); - // Previous button should be disabled on page 1 - expect(prevButton).toBeDisabled(); + // Previous button should have pointer-events-none class (disabled) on page 1 + expect(prevButton).toHaveClass("pointer-events-none"); }); }); }); diff --git a/dashboard/src/components/features/batches/Batches/Batches.tsx b/dashboard/src/components/features/batches/Batches/Batches.tsx index 34be44007..d665e957f 100644 --- a/dashboard/src/components/features/batches/Batches/Batches.tsx +++ b/dashboard/src/components/features/batches/Batches/Batches.tsx @@ -20,6 +20,7 @@ import { SelectValue, } from "../../../ui/select"; import { DataTable } from "../../../ui/data-table"; +import { CursorPagination } from "../../../ui/cursor-pagination"; import { createFileColumns } from "../FilesTable/columns"; import { createBatchColumns } from "../BatchesTable/columns"; import { useFiles, useBatches } from "../../../../api/control-layer/hooks"; @@ -597,48 +598,21 @@ export function Batches({ } /> - {/* Server-side pagination controls */} -
-
- Showing {filesPage * filesPageSize + 1} -{" "} - {filesPage * filesPageSize + files.length} - {filesHasMore && " of many"} -
-
- {filesPage > 1 && ( - - )} - - - Page {filesPage + 1} - - -
-
+ { + setFilesAfterCursor(undefined); + filesCursorHistory.current = []; + updateFilesPagination(0, filesPageSize); + }} + hasNextPage={filesHasMore} + hasPrevPage={filesPage > 0} + currentPageItemCount={files.length} + itemName="files" + /> )} @@ -726,48 +700,21 @@ export function Batches({ } /> - {/* Server-side pagination controls */} -
-
- Showing {batchesPage * batchesPageSize + 1} -{" "} - {batchesPage * batchesPageSize + filteredBatches.length} - {batchesHasMore && " of many"} -
-
- {batchesPage > 1 && ( - - )} - - - Page {batchesPage + 1} - - -
-
+ { + setBatchesAfterCursor(undefined); + batchesCursorHistory.current = []; + updateBatchesPagination(0, batchesPageSize); + }} + hasNextPage={batchesHasMore} + hasPrevPage={batchesPage > 0} + currentPageItemCount={filteredBatches.length} + itemName="batches" + /> )} diff --git a/dashboard/src/components/features/batches/FileRequests/FileRequests.tsx b/dashboard/src/components/features/batches/FileRequests/FileRequests.tsx index 2fb6388a9..4055cdfd7 100644 --- a/dashboard/src/components/features/batches/FileRequests/FileRequests.tsx +++ b/dashboard/src/components/features/batches/FileRequests/FileRequests.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import { ArrowLeft, @@ -6,11 +6,10 @@ import { FileCheck, AlertCircle, Download, - Loader2, } from "lucide-react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "../../../ui/button"; import { DataTable } from "../../../ui/data-table"; +import { CursorPagination } from "../../../ui/cursor-pagination"; import { Select, SelectContent, @@ -18,8 +17,11 @@ import { SelectTrigger, SelectValue, } from "../../../ui/select"; -import { dwctlApi } from "../../../../api/control-layer/client"; -import { useFile, useBatches } from "../../../../api/control-layer/hooks"; +import { + useFile, + useBatches, + useFileContent, +} from "../../../../api/control-layer/hooks"; import { createFileRequestsColumns, type FileRequestOrResponse, @@ -38,7 +40,6 @@ import type { FileRequest } from "../../../../api/control-layer/types"; export function FileRequests() { const { fileId } = useParams<{ fileId: string }>(); const navigate = useNavigate(); - const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const [downloadModalOpen, setDownloadModalOpen] = useState(false); @@ -50,15 +51,20 @@ export function FileRequests() { useState(null); const [requestBodyModalOpen, setRequestBodyModalOpen] = useState(false); - // Get pagination from URL or use defaults - const page = parseInt(searchParams.get("page") || "0", 10); - const pageSize = parseInt(searchParams.get("pageSize") || "10", 10); + // Pagination state (1-based page number) + const currentPage = Number(searchParams.get("page")) || 1; + const pageSize = Number(searchParams.get("pageSize")) || 10; + + // Calculate 0-based offset for API + const offset = (currentPage - 1) * pageSize; - // Update URL when pagination changes - const updatePagination = (newPage: number, newPageSize: number) => { + // Update pagination in URL + const updatePagination = (page: number, size: number) => { const params = new URLSearchParams(searchParams); - params.set("page", newPage.toString()); - params.set("pageSize", newPageSize.toString()); + params.set("page", String(page)); + params.set("pageSize", String(size)); + // Preserve returnTab + if (returnTab) params.set("returnTab", returnTab); setSearchParams(params, { replace: true }); }; @@ -79,31 +85,12 @@ export function FileRequests() { ["validating", "in_progress", "finalizing"].includes(b.status), ); - // Fetch file content with pagination - const { data, isLoading } = useQuery({ - queryKey: ["file-content", fileId, page, pageSize], - queryFn: () => - dwctlApi.files.getContent(fileId || "", { - limit: pageSize, - offset: page * pageSize, - }), - enabled: !!fileId, + // Fetch file content with pagination using custom hook + const { data, isLoading } = useFileContent(fileId || "", { + limit: pageSize, + skip: offset, }); - // Prefetch next page - useEffect(() => { - if (fileId && data?.incomplete) { - queryClient.prefetchQuery({ - queryKey: ["file-content", fileId, page + 1, pageSize], - queryFn: () => - dwctlApi.files.getContent(fileId, { - limit: pageSize, - offset: (page + 1) * pageSize, - }), - }); - } - }, [fileId, page, pageSize, data?.incomplete, queryClient]); - // Parse JSONL into requests (could be templates or responses) const requests: FileRequestOrResponse[] = data?.content ? data.content @@ -136,7 +123,7 @@ export function FileRequests() {
)} @@ -223,10 +200,10 @@ export function FileRequests() {