diff --git a/src/components/table/DataTable.tsx b/src/components/table/DataTable.tsx index 9b164dbd5..c05f05049 100644 --- a/src/components/table/DataTable.tsx +++ b/src/components/table/DataTable.tsx @@ -1,5 +1,6 @@ "use client"; import { TableContentSkeleton } from "@components/skeletons/SkeletonTable"; +import { DataTableInstanceProvider } from "@components/table/DataTableContext"; import DataTableGlobalSearch from "@components/table/DataTableGlobalSearch"; import { DataTableHeadingPortal } from "@components/table/DataTableHeadingPortal"; import { DataTablePagination } from "@components/table/DataTablePagination"; @@ -468,7 +469,7 @@ export function DataTable({ } }, [manualColumnFiltering, externalColumnFilters, table]); - return ( + const content = (
{showSearchAndFilters && (
@@ -678,4 +679,10 @@ export function DataTable({ />
); + + return ( + + {content} + + ); } diff --git a/src/components/table/DataTableContext.tsx b/src/components/table/DataTableContext.tsx new file mode 100644 index 000000000..76a0c664e --- /dev/null +++ b/src/components/table/DataTableContext.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { Table as TanStackTable } from "@tanstack/table-core"; +import React, { createContext, useContext } from "react"; + +const DataTableInstanceContext = createContext | null>(null); + +type ProviderProps = { + table: TanStackTable; + children: React.ReactNode; +}; + +export function DataTableInstanceProvider({ table, children }: ProviderProps) { + return ( + + {children} + + ); +} + +/** + * Returns the tanstack table instance for the surrounding DataTable, or null + * when used outside of one. + */ +export function useOptionalDataTable() { + return useContext(DataTableInstanceContext); +} diff --git a/src/components/table/DataTableHeader.test.tsx b/src/components/table/DataTableHeader.test.tsx new file mode 100644 index 000000000..000938ece --- /dev/null +++ b/src/components/table/DataTableHeader.test.tsx @@ -0,0 +1,156 @@ +import type { ColumnDef, SortingState } from "@tanstack/react-table"; +import { + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import React, { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DataTableInstanceProvider } from "./DataTableContext"; +import DataTableHeader from "./DataTableHeader"; + +// A header click must always replace the sort with the clicked column alone. +// The peers tables default to a multi-sort ([connected, last_seen, name]), and +// column.toggleSorting() only toggles in place when the clicked column is that +// sort's last entry — so the click used to leave the visible order untouched. + +const { setSort } = vi.hoisted(() => ({ setSort: vi.fn() })); + +vi.mock("@/contexts/ServerPaginationProvider", () => ({ + useOptionalServerPagination: () => ({ setSort }), +})); + +afterEach(() => { + cleanup(); + setSort.mockClear(); +}); + +type Peer = { name: string; connected: boolean; last_seen: string }; + +const peers: Peer[] = [ + { name: "alpha", connected: false, last_seen: "2026-01-01" }, + { name: "zulu", connected: true, last_seen: "2026-03-01" }, + { name: "mike", connected: true, last_seen: "2026-02-01" }, +]; + +const columns: ColumnDef[] = [ + { id: "name", accessorKey: "name", sortingFn: "text" }, + { id: "connected", accessorKey: "connected" }, + { id: "last_seen", accessorKey: "last_seen", sortingFn: "text" }, +]; + +// DataTable augments TanStack's FilterFns and SortingFns interfaces, which makes +// both options required on every table in the app. Nothing here uses them. +const filterFns = { + fuzzy: () => true, + dateRange: () => true, + exactMatch: () => true, + arrIncludesSomeExact: () => true, +}; +const sortingFns = { checkbox: () => 0, datetime: () => 0 }; + +// Mirrors DataTable: sorting state lives in the parent and is fed back through +// state.sorting / onSortingChange. +function Harness({ + initialSorting, + columnId, + name, +}: { + initialSorting: SortingState; + columnId: string; + name?: string; +}) { + const [sorting, setSorting] = useState(initialSorting); + const table = useReactTable({ + data: peers, + columns, + filterFns, + sortingFns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + + + Header + + {JSON.stringify(sorting)} +
    + {table.getRowModel().rows.map((row) => ( +
  • {row.original.name}
  • + ))} +
+
+ ); +} + +const defaultSorting: SortingState = [ + { id: "connected", desc: true }, + { id: "last_seen", desc: true }, + { id: "name", desc: false }, +]; + +const rowOrder = () => + screen.getAllByRole("listitem").map((row) => row.textContent); + +const sortingState = () => + JSON.parse(screen.getByTestId("sorting").textContent ?? ""); + +const clickHeader = () => fireEvent.click(screen.getByText("Header")); + +describe("DataTableHeader sorting", () => { + it("replaces a default multi-sort when its lowest-priority column is clicked", () => { + render(); + expect(rowOrder()).toEqual(["zulu", "mike", "alpha"]); + + clickHeader(); + + expect(rowOrder()).toEqual(["alpha", "mike", "zulu"]); + expect(sortingState()).toEqual([{ id: "name", desc: false }]); + }); + + it("sorts ascending on the first click of a column that does not lead the sort", () => { + render(); + + clickHeader(); + + expect(sortingState()).toEqual([{ id: "name", desc: false }]); + expect(rowOrder()).toEqual(["alpha", "mike", "zulu"]); + }); + + it("toggles the direction while the column leads the sort", () => { + render( + , + ); + + clickHeader(); + expect(sortingState()).toEqual([{ id: "name", desc: true }]); + expect(rowOrder()).toEqual(["zulu", "mike", "alpha"]); + + clickHeader(); + expect(sortingState()).toEqual([{ id: "name", desc: false }]); + expect(rowOrder()).toEqual(["alpha", "mike", "zulu"]); + }); + + it("reports the direction it applied to server-side pagination", () => { + render( + , + ); + + clickHeader(); + + expect(sortingState()).toEqual([{ id: "name", desc: false }]); + expect(setSort).toHaveBeenCalledWith("name", "asc"); + }); +}); diff --git a/src/components/table/DataTableHeader.tsx b/src/components/table/DataTableHeader.tsx index 91aeb876a..738410170 100644 --- a/src/components/table/DataTableHeader.tsx +++ b/src/components/table/DataTableHeader.tsx @@ -1,6 +1,7 @@ "use client"; import FullTooltip from "@components/FullTooltip"; +import { useOptionalDataTable } from "@components/table/DataTableContext"; import { IconSortAscending, IconSortDescending } from "@tabler/icons-react"; import type { Column } from "@tanstack/table-core"; import { cn } from "@utils/helpers"; @@ -28,17 +29,27 @@ export default function DataTableHeader({ name, }: Props) { const serverPagination = useOptionalServerPagination(); + const table = useOptionalDataTable(); const handleSort = () => { + // A click replaces the sort with this column alone. The direction only + // flips while the column already leads the sort; clicking any other column + // starts ascending. column.toggleSorting() cannot express this: when the + // column is the lowest-priority entry of an existing multi-sort it toggles + // in place, which leaves the visible order unchanged. + const leadsSort = table?.getState().sorting[0]?.id === column.id; + const desc = leadsSort ? column.getIsSorted() !== "desc" : false; + if (onSort) { onSort(); + } else if (table) { + table.setSorting([{ id: column.id, desc }]); } else { - const direction = column.getIsSorted() === "asc" ? "desc" : "asc"; - column.toggleSorting(direction === "desc"); + column.toggleSorting(desc); } + if (name && serverPagination?.setSort) { - const direction = column.getIsSorted() === "asc" ? "desc" : "asc"; - serverPagination.setSort(name, direction); + serverPagination.setSort(name, desc ? "desc" : "asc"); } };