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 >