Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 163 additions & 3 deletions src/components/SimpleBrowseDataTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
* @vitest-environment jsdom
*/
import { ColumnDef } from '@/lib/table';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SimpleBrowseDataTable } from './SimpleBrowseDataTable';

afterEach(() => cleanup());
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

interface Pet {
name: string;
Expand All @@ -31,3 +34,160 @@ describe('SimpleBrowseDataTable', () => {
expect(renderedNames()).toEqual(['alpha', 'zeta']);
});
});

interface Animal {
name: string;
kind: string;
tag: string;
}

const animalColumns: ColumnDef<Animal>[] = [
{ header: 'Name', accessorKey: 'name', enableSorting: true },
{ header: 'Kind', accessorKey: 'kind' },
{ header: 'Tag', accessorKey: 'tag', enableGlobalFilter: false },
];

/** `count` animals named `<prefix>-00`, `<prefix>-01`, ... so the rendered order is checkable. */
function animals(count: number, prefix = 'pet', kind = 'dog'): Animal[] {
return Array.from({ length: count }, (_, index) => ({
name: `${prefix}-${String(index).padStart(2, '0')}`,
kind,
tag: 'cat',
}));
}

function renderedAnimalNames() {
return Array.from(document.querySelectorAll('tbody tr')).map((row) => row.querySelector('td')?.textContent);
}

function filterBox() {
return screen.getByRole('searchbox', { name: 'Filter animals' });
}

/**
* TanStack defers its automatic page-index reset to a microtask, so a change is awaited inside `act`
* before asserting: a reset that should not happen must have had the chance to.
*/
async function settled(change: () => void) {
await act(async () => change());
}

function typeFilter(text: string) {
return settled(() => fireEvent.change(filterBox(), { target: { value: text } }));
}

function nextPage() {
fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
}

describe('SimpleBrowseDataTable opt-in filtering and pagination', () => {
it('renders every row it is given, with no search box or pager, when the table does not opt in', () => {
// The client row models are registered on every SimpleBrowseDataTable; only the opt-in props
// may switch them on. Without `manualPagination` here this table would quietly stop at one page.
render(<SimpleBrowseDataTable columns={animalColumns} data={animals(45)} />);

expect(renderedAnimalNames()).toHaveLength(45);
expect(screen.queryByRole('searchbox')).toBeNull();
expect(screen.queryByRole('button', { name: 'Next page' })).toBeNull();
});

it('filters rows case-insensitively, ignoring a column that opts out of the filter', async () => {
const rows: Animal[] = [
{ name: 'Whiskers', kind: 'cat', tag: 'a' },
{ name: 'Rex', kind: 'dog', tag: 'cat' },
{ name: 'Catalina', kind: 'parrot', tag: 'b' },
];
render(<SimpleBrowseDataTable columns={animalColumns} data={rows} filter={{ label: 'Filter animals' }} />);

await typeFilter('CAT');

// Rex's only "cat" is in the opted-out Tag column.
expect(renderedAnimalNames()).toEqual(['Whiskers', 'Catalina']);

await typeFilter('nothing like this');
expect(screen.getByText('No results.')).toBeTruthy();

await typeFilter('');
expect(renderedAnimalNames()).toEqual(['Whiskers', 'Rex', 'Catalina']);
});

it('pages rows 20 at a time', () => {
render(<SimpleBrowseDataTable columns={animalColumns} data={animals(25)} paginated />);

expect(renderedAnimalNames()).toEqual(animals(20).map((animal) => animal.name));
expect(screen.getByText('25 records')).toBeTruthy();

nextPage();

expect(renderedAnimalNames()).toEqual(animals(25).slice(20).map((animal) => animal.name));
});

it('leaves the pager out while every row fits on the smallest page', () => {
render(<SimpleBrowseDataTable columns={animalColumns} data={animals(20)} paginated />);

expect(renderedAnimalNames()).toHaveLength(20);
expect(screen.queryByRole('button', { name: 'Next page' })).toBeNull();
});

it('sorts the whole list before cutting it into pages', () => {
render(<SimpleBrowseDataTable columns={animalColumns} data={animals(25)} paginated />);

fireEvent.click(screen.getByRole('button', { name: 'Name' }));
fireEvent.click(screen.getByRole('button', { name: 'Name' }));

expect(renderedAnimalNames().slice(0, 2)).toEqual(['pet-24', 'pet-23']);
});

it('returns to the first page when the rows are re-sorted', () => {
render(<SimpleBrowseDataTable columns={animalColumns} data={animals(25)} paginated />);
nextPage();

fireEvent.click(screen.getByRole('button', { name: 'Name' }));

expect(renderedAnimalNames()).toEqual(animals(20).map((animal) => animal.name));
});

it('returns to the first page when the filter changes, and pages and counts only the matching rows', async () => {
const consoleError = vi.spyOn(console, 'error');
const rows = [...animals(25, 'dog', 'dog'), ...animals(22, 'cat', 'cat')];
render(
<SimpleBrowseDataTable columns={animalColumns} data={rows} filter={{ label: 'Filter animals' }} paginated />,
);
nextPage();
nextPage();
expect(renderedAnimalNames()).toEqual(['cat-15', 'cat-16', 'cat-17', 'cat-18', 'cat-19', 'cat-20', 'cat-21']);

await typeFilter('cat');

expect(renderedAnimalNames()).toEqual(animals(20, 'cat').map((animal) => animal.name));
expect(screen.getByText('22 records')).toBeTruthy();

await typeFilter('cat-0');

expect(renderedAnimalNames()).toHaveLength(10);
expect(screen.queryByRole('button', { name: 'Next page' })).toBeNull();
expect(consoleError).not.toHaveBeenCalled();
});

it('stays on the current page when the data is refreshed', async () => {
const { rerender } = render(<SimpleBrowseDataTable columns={animalColumns} data={animals(45)} paginated />);
nextPage();

// A poll that brings back a changed list hands the table a new array.
await settled(() => rerender(<SimpleBrowseDataTable columns={animalColumns} data={animals(45)} paginated />));

expect(renderedAnimalNames()).toEqual(animals(40).slice(20).map((animal) => animal.name));
});

it('moves to the new last page when the data shrinks out from under the current one', async () => {
const { rerender } = render(<SimpleBrowseDataTable columns={animalColumns} data={animals(45)} paginated />);
nextPage();
nextPage();
expect(renderedAnimalNames()).toHaveLength(5);

await settled(() => rerender(<SimpleBrowseDataTable columns={animalColumns} data={animals(30)} paginated />));

expect(renderedAnimalNames()).toEqual(animals(30).slice(20).map((animal) => animal.name));
expect(screen.getByText('30 records')).toBeTruthy();
});
});
83 changes: 74 additions & 9 deletions src/components/SimpleBrowseDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@

import { Loading } from '@/components/Loading';

import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHeader, TableHeadSortable, TableRow } from '@/components/ui/table';
import { PAGE_SIZE_OPTIONS, TablePagination } from '@/features/instance/databases/components/TablePagination';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Importing TablePagination from a feature-specific directory (@/features/instance/databases/components/) into a shared component (@/components/SimpleBrowseDataTable.tsx) violates the clean architecture principle of unidirectional dependencies. Shared components should be self-contained and not depend on feature-specific code.

Consider moving TablePagination.tsx to src/components/ and updating the import.

Suggested change
import { PAGE_SIZE_OPTIONS, TablePagination } from '@/features/instance/databases/components/TablePagination';
import { PAGE_SIZE_OPTIONS, TablePagination } from '@/components/TablePagination';

import { cn } from '@/lib/cn';
import { ColumnDef, Row, studioTableFeatures } from '@/lib/table';
import { flexRender, RowData, SortingState, useTable } from '@tanstack/react-table';
import React from 'react';
import { ColumnDef, Row, studioClientTableFeatures } from '@/lib/table';
import { flexRender, PaginationState, RowData, SortingState, useTable } from '@tanstack/react-table';
import { SearchIcon } from 'lucide-react';
import React, { useLayoutEffect, useState } from 'react';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since useLayoutEffect is being removed to avoid SSR warnings, we can remove it from the React imports as well.

Suggested change
import React, { useLayoutEffect, useState } from 'react';
import React, { useState } from 'react';


const SMALLEST_PAGE_SIZE = PAGE_SIZE_OPTIONS[0];

interface BrowseDataTableProps<TData extends RowData> {
columns: ColumnDef<TData>[];
Expand All @@ -15,6 +20,14 @@ interface BrowseDataTableProps<TData extends RowData> {
onRowClick?: (row: Row<TData>) => void;
onColumnClick?: (accessorKey: string, isDescending: boolean) => void;
sortingState?: SortingState;
/**
* Adds a search box that filters the rows on the client. It matches every column whose values are
* strings or numbers, except one that sets `enableGlobalFilter: false`. `label` is the box's
* accessible name, and its placeholder unless `placeholder` is given.
*/
filter?: { label: string; placeholder?: string };
/** Pages the rows on the client. The pager appears once the rows outnumber the smallest page. */
paginated?: boolean;
children?: React.ReactNode;
}

Expand All @@ -25,23 +38,65 @@ export function SimpleBrowseDataTable<TData extends RowData>({
onRowClick,
onColumnClick,
sortingState,
filter,
paginated,
children,
}: BrowseDataTableProps<TData>) {
// `?? []` matters: `toggleSorting` reads the previous sorting state as an array, so an undefined one
// makes the first header click throw.
const [sorting, setSorting] = useState<SortingState>(() => sortingState ?? []);
const [globalFilter, setGlobalFilter] = useState('');
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: SMALLEST_PAGE_SIZE });
const toFirstPage = () => setPagination((current) => ({ ...current, pageIndex: 0 }));
const table = useTable({
features: studioTableFeatures,
features: studioClientTableFeatures,
data,
columns,
initialState: {
// `?? []` matters: TanStack builds the initial state as `{ sorting: [], ...initialState }`,
// so an explicit `sorting: undefined` key replaces the default and the first header click
// throws in `toggleSorting`.
sorting: sortingState ?? [],
manualFiltering: !filter,
manualPagination: !paginated,
// A re-sort or a new filter starts from the first page; new `data` does not. TanStack's own reset
// also fires on every new `data` array, and these lists are polled, so any changed record (an org
// member's `lastAccessedAt`, say) would throw the user back to page 1.
autoResetPageIndex: false,
state: { sorting, globalFilter, pagination },
onSortingChange: (updater) => {
setSorting(updater);
toFirstPage();
},
onGlobalFilterChange: (updater) => {
setGlobalFilter(updater);
toFirstPage();
},
onPaginationChange: setPagination,
});
const matchingRowCount = table.getPrePaginatedRowModel().rows.length;
const showPager = paginated && matchingRowCount > SMALLEST_PAGE_SIZE;
const lastPageIndex = Math.max(table.getPageCount() - 1, 0);
useLayoutEffect(() => {
if (pagination.pageIndex > lastPageIndex) {
setPagination((current) => ({ ...current, pageIndex: lastPageIndex }));
}
}, [pagination.pageIndex, lastPageIndex]);
Comment on lines +75 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using useLayoutEffect in a client component ('use client') will trigger SSR warnings during pre-rendering in frameworks like Next.js (e.g., "useLayoutEffect does nothing on the server...").

Instead of an effect, we can safely adjust the state during the render phase. When React detects a state update during render, it immediately aborts the current render and restarts it with the updated state, avoiding both SSR warnings and layout flashes.

	if (pagination.pageIndex > lastPageIndex) {
		setPagination((current) => ({ ...current, pageIndex: lastPageIndex }));
	}


return (
<>
<div className="flex items-center justify-end space-x-2 pb-4">
{filter && (
<div className="relative mr-auto w-full max-w-xs">
<SearchIcon
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
aria-label={filter.label}
placeholder={filter.placeholder ?? filter.label}
className="pl-9"
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
/>
</div>
)}
<div className="grow lg:hidden"></div>
{children}
<div className="grow hidden lg:visible"></div>
Expand Down Expand Up @@ -91,6 +146,16 @@ export function SimpleBrowseDataTable<TData extends RowData>({
)}
</TableBody>
</Table>
{showPager && (
<TablePagination
pageIndex={pagination.pageIndex}
pageSize={pagination.pageSize}
totalPages={table.getPageCount()}
totalRecords={matchingRowCount}
setPageIndex={table.setPageIndex}
setPageSize={table.setPageSize}
/>
)}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { cn } from '@/lib/cn';
import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon } from 'lucide-react';
import { ComponentProps, Dispatch, FormEvent, SetStateAction, useState } from 'react';

const PAGE_SIZE_OPTIONS = [20, 50, 100, 250];
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 250] as const;

interface TablePaginationProps {
pageIndex: number;
Expand Down
Loading
Loading