From 28146a1e58d64d711205c1c9e758816bdcbc7756 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 00:04:00 -0400 Subject: [PATCH] feat(organization): sort, filter and page the org users table SimpleBrowseDataTable gains two opt-in props: `filter` (a search box driving TanStack's global filter) and `paginated` (client-side pages of 20, reusing the browse table's TablePagination, shown only once the rows outnumber one page). The org users table turns both on and makes every data column sortable, with Roles now an accessor column so it can be sorted and searched. A re-sort or a new filter returns to the first page, but new `data` keeps the current one (clamped to the last page if the list shrank): these lists poll every 10s, and TanStack's automatic reset would send the user back to page 1 whenever any record in the payload changed. The filtered and paginated row models live in a separate `studioClientTableFeatures` object rather than the shared feature set: the paginated row model defaults to a 10-row page, so registered globally it would truncate the browse, logs and every other table that did not remember `manualPagination: true`. Only the two features' APIs and state join the shared set, so every table keeps the one table type, and a SimpleBrowseDataTable that opts into neither prop bypasses both row models (`manualFiltering` / `manualPagination`). Refs #1264 Co-Authored-By: Claude Opus 5.5 --- src/components/SimpleBrowseDataTable.test.tsx | 166 +++++++++++++++++- src/components/SimpleBrowseDataTable.tsx | 83 ++++++++- .../databases/components/TablePagination.tsx | 2 +- .../users/constants/tableDefinition.test.tsx | 90 ++++++++++ .../users/constants/tableDefinition.tsx | 21 +-- src/features/organization/users/index.tsx | 2 + src/lib/table.ts | 26 ++- 7 files changed, 365 insertions(+), 25 deletions(-) create mode 100644 src/features/organization/users/constants/tableDefinition.test.tsx diff --git a/src/components/SimpleBrowseDataTable.test.tsx b/src/components/SimpleBrowseDataTable.test.tsx index e4068e8b5..a8063d6e7 100644 --- a/src/components/SimpleBrowseDataTable.test.tsx +++ b/src/components/SimpleBrowseDataTable.test.tsx @@ -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; @@ -31,3 +34,160 @@ describe('SimpleBrowseDataTable', () => { expect(renderedNames()).toEqual(['alpha', 'zeta']); }); }); + +interface Animal { + name: string; + kind: string; + tag: string; +} + +const animalColumns: ColumnDef[] = [ + { header: 'Name', accessorKey: 'name', enableSorting: true }, + { header: 'Kind', accessorKey: 'kind' }, + { header: 'Tag', accessorKey: 'tag', enableGlobalFilter: false }, +]; + +/** `count` animals named `-00`, `-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(); + + 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(); + + 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(); + + 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(); + + expect(renderedAnimalNames()).toHaveLength(20); + expect(screen.queryByRole('button', { name: 'Next page' })).toBeNull(); + }); + + it('sorts the whole list before cutting it into pages', () => { + render(); + + 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(); + 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( + , + ); + 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(); + nextPage(); + + // A poll that brings back a changed list hands the table a new array. + await settled(() => rerender()); + + 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(); + nextPage(); + nextPage(); + expect(renderedAnimalNames()).toHaveLength(5); + + await settled(() => rerender()); + + expect(renderedAnimalNames()).toEqual(animals(30).slice(20).map((animal) => animal.name)); + expect(screen.getByText('30 records')).toBeTruthy(); + }); +}); diff --git a/src/components/SimpleBrowseDataTable.tsx b/src/components/SimpleBrowseDataTable.tsx index ddbb22e20..e68024dd4 100644 --- a/src/components/SimpleBrowseDataTable.tsx +++ b/src/components/SimpleBrowseDataTable.tsx @@ -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'; 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'; + +const SMALLEST_PAGE_SIZE = PAGE_SIZE_OPTIONS[0]; interface BrowseDataTableProps { columns: ColumnDef[]; @@ -15,6 +20,14 @@ interface BrowseDataTableProps { onRowClick?: (row: Row) => 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; } @@ -25,23 +38,65 @@ export function SimpleBrowseDataTable({ onRowClick, onColumnClick, sortingState, + filter, + paginated, children, }: BrowseDataTableProps) { + // `?? []` 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 ?? []); + const [globalFilter, setGlobalFilter] = useState(''); + const [pagination, setPagination] = useState({ 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]); return ( <>
+ {filter && ( +
+
+ )}
{children}
@@ -91,6 +146,16 @@ export function SimpleBrowseDataTable({ )} + {showPager && ( + + )} ); } diff --git a/src/features/instance/databases/components/TablePagination.tsx b/src/features/instance/databases/components/TablePagination.tsx index c5f296732..aeac3fe7e 100644 --- a/src/features/instance/databases/components/TablePagination.tsx +++ b/src/features/instance/databases/components/TablePagination.tsx @@ -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; diff --git a/src/features/organization/users/constants/tableDefinition.test.tsx b/src/features/organization/users/constants/tableDefinition.test.tsx new file mode 100644 index 000000000..618ff3ab9 --- /dev/null +++ b/src/features/organization/users/constants/tableDefinition.test.tsx @@ -0,0 +1,90 @@ +/** + * @vitest-environment jsdom + */ +import { SimpleBrowseDataTable } from '@/components/SimpleBrowseDataTable'; +import { SchemaOrganizationRole, SchemaUser } from '@/integrations/api/api.gen'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { dataTableColumns } from './tableDefinition'; + +vi.mock('@/features/organization/users/components/ResendInviteButton', () => ({ + ResendInviteButton: () => null, +})); + +afterEach(() => cleanup()); + +function role(roleName: string): SchemaOrganizationRole { + return { id: `role-${roleName}`, organizationId: 'org-1', roleName, userIds: [] }; +} + +function user(email: string, roleNames: string[], extra: Partial = {}): SchemaUser { + return { + id: `id-${email}`, + email, + firstname: email.split('@')[0], + lastname: 'Example', + status: 'ACTIVE', + roles: roleNames.map(role), + ...extra, + }; +} + +function renderedEmails() { + return Array.from(document.querySelectorAll('tbody tr')).map((row) => row.querySelectorAll('td')[1]?.textContent); +} + +function renderUsers(users: SchemaUser[]) { + render( + , + ); +} + +async function typeFilter(text: string) { + await act(async () => { + fireEvent.change(screen.getByRole('searchbox', { name: 'Filter users' }), { target: { value: text } }); + }); +} + +describe('org users table columns', () => { + it('filters by email, name, role and status, but not by the opaque user id', async () => { + renderUsers([ + user('ada@example.com', ['admin']), + user('bob@example.com', ['developer'], { firstname: 'Robert' }), + user('cy@example.com', ['viewer'], { status: 'PENDING' }), + ]); + + await typeFilter('robert'); + expect(renderedEmails()).toEqual(['bob@example.com']); + + await typeFilter('Admin'); + expect(renderedEmails()).toEqual(['ada@example.com']); + + await typeFilter('pending'); + expect(renderedEmails()).toEqual(['cy@example.com']); + + // Every id is `id-`, so an id match would return every row. + await typeFilter('id-'); + expect(screen.getByText('No results.')).toBeTruthy(); + }); + + it('sorts by the roles a user holds', () => { + renderUsers([ + user('ada@example.com', ['viewer']), + user('bob@example.com', ['developer', 'admin']), + user('cy@example.com', ['billing']), + ]); + + fireEvent.click(screen.getByRole('button', { name: 'Roles' })); + + expect(renderedEmails()).toEqual(['bob@example.com', 'cy@example.com', 'ada@example.com']); + expect(screen.getByText('admin, developer')).toBeTruthy(); + }); + + it("lists a user's roles alphabetically without reordering the user's own roles", () => { + const bob = user('bob@example.com', ['viewer', 'admin']); + renderUsers([bob]); + + expect(screen.getByText('admin, viewer')).toBeTruthy(); + expect(bob.roles?.map((heldRole) => heldRole.roleName)).toEqual(['viewer', 'admin']); + }); +}); diff --git a/src/features/organization/users/constants/tableDefinition.tsx b/src/features/organization/users/constants/tableDefinition.tsx index e0e70922b..c57d16cb8 100644 --- a/src/features/organization/users/constants/tableDefinition.tsx +++ b/src/features/organization/users/constants/tableDefinition.tsx @@ -8,38 +8,39 @@ export const dataTableColumns: Array> = [ { header: 'User Id', accessorKey: 'id', - enableSorting: false, + enableSorting: true, + enableGlobalFilter: false, }, { header: 'Email', accessorKey: 'email', - enableSorting: false, + enableSorting: true, }, { header: 'First Name', accessorKey: 'firstname', - enableSorting: false, + enableSorting: true, }, { header: 'Last Name', accessorKey: 'lastname', - enableSorting: false, + enableSorting: true, }, - columnHelper.display({ + { header: 'Roles', - enableSorting: false, id: 'roles', - cell: (props) => props.row.original.roles?.map(r => r.roleName)?.sort()?.join(', '), - }), + accessorFn: (user) => (user.roles ?? []).map((role) => role.roleName).sort().join(', '), + enableSorting: true, + }, { header: 'Status', accessorKey: 'status', - enableSorting: false, + enableSorting: true, }, { header: 'Verified', accessorKey: 'isVerified', - enableSorting: false, + enableSorting: true, }, columnHelper.display({ header: '', diff --git a/src/features/organization/users/index.tsx b/src/features/organization/users/index.tsx index c9bec8a32..687085f16 100644 --- a/src/features/organization/users/index.tsx +++ b/src/features/organization/users/index.tsx @@ -93,6 +93,8 @@ export function OrgConfigUsersIndex() { columns={dataTableColumns} onRowClick={onRowClick} sortingState={sortingState} + filter={{ label: 'Filter users', placeholder: 'Filter by email, name, role or status' }} + paginated >