From cca4e52dd0bb10f863327e05de2ff81389a3fc85 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:48:52 +1100 Subject: [PATCH] refactor(ui-patterns): Standardise TanStack sort headers (#44212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Component update. ## What is the current behaviour? TanStack tables in the repo are split between the shared `TableHeadSort` primitive and the older Studio-local `DataTableColumnHeader` helper, which makes the sorting UI and integration path inconsistent. If you were to just use `DataTableColumnHeader` in `ui-patterns/Table`, you’d get a very different visual result to the `TableHeadSort` UI you see in most other tables. ## What is the new behaviour? Adds a shared `TanStackTableHeadSort` adapter in `ui-patterns/Table`, backed by the existing `TableHeadSort` primitive, and switches the webhook table plus the design-system TanStack demo to that canonical path. `DataTableColumnHeader` stays as a deprecated wrapper for now, Studio gets a lint guard to block new imports of it, and the table docs now point TanStack tables at the shared adapter explicitly. ## To test Check out column sorting on the Platform Webhook endpoint deliveries table. --- .../content/docs/components/table.mdx | 17 +++ .../default/example/data-table-demo.tsx | 65 +++------ .../PlatformWebhooksEndpointDetails.tsx | 69 ++++------ .../UnifiedLogs/components/Columns.tsx | 9 +- .../DataTableColumn/DataTableColumnHeader.tsx | 49 ++----- apps/studio/eslint.config.cjs | 11 ++ packages/ui-patterns/package.json | 5 + .../src/Table/TanStackTableHeadSort.test.tsx | 124 ++++++++++++++++++ .../src/Table/TanStackTableHeadSort.tsx | 53 ++++++++ packages/ui-patterns/src/Table/index.ts | 1 + pnpm-lock.yaml | 3 + 11 files changed, 270 insertions(+), 136 deletions(-) create mode 100644 packages/ui-patterns/src/Table/TanStackTableHeadSort.test.tsx create mode 100644 packages/ui-patterns/src/Table/TanStackTableHeadSort.tsx create mode 100644 packages/ui-patterns/src/Table/index.ts diff --git a/apps/design-system/content/docs/components/table.mdx b/apps/design-system/content/docs/components/table.mdx index 8553b7eb394..f25a80fe12f 100644 --- a/apps/design-system/content/docs/components/table.mdx +++ b/apps/design-system/content/docs/components/table.mdx @@ -144,6 +144,23 @@ The component displays: +For TanStack tables, prefer the shared adapter instead of reimplementing the `TableHeadSort` bridge in each table. + +```tsx +import { TanStackTableHeadSort } from 'ui-patterns/Table' +``` + +```tsx showLineNumbers +const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => Name, + }, +] +``` + +This keeps TanStack tables aligned with the same `TableHeadSort` visual treatment and sorting cycle used by manual tables. + ### Row icons When adding icon columns to your table, use [Accessibility](../accessibility) markup by including a screen reader-only label in the corresponding Table Head using the `sr-only` class. This ensures that assistive technologies can properly identify the column's purpose. Remove these icon cells when loading or displaying zero results to maintain a clean and consistent table structure. diff --git a/apps/design-system/registry/default/example/data-table-demo.tsx b/apps/design-system/registry/default/example/data-table-demo.tsx index 409bf184363..bb32e652d50 100644 --- a/apps/design-system/registry/default/example/data-table-demo.tsx +++ b/apps/design-system/registry/default/example/data-table-demo.tsx @@ -30,9 +30,9 @@ import { TableCell, TableHead, TableHeader, - TableHeadSort, TableRow, } from 'ui' +import { TanStackTableHeadSort } from 'ui-patterns/Table' const data: Payment[] = [ { @@ -99,19 +99,23 @@ export const columns: ColumnDef[] = [ }, { accessorKey: 'status', - header: 'Status', + header: ({ column }) => Status, enableSorting: true, cell: ({ row }) =>
{row.getValue('status')}
, }, { accessorKey: 'email', - header: 'Email', + header: ({ column }) => Email, enableSorting: true, cell: ({ row }) =>
{row.getValue('email')}
, }, { accessorKey: 'amount', - header: () =>
Amount
, + header: ({ column }) => ( + + Amount + + ), enableSorting: true, cell: ({ row }) => { const amount = parseFloat(row.getValue('amount')) @@ -164,33 +168,6 @@ export default function DataTableDemo() { const [columnVisibility, setColumnVisibility] = React.useState({}) const [rowSelection, setRowSelection] = React.useState({}) - // Convert TanStack Table's SortingState to the string format expected by TableHeadSort - const getSortString = React.useMemo(() => { - if (sorting.length === 0) return '' - const sort = sorting[0] - return `${sort.id}:${sort.desc ? 'desc' : 'asc'}` - }, [sorting]) - - // Handle sort changes from TableHeadSort and convert to TanStack Table's SortingState - const handleSortChange = React.useCallback( - (column: string) => { - const currentSort = sorting.find((s) => s.id === column) - if (currentSort) { - if (currentSort.desc) { - // Cycle: desc -> remove sort - setSorting([]) - } else { - // Cycle: asc -> desc - setSorting([{ id: column, desc: true }]) - } - } else { - // New column, start with asc - setSorting([{ id: column, desc: false }]) - } - }, - [sorting] - ) - const table = useReactTable({ data, columns, @@ -254,11 +231,20 @@ export default function DataTableDemo() { {headerGroup.headers.map((header) => { const columnId = header.column.id - const canSort = header.column.getCanSort() + const sort = header.column.getIsSorted() return ( - {header.isPlaceholder ? null : canSort ? ( - - {flexRender(header.column.columnDef.header, header.getContext())} - - ) : ( - flexRender(header.column.columnDef.header, header.getContext()) - )} + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} ) })} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx index ca83443e2d6..6089437abd0 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -24,11 +24,11 @@ import { TableCell, TableHead, TableHeader, - TableHeadSort, TableRow, } from 'ui' import { TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' +import { TanStackTableHeadSort } from 'ui-patterns/Table' import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' import { statusBadgeVariant } from './PlatformWebhooksView.utils' @@ -59,39 +59,24 @@ const DELIVERIES_PAGE_SIZE = 5 const DELIVERY_ACTIONS_COLUMN_ID = 'actions' const DEFAULT_DELIVERY_SORTING: SortingState = [{ id: 'attemptAt', desc: true }] -const getCurrentSort = (sorting: SortingState) => { - if (sorting.length === 0) return '' - - const [currentSort] = sorting - return `${currentSort.id}:${currentSort.desc ? 'desc' : 'asc'}` -} - -const getAriaSort = ( - sorting: SortingState, - columnId: string -): 'ascending' | 'descending' | 'none' => { - const currentSort = sorting.find((sort) => sort.id === columnId) - - if (!currentSort) return 'none' - return currentSort.desc ? 'descending' : 'ascending' -} - const DELIVERY_COLUMNS: ColumnDef[] = [ { accessorKey: 'status', - header: 'Status', + header: ({ column }) => Status, cell: ({ row }) => ( {row.original.status} ), }, { accessorKey: 'eventType', - header: 'Event type', + header: ({ column }) => ( + Event type + ), cell: ({ row }) => {row.original.eventType}, }, { accessorKey: 'responseCode', - header: 'Response', + header: ({ column }) => Response, sortingFn: (rowA, rowB, columnId) => { const responseA = rowA.getValue(columnId) ?? -1 const responseB = rowB.getValue(columnId) ?? -1 @@ -110,7 +95,9 @@ const DELIVERY_COLUMNS: ColumnDef[] = [ }, { accessorKey: 'attemptAt', - header: 'Attempted', + header: ({ column }) => ( + Attempted + ), cell: ({ row }) => ( { - const currentColumnSort = sorting.find((sort) => sort.id === columnId) - - if (!currentColumnSort) { - setSorting([{ id: columnId, desc: false }]) - return - } - - setSorting([{ id: columnId, desc: !currentColumnSort.desc }]) - } const table = useReactTable({ data: filteredDeliveries, @@ -290,25 +265,25 @@ export const PlatformWebhooksEndpointDetails = ({ {headerGroup.headers.map((header) => { const columnId = header.column.id - const canSort = header.column.getCanSort() + const sort = header.column.getIsSorted() return ( - {header.isPlaceholder ? null : canSort ? ( - - {flexRender(header.column.columnDef.header, header.getContext())} - - ) : ( - flexRender(header.column.columnDef.header, header.getContext()) - )} + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} ) })} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx index 0757e887775..0c79832a174 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -1,11 +1,10 @@ import { ColumnDef } from '@tanstack/react-table' - -import { DataTableColumnHeader } from 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader' import { DataTableColumnLevelIndicator } from 'components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator' import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema' + import { STATUS_CODE_LABELS } from '../UnifiedLogs.constants' +import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema' import { AuthUserHoverCard } from './AuthUserHoverCard' import { HoverCardTimestamp } from './HoverCardTimestamp' import { LogTypeIcon } from './LogTypeIcon' @@ -64,7 +63,7 @@ export function generateDynamicColumns(data: ColumnSchema[]): { // Date column - always visible { accessorKey: 'date', - header: ({ column }) => , + header: 'Date', cell: ({ row }) => { const date = new Date(row.getValue('date')) return @@ -195,7 +194,7 @@ export function generateDynamicColumns(data: ColumnSchema[]): { // Event message column - controlled by columnVisibility { accessorKey: 'event_message', - header: ({ column }) => , + header: 'Event message', cell: ({ row }) => { const value = row.getValue('event_message') const logCount = row.original.log_count diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx index 7bd07547b55..ee6f6127aec 100644 --- a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx @@ -1,54 +1,23 @@ import { type Column } from '@tanstack/react-table' -import { ChevronDown, ChevronUp } from 'lucide-react' +import { TanStackTableHeadSort } from 'ui-patterns/Table' -import { Button, cn, type ButtonProps } from 'ui' - -interface DataTableColumnHeaderProps extends ButtonProps { +interface DataTableColumnHeaderProps { column: Column title: string + className?: string } +/** + * @deprecated Use `TanStackTableHeadSort` from `ui-patterns/Table` instead. + */ export const DataTableColumnHeader = ({ column, title, className, - ...props }: DataTableColumnHeaderProps) => { - if (!column.getCanSort()) { - return
{title}
- } - return ( - + + {title} + ) } diff --git a/apps/studio/eslint.config.cjs b/apps/studio/eslint.config.cjs index 64ced45988d..d1de2c309c4 100644 --- a/apps/studio/eslint.config.cjs +++ b/apps/studio/eslint.config.cjs @@ -17,6 +17,17 @@ module.exports = defineConfig([ 'react/display-name': 'warn', 'react/no-unstable-nested-components': 'warn', 'react/jsx-key': 'error', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader', + message: 'Use TanStackTableHeadSort from ui-patterns/Table instead.', + }, + ], + }, + ], 'barrel-files/avoid-re-export-all': 'error', 'jsx-a11y/alt-text': 'warn', 'jsx-a11y/role-has-required-aria-props': 'error', diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 69f5049aae3..76cef55abab 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -666,6 +666,10 @@ "import": "./src/TimestampInfo/index.tsx", "types": "./src/TimestampInfo/index.tsx" }, + "./Table": { + "import": "./src/Table/index.ts", + "types": "./src/Table/index.ts" + }, "./Toc": { "import": "./src/Toc/index.ts", "types": "./src/Toc/index.ts" @@ -769,6 +773,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-visually-hidden": "^1.1.3", "@std/toml": "jsr:^1.0.11", + "@tanstack/react-table": "^8.21.3", "@supabase/sql-to-rest": "^0.1.6", "@supabase/supabase-js": "catalog:", "@vitest/coverage-v8": "^3.2.0", diff --git a/packages/ui-patterns/src/Table/TanStackTableHeadSort.test.tsx b/packages/ui-patterns/src/Table/TanStackTableHeadSort.test.tsx new file mode 100644 index 00000000000..63e4b32f2e3 --- /dev/null +++ b/packages/ui-patterns/src/Table/TanStackTableHeadSort.test.tsx @@ -0,0 +1,124 @@ +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' +import { describe, expect, it } from 'vitest' + +import { TanStackTableHeadSort } from './TanStackTableHeadSort' + +type Row = { + name: string + amount: number +} + +const data: Row[] = [ + { name: 'Bravo', amount: 200 }, + { name: 'Alpha', amount: 100 }, +] + +const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => Name, + cell: ({ row }) => row.getValue('name'), + }, + { + accessorKey: 'amount', + enableSorting: false, + header: ({ column }) => Amount, + cell: ({ row }) => row.getValue('amount'), + }, +] + +const classNameColumns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => ( + + Name + + ), + cell: ({ row }) => row.getValue('name'), + }, +] + +const TestTable = ({ tableColumns = columns }: { tableColumns?: ColumnDef[] }) => { + const [sorting, setSorting] = useState([]) + const table = useReactTable({ + data, + columns: tableColumns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+ ) +} + +describe('TanStackTableHeadSort', () => { + it('cycles unsorted, ascending, descending, and cleared', async () => { + const user = userEvent.setup() + + render() + + expect(screen.getAllByRole('row')[1]).toHaveTextContent('Bravo') + + await user.click(screen.getByRole('button', { name: 'Name' })) + expect(screen.getAllByRole('row')[1]).toHaveTextContent('Alpha') + + await user.click(screen.getByRole('button', { name: 'Name' })) + expect(screen.getAllByRole('row')[1]).toHaveTextContent('Bravo') + + await user.click(screen.getByRole('button', { name: 'Name' })) + expect(screen.getAllByRole('row')[1]).toHaveTextContent('Bravo') + }) + + it('renders non-sortable columns as plain content', () => { + render() + + expect(screen.getByText('Amount')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Amount' })).not.toBeInTheDocument() + }) + + it('passes className through to the rendered sort control', () => { + render() + + expect(screen.getByRole('button', { name: 'Name' })).toHaveClass('justify-end') + }) +}) diff --git a/packages/ui-patterns/src/Table/TanStackTableHeadSort.tsx b/packages/ui-patterns/src/Table/TanStackTableHeadSort.tsx new file mode 100644 index 00000000000..a36169268ef --- /dev/null +++ b/packages/ui-patterns/src/Table/TanStackTableHeadSort.tsx @@ -0,0 +1,53 @@ +'use client' + +import { type Column } from '@tanstack/react-table' +import type { ReactNode } from 'react' +import { cn, TableHeadSort } from 'ui' + +interface TanStackTableHeadSortProps { + column: Column + children: ReactNode + className?: string +} + +/** + * Shared TanStack adapter for the `TableHeadSort` primitive. + * Prefer this in TanStack tables instead of wiring `TableHeadSort` manually. + */ +export const TanStackTableHeadSort = ({ + column, + children, + className, +}: TanStackTableHeadSortProps) => { + if (!column.getCanSort()) { + return
{children}
+ } + + const sort = column.getIsSorted() + const currentSort = sort ? `${column.id}:${sort}` : '' + + const handleSortChange = () => { + if (!sort) { + column.toggleSorting(false) + return + } + + if (sort === 'asc') { + column.toggleSorting(true) + return + } + + column.clearSorting() + } + + return ( + + {children} + + ) +} diff --git a/packages/ui-patterns/src/Table/index.ts b/packages/ui-patterns/src/Table/index.ts new file mode 100644 index 00000000000..37e4e448706 --- /dev/null +++ b/packages/ui-patterns/src/Table/index.ts @@ -0,0 +1 @@ +export * from './TanStackTableHeadSort' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99d5c74e2ee..d7051635407 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2607,6 +2607,9 @@ importers: '@supabase/supabase-js': specifier: 'catalog:' version: 2.100.0 + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@vitest/coverage-v8': specifier: ^3.2.0 version: 3.2.4(supports-color@8.1.1)(vitest@3.2.4)