feat(ui): add infinite scroll pagination to chat completions/responses logs table (#2466)
Some checks failed
Integration Auth Tests / test-matrix (oauth2_token) (push) Failing after 2s
Integration Tests / test-matrix (http, 3.10, inspect) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, providers) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, datasets) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, post_training) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.11, datasets) (push) Failing after 4s
Integration Tests / test-matrix (http, 3.10, tool_runtime) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.11, agents) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.11, inference) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, vector_io) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, inspect) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.11, post_training) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.11, tool_runtime) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.10, agents) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, inspect) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.11, scoring) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.12, datasets) (push) Failing after 9s
Integration Tests / test-matrix (http, 3.12, inference) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.10, scoring) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.10, inference) (push) Failing after 11s
Integration Tests / test-matrix (http, 3.12, post_training) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.12, tool_runtime) (push) Failing after 9s
Integration Tests / test-matrix (http, 3.12, agents) (push) Failing after 11s
Integration Tests / test-matrix (http, 3.12, scoring) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, providers) (push) Failing after 9s
Integration Tests / test-matrix (library, 3.10, agents) (push) Failing after 9s
Integration Tests / test-matrix (library, 3.10, datasets) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, inference) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.10, providers) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, post_training) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, datasets) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.10, scoring) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, providers) (push) Failing after 11s
Integration Tests / test-matrix (library, 3.10, inspect) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, vector_io) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.10, tool_runtime) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, agents) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, post_training) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, inspect) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.11, inference) (push) Failing after 7s
Integration Tests / test-matrix (library, 3.11, tool_runtime) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, providers) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, scoring) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.11, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (library, 3.12, datasets) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, agents) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, inference) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, inspect) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, post_training) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, providers) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, scoring) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, tool_runtime) (push) Failing after 5s
Test External Providers / test-external-providers (venv) (push) Failing after 16s
Integration Tests / test-matrix (library, 3.12, vector_io) (push) Failing after 20s
Unit Tests / unit-tests (3.11) (push) Failing after 16s
Unit Tests / unit-tests (3.13) (push) Failing after 14s
Unit Tests / unit-tests (3.10) (push) Failing after 48s
Unit Tests / unit-tests (3.12) (push) Failing after 46s
Pre-commit / pre-commit (push) Successful in 1m23s

## Summary:

This commit adds infinite scroll pagination to the chat completions and
responses tables.


## Test Plan:
  1. Run unit tests: npm run test
  2. Manual testing: Navigate to chat
  completions/responses pages
  3. Verify infinite scroll triggers when approaching
  bottom
  4. Added playwright tests: npm run test:e2e
This commit is contained in:
ehhuang 2025-06-18 15:28:39 -07:00 committed by GitHub
parent 90d03552d4
commit e6bfc717cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1145 additions and 388 deletions

View file

@ -0,0 +1,142 @@
import React from "react";
import { render, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { LogsTable, LogTableRow } from "./logs-table";
import { PaginationStatus } from "@/lib/types";
// Mock next/navigation
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: jest.fn(),
}),
}));
// Mock the useInfiniteScroll hook
jest.mock("@/hooks/useInfiniteScroll", () => ({
useInfiniteScroll: jest.fn((onLoadMore, options) => {
const ref = React.useRef(null);
React.useEffect(() => {
// Simulate the observer behavior
if (options?.enabled && onLoadMore) {
// Trigger load after a delay to simulate intersection
const timeout = setTimeout(() => {
onLoadMore();
}, 100);
return () => clearTimeout(timeout);
}
}, [options?.enabled, onLoadMore]);
return ref;
}),
}));
// IntersectionObserver mock is already in jest.setup.ts
describe("LogsTable Viewport Loading", () => {
const mockData: LogTableRow[] = Array.from({ length: 10 }, (_, i) => ({
id: `row_${i}`,
input: `Input ${i}`,
output: `Output ${i}`,
model: "test-model",
createdTime: new Date().toISOString(),
detailPath: `/logs/test/${i}`,
}));
const defaultProps = {
data: mockData,
status: "idle" as PaginationStatus,
hasMore: true,
error: null,
caption: "Test table",
emptyMessage: "No data",
};
beforeEach(() => {
jest.clearAllMocks();
});
test("should trigger loadMore when sentinel is visible", async () => {
const mockLoadMore = jest.fn();
render(<LogsTable {...defaultProps} onLoadMore={mockLoadMore} />);
// Wait for the intersection observer to trigger
await waitFor(
() => {
expect(mockLoadMore).toHaveBeenCalled();
},
{ timeout: 300 },
);
expect(mockLoadMore).toHaveBeenCalledTimes(1);
});
test("should not trigger loadMore when already loading", async () => {
const mockLoadMore = jest.fn();
render(
<LogsTable
{...defaultProps}
status="loading-more"
onLoadMore={mockLoadMore}
/>,
);
// Wait for possible triggers
await new Promise((resolve) => setTimeout(resolve, 300));
expect(mockLoadMore).not.toHaveBeenCalled();
});
test("should not trigger loadMore when status is loading", async () => {
const mockLoadMore = jest.fn();
render(
<LogsTable
{...defaultProps}
status="loading"
onLoadMore={mockLoadMore}
/>,
);
// Wait for possible triggers
await new Promise((resolve) => setTimeout(resolve, 300));
expect(mockLoadMore).not.toHaveBeenCalled();
});
test("should not trigger loadMore when hasMore is false", async () => {
const mockLoadMore = jest.fn();
render(
<LogsTable {...defaultProps} hasMore={false} onLoadMore={mockLoadMore} />,
);
// Wait for possible triggers
await new Promise((resolve) => setTimeout(resolve, 300));
expect(mockLoadMore).not.toHaveBeenCalled();
});
test("sentinel element should not be rendered when loading", () => {
const { container } = render(
<LogsTable {...defaultProps} status="loading-more" />,
);
// Check that no sentinel row with height: 1 exists
const sentinelRow = container.querySelector('tr[style*="height: 1"]');
expect(sentinelRow).not.toBeInTheDocument();
});
test("sentinel element should be rendered when not loading and hasMore", () => {
const { container } = render(
<LogsTable {...defaultProps} hasMore={true} status="idle" />,
);
// Check that sentinel row exists
const sentinelRow = container.querySelector('tr[style*="height: 1"]');
expect(sentinelRow).toBeInTheDocument();
});
});

View file

@ -2,6 +2,7 @@ import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { LogsTable, LogTableRow } from "./logs-table";
import { PaginationStatus } from "@/lib/types";
// Mock next/navigation
const mockPush = jest.fn();
@ -23,7 +24,7 @@ const truncateText = originalTruncateText as jest.Mock;
describe("LogsTable", () => {
const defaultProps = {
data: [] as LogTableRow[],
isLoading: false,
status: "idle" as PaginationStatus,
error: null,
caption: "Test table caption",
emptyMessage: "No data found",
@ -69,7 +70,7 @@ describe("LogsTable", () => {
describe("Loading State", () => {
test("renders skeleton UI when isLoading is true", () => {
const { container } = render(
<LogsTable {...defaultProps} isLoading={true} />,
<LogsTable {...defaultProps} status="loading" />,
);
// Check for skeleton in the table caption
@ -101,7 +102,7 @@ describe("LogsTable", () => {
test("renders correct number of skeleton rows", () => {
const { container } = render(
<LogsTable {...defaultProps} isLoading={true} />,
<LogsTable {...defaultProps} status="loading" />,
);
const skeletonRows = container.querySelectorAll("tbody tr");
@ -115,27 +116,45 @@ describe("LogsTable", () => {
render(
<LogsTable
{...defaultProps}
error={{ name: "Error", message: errorMessage }}
status="error"
error={{ name: "Error", message: errorMessage } as Error}
/>,
);
expect(
screen.getByText(`Error fetching data: ${errorMessage}`),
screen.getByText("Unable to load chat completions"),
).toBeInTheDocument();
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
test("renders default error message when error.message is not available", () => {
render(
<LogsTable {...defaultProps} error={{ name: "Error", message: "" }} />,
<LogsTable
{...defaultProps}
status="error"
error={{ name: "Error", message: "" } as Error}
/>,
);
expect(
screen.getByText("Error fetching data: An unknown error occurred"),
screen.getByText("Unable to load chat completions"),
).toBeInTheDocument();
expect(
screen.getByText(
"An unexpected error occurred while loading the data.",
),
).toBeInTheDocument();
});
test("renders default error message when error prop is an object without message", () => {
render(<LogsTable {...defaultProps} error={{} as Error} />);
render(
<LogsTable {...defaultProps} status="error" error={{} as Error} />,
);
expect(
screen.getByText("Error fetching data: An unknown error occurred"),
screen.getByText("Unable to load chat completions"),
).toBeInTheDocument();
expect(
screen.getByText(
"An unexpected error occurred while loading the data.",
),
).toBeInTheDocument();
});
@ -143,7 +162,8 @@ describe("LogsTable", () => {
render(
<LogsTable
{...defaultProps}
error={{ name: "Error", message: "Test error" }}
status="error"
error={{ name: "Error", message: "Test error" } as Error}
/>,
);
const table = screen.queryByRole("table");
@ -337,14 +357,19 @@ describe("LogsTable", () => {
render(<LogsTable {...defaultProps} data={mockData} />);
const table = screen.getByRole("table");
expect(table).toBeInTheDocument();
const tables = screen.getAllByRole("table");
expect(tables).toHaveLength(2); // Fixed header table + body table
const columnHeaders = screen.getAllByRole("columnheader");
expect(columnHeaders).toHaveLength(4);
const rows = screen.getAllByRole("row");
expect(rows).toHaveLength(2); // 1 header row + 1 data row
expect(rows).toHaveLength(3); // 1 header row + 1 data row + 1 "no more items" row
expect(screen.getByText("Input")).toBeInTheDocument();
expect(screen.getByText("Output")).toBeInTheDocument();
expect(screen.getByText("Model")).toBeInTheDocument();
expect(screen.getByText("Created")).toBeInTheDocument();
});
});
});

View file

@ -1,7 +1,10 @@
"use client";
import { useRouter } from "next/navigation";
import { useRef } from "react";
import { truncateText } from "@/lib/truncate-text";
import { PaginationStatus } from "@/lib/types";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import {
Table,
TableBody,
@ -24,65 +27,107 @@ export interface LogTableRow {
}
interface LogsTableProps {
/** Array of log table row data to display */
data: LogTableRow[];
isLoading: boolean;
/** Current loading/error status */
status: PaginationStatus;
/** Whether more data is available to load */
hasMore?: boolean;
/** Error state, null if no error */
error: Error | null;
/** Table caption for accessibility */
caption: string;
/** Message to show when no data is available */
emptyMessage: string;
/** Callback function to load more data */
onLoadMore?: () => void;
}
export function LogsTable({
data,
isLoading,
status,
hasMore = false,
error,
caption,
emptyMessage,
onLoadMore,
}: LogsTableProps) {
const router = useRouter();
const tableContainerRef = useRef<HTMLDivElement>(null);
const tableHeader = (
<TableHeader>
<TableRow>
<TableHead>Input</TableHead>
<TableHead>Output</TableHead>
<TableHead>Model</TableHead>
<TableHead className="text-right">Created</TableHead>
</TableRow>
</TableHeader>
// Use Intersection Observer for infinite scroll
const sentinelRef = useInfiniteScroll(onLoadMore, {
enabled: hasMore && status === "idle",
rootMargin: "100px",
threshold: 0.1,
});
// Fixed header component
const FixedHeader = () => (
<div className="bg-background border-b border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/4">Input</TableHead>
<TableHead className="w-1/4">Output</TableHead>
<TableHead className="w-1/4">Model</TableHead>
<TableHead className="w-1/4 text-right">Created</TableHead>
</TableRow>
</TableHeader>
</Table>
</div>
);
if (isLoading) {
if (status === "loading") {
return (
<Table>
<TableCaption>
<Skeleton className="h-4 w-[250px] mx-auto" />
</TableCaption>
{tableHeader}
<TableBody>
{[...Array(3)].map((_, i) => (
<TableRow key={`skeleton-${i}`}>
<TableCell>
<Skeleton className="h-4 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-3/4" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-1/2 ml-auto" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="h-full flex flex-col">
<FixedHeader />
<div ref={tableContainerRef} className="overflow-auto flex-1 min-h-0">
<Table>
<TableCaption>
<Skeleton className="h-4 w-[250px] mx-auto" />
</TableCaption>
<TableBody>
{[...Array(3)].map((_, i) => (
<TableRow key={`skeleton-${i}`}>
<TableCell className="w-1/4">
<Skeleton className="h-4 w-full" />
</TableCell>
<TableCell className="w-1/4">
<Skeleton className="h-4 w-full" />
</TableCell>
<TableCell className="w-1/4">
<Skeleton className="h-4 w-3/4" />
</TableCell>
<TableCell className="w-1/4 text-right">
<Skeleton className="h-4 w-1/2 ml-auto" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
if (error) {
if (status === "error") {
return (
<p>Error fetching data: {error.message || "An unknown error occurred"}</p>
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="text-destructive font-medium">
Unable to load chat completions
</div>
<div className="text-sm text-muted-foreground text-center max-w-md">
{error?.message ||
"An unexpected error occurred while loading the data."}
</div>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Retry
</button>
</div>
);
}
@ -91,23 +136,60 @@ export function LogsTable({
}
return (
<Table>
<TableCaption>{caption}</TableCaption>
{tableHeader}
<TableBody>
{data.map((row) => (
<TableRow
key={row.id}
onClick={() => router.push(row.detailPath)}
className="cursor-pointer hover:bg-muted/50"
>
<TableCell>{truncateText(row.input)}</TableCell>
<TableCell>{truncateText(row.output)}</TableCell>
<TableCell>{row.model}</TableCell>
<TableCell className="text-right">{row.createdTime}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="h-full flex flex-col">
<FixedHeader />
<div ref={tableContainerRef} className="overflow-auto flex-1 min-h-0">
<Table>
<TableCaption className="sr-only">{caption}</TableCaption>
<TableBody>
{data.map((row) => (
<TableRow
key={row.id}
onClick={() => router.push(row.detailPath)}
className="cursor-pointer hover:bg-muted/50"
>
<TableCell className="w-1/4">
{truncateText(row.input)}
</TableCell>
<TableCell className="w-1/4">
{truncateText(row.output)}
</TableCell>
<TableCell className="w-1/4">{row.model}</TableCell>
<TableCell className="w-1/4 text-right">
{row.createdTime}
</TableCell>
</TableRow>
))}
{/* Sentinel element for infinite scroll */}
{hasMore && status === "idle" && (
<TableRow ref={sentinelRef} style={{ height: 1 }}>
<TableCell colSpan={4} style={{ padding: 0, border: 0 }} />
</TableRow>
)}
{status === "loading-more" && (
<TableRow>
<TableCell colSpan={4} className="text-center py-4">
<div className="flex items-center justify-center space-x-2">
<Skeleton className="h-4 w-4 rounded-full" />
<span className="text-sm text-muted-foreground">
Loading more...
</span>
</div>
</TableCell>
</TableRow>
)}
{!hasMore && data.length > 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-4">
<span className="text-sm text-muted-foreground">
No more items to load
</span>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}