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 (
- {
- column.toggleSorting(undefined)
- }}
- className={cn(
- 'text-xs',
- 'py-0 px-0 h-7 hover:bg-transparent flex gap-2 items-center justify-between w-full',
- className
- )}
- iconRight={
-
-
-
-
- }
- {...props}
- >
- {title}
-
+
+ {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)