Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/table/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -478,7 +479,7 @@ export function DataTable<TData, TValue>({
}
}, [manualColumnFiltering, externalColumnFilters, table]);

return (
const content = (
<div className={cn("relative table-fixed-scroll", className)}>
{showSearchAndFilters && (
<div className={cn("flex gap-x-4 gap-y-6", !minimal && "p-default")}>
Expand Down Expand Up @@ -697,4 +698,10 @@ export function DataTable<TData, TValue>({
/>
</div>
);

return (
<DataTableInstanceProvider table={table}>
{content}
</DataTableInstanceProvider>
);
}
27 changes: 27 additions & 0 deletions src/components/table/DataTableContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import type { Table as TanStackTable } from "@tanstack/table-core";
import React, { createContext, useContext } from "react";

const DataTableInstanceContext = createContext<TanStackTable<any> | null>(null);

type ProviderProps = {
table: TanStackTable<any>;
children: React.ReactNode;
};

export function DataTableInstanceProvider({ table, children }: ProviderProps) {
return (
<DataTableInstanceContext.Provider value={table}>
{children}
</DataTableInstanceContext.Provider>
);
}

/**
* Returns the tanstack table instance for the surrounding DataTable, or null
* when used outside of one.
*/
export function useOptionalDataTable() {
Comment thread
heisbrot marked this conversation as resolved.
Outdated
return useContext(DataTableInstanceContext);
}
186 changes: 186 additions & 0 deletions src/components/table/DataTableHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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<Peer>[] = [
{ 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. withContext={false} renders the header the
// way a caller outside DataTable would, without the table instance in context.
function Harness({
initialSorting,
columnId,
name,
withContext = true,
}: {
initialSorting: SortingState;
columnId: string;
name?: string;
withContext?: boolean;
}) {
const [sorting, setSorting] = useState<SortingState>(initialSorting);
const table = useReactTable({
data: peers,
columns,
filterFns,
sortingFns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});

const content = (
<>
<DataTableHeader column={table.getColumn(columnId)!} name={name}>
Header
</DataTableHeader>
<output data-testid={"sorting"}>{JSON.stringify(sorting)}</output>
<ul>
{table.getRowModel().rows.map((row) => (
<li key={row.id}>{row.original.name}</li>
))}
</ul>
</>
);

return withContext ? (
<DataTableInstanceProvider table={table}>
{content}
</DataTableInstanceProvider>
) : (
content
);
}

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(<Harness initialSorting={defaultSorting} columnId={"name"} />);
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(<Harness initialSorting={[]} columnId={"name"} />);

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(
<Harness
initialSorting={[{ id: "name", desc: false }]}
columnId={"name"}
/>,
);

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(
<Harness
initialSorting={defaultSorting}
columnId={"name"}
name={"name"}
/>,
);

clickHeader();

expect(sortingState()).toEqual([{ id: "name", desc: false }]);
expect(setSort).toHaveBeenCalledWith("name", "asc");
});

it("toggles the direction when rendered outside a DataTable", () => {
render(
<Harness
initialSorting={[{ id: "name", desc: false }]}
columnId={"name"}
name={"name"}
withContext={false}
/>,
);

clickHeader();
expect(sortingState()).toEqual([{ id: "name", desc: true }]);
expect(setSort).toHaveBeenLastCalledWith("name", "desc");

clickHeader();
expect(sortingState()).toEqual([{ id: "name", desc: false }]);
expect(setSort).toHaveBeenLastCalledWith("name", "asc");
});
});
21 changes: 17 additions & 4 deletions src/components/table/DataTableHeader.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -28,17 +29,29 @@ 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.
// Outside a DataTable the sort order is unknown, so the column's own
// direction is all there is to flip.
const flips = table ? table.getState().sorting[0]?.id === column.id : true;
const desc = flips && column.getIsSorted() === "asc";

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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Comment thread
heisbrot marked this conversation as resolved.

if (name && serverPagination?.setSort) {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
serverPagination.setSort(name, direction);
serverPagination.setSort(name, desc ? "desc" : "asc");
}
};

Expand Down
Loading