/* Copyright 2026 Marimo. All rights reserved. */
import type {
Column,
SortDirection,
SortingState,
Table,
} from "@tanstack/react-table";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ColumnPinning,
ColumnWrapping,
CopyColumn,
DataType,
FormatOptions,
HideColumn,
Sorts,
} from "../header-items";
const renderInMenu = (node: React.ReactNode) =>
render(
{node}
,
);
describe("multi-column sorting logic", () => {
// Extract the core sorting logic to test in isolation
const handleSort = (options: {
columnId: string;
desc: boolean;
sortingState: SortingState;
setSorting: (state: SortingState) => void;
clearSorting: () => void;
}) => {
const { columnId, desc, sortingState, setSorting, clearSorting } = options;
const currentSort = sortingState.find((s) => s.id === columnId);
if (currentSort && currentSort.desc === desc) {
// Clicking the same sort again - remove it
clearSorting();
} else {
// New sort or different direction - move to end of stack
const otherSorts = sortingState.filter((s) => s.id !== columnId);
const newSort = { id: columnId, desc };
setSorting([...otherSorts, newSort]);
}
};
it("implements stack-based sorting: moves re-clicked column to end", () => {
const sortingState: SortingState = [
{ id: "name", desc: false },
{ id: "age", desc: false },
];
const setSorting = vi.fn();
const clearSorting = vi.fn();
// Click Desc on age - should move age to end with desc=true
handleSort({
columnId: "age",
desc: true,
sortingState,
setSorting,
clearSorting,
});
expect(setSorting).toHaveBeenCalledWith([
{ id: "name", desc: false },
{ id: "age", desc: true },
]);
expect(clearSorting).not.toHaveBeenCalled();
});
it("removes sort when clicking same direction twice", () => {
const sortingState: SortingState = [{ id: "age", desc: false }];
const setSorting = vi.fn();
const clearSorting = vi.fn();
// Click Asc on age again - should remove the sort
handleSort({
columnId: "age",
desc: false,
sortingState,
setSorting,
clearSorting,
});
expect(clearSorting).toHaveBeenCalled();
expect(setSorting).not.toHaveBeenCalled();
});
it("adds new column to end of stack", () => {
const sortingState: SortingState = [{ id: "name", desc: false }];
const setSorting = vi.fn();
const clearSorting = vi.fn();
// Click Asc on age - should add age to end
handleSort({
columnId: "age",
desc: false,
sortingState,
setSorting,
clearSorting,
});
expect(setSorting).toHaveBeenCalledWith([
{ id: "name", desc: false },
{ id: "age", desc: false },
]);
expect(clearSorting).not.toHaveBeenCalled();
});
it("toggles sort direction when clicking opposite", () => {
const sortingState: SortingState = [{ id: "age", desc: false }];
const setSorting = vi.fn();
const clearSorting = vi.fn();
// Click Desc on age - should toggle to descending
handleSort({
columnId: "age",
desc: true,
sortingState,
setSorting,
clearSorting,
});
expect(setSorting).toHaveBeenCalledWith([{ id: "age", desc: true }]);
expect(clearSorting).not.toHaveBeenCalled();
});
it("correctly calculates priority numbers", () => {
const sortingState: SortingState = [
{ id: "name", desc: false },
{ id: "age", desc: true },
{ id: "dept", desc: false },
];
// Priority is index + 1
const nameSort = sortingState.find((s) => s.id === "name");
const namePriority = nameSort ? sortingState.indexOf(nameSort) + 1 : null;
expect(namePriority).toBe(1);
const deptSort = sortingState.find((s) => s.id === "dept");
const deptPriority = deptSort ? sortingState.indexOf(deptSort) + 1 : null;
expect(deptPriority).toBe(3);
});
it("handles removing column from middle of stack", () => {
const sortingState: SortingState = [
{ id: "name", desc: false },
{ id: "age", desc: true },
{ id: "dept", desc: false },
];
const setSorting = vi.fn();
const clearSorting = vi.fn();
// Click Desc on age again - should remove it
handleSort({
columnId: "age",
desc: true,
sortingState,
setSorting,
clearSorting,
});
expect(clearSorting).toHaveBeenCalled();
// After removal, dept should move from priority 3 to priority 2
});
});
describe("HideColumn", () => {
const makeColumn = ({
canHide = true,
toggleVisibility = vi.fn(),
}: {
canHide?: boolean;
toggleVisibility?: (value?: boolean) => void;
} = {}) =>
({
getCanHide: () => canHide,
toggleVisibility,
}) as unknown as Column;
it("renders 'Hide column' when canHide is true", () => {
renderInMenu();
expect(screen.getByText("Hide column")).toBeInTheDocument();
});
it("returns null when getCanHide is false", () => {
renderInMenu();
expect(screen.queryByText("Hide column")).toBeNull();
});
it("calls toggleVisibility(false) on click", () => {
const toggleVisibility = vi.fn();
renderInMenu();
fireEvent.click(screen.getByText("Hide column"));
expect(toggleVisibility).toHaveBeenCalledWith(false);
});
});
describe("DataType", () => {
const makeColumn = (dtype?: string) =>
({
columnDef: { meta: dtype === undefined ? {} : { dtype } },
}) as unknown as Column;
it("renders the dtype label when present", () => {
renderInMenu();
expect(screen.getByText("int64")).toBeInTheDocument();
});
it("returns null when dtype is absent", () => {
renderInMenu();
expect(screen.queryByText("int64")).toBeNull();
});
});
describe("Sorts", () => {
const makeColumn = ({
canSort = true,
sorted = false,
sortIndex = 0,
}: {
canSort?: boolean;
sorted?: false | SortDirection;
sortIndex?: number;
} = {}) =>
({
getCanSort: () => canSort,
getIsSorted: () => sorted,
getSortIndex: () => sortIndex,
clearSorting: vi.fn(),
toggleSorting: vi.fn(),
}) as unknown as Column;
const makeTable = (sorting: SortingState) =>
({
getState: () => ({ sorting }),
resetSorting: vi.fn(),
}) as unknown as Table;
it("returns null when the column cannot sort", () => {
renderInMenu();
expect(screen.queryByText("Asc")).toBeNull();
});
it("renders Asc and Desc items", () => {
renderInMenu();
expect(screen.getByText("Asc")).toBeInTheDocument();
expect(screen.getByText("Desc")).toBeInTheDocument();
});
it("offers single-column 'Clear sort' when sorted without multi-sort", () => {
renderInMenu();
expect(screen.getByText("Clear sort")).toBeInTheDocument();
});
it("offers 'Clear all sorts' when the table has multiple sorts", () => {
renderInMenu(
,
);
expect(screen.getByText("Clear all sorts")).toBeInTheDocument();
});
});
describe("CopyColumn", () => {
const makeColumn = ({
canCopy = true,
id = "name",
}: {
canCopy?: boolean;
id?: string;
} = {}) =>
({
id,
getCanCopy: () => canCopy,
}) as unknown as Column;
it("renders 'Copy column name' when copyable", () => {
renderInMenu();
expect(screen.getByText("Copy column name")).toBeInTheDocument();
});
it("returns null when the column cannot be copied", () => {
renderInMenu();
expect(screen.queryByText("Copy column name")).toBeNull();
});
});
describe("ColumnPinning", () => {
const makeColumn = ({
canPin = true,
pinned = false,
}: {
canPin?: boolean;
pinned?: false | "left" | "right";
} = {}) =>
({
getCanPin: () => canPin,
getIsPinned: () => pinned,
pin: vi.fn(),
}) as unknown as Column;
it("returns null when the column cannot be pinned", () => {
renderInMenu();
expect(screen.queryByText("Freeze left")).toBeNull();
});
it("offers freeze options when unpinned", () => {
renderInMenu();
expect(screen.getByText("Freeze left")).toBeInTheDocument();
expect(screen.getByText("Freeze right")).toBeInTheDocument();
});
it("offers 'Unfreeze' when pinned", () => {
renderInMenu();
expect(screen.getByText("Unfreeze")).toBeInTheDocument();
});
});
describe("ColumnWrapping", () => {
const makeColumn = ({
canWrap = true,
wrapping = "nowrap",
}: {
canWrap?: boolean;
wrapping?: "wrap" | "nowrap";
} = {}) =>
({
getCanWrap: () => canWrap,
getColumnWrapping: () => wrapping,
toggleColumnWrapping: vi.fn(),
}) as unknown as Column;
it("returns null when the column cannot wrap", () => {
renderInMenu();
expect(screen.queryByText("Wrap text")).toBeNull();
});
it("offers 'Wrap text' when not wrapping", () => {
renderInMenu();
expect(screen.getByText("Wrap text")).toBeInTheDocument();
});
it("offers 'No wrap text' when wrapping", () => {
renderInMenu();
expect(screen.getByText("No wrap text")).toBeInTheDocument();
});
});
describe("FormatOptions", () => {
const makeColumn = ({
dataType = "number",
canFormat = true,
}: {
dataType?: string;
canFormat?: boolean;
} = {}) =>
({
columnDef: { meta: { dataType } },
getCanFormat: () => canFormat,
getColumnFormatting: () => undefined,
setColumnFormatting: vi.fn(),
}) as unknown as Column;
it("renders the 'Format' submenu trigger for formattable columns", () => {
renderInMenu();
expect(screen.getByText("Format")).toBeInTheDocument();
});
it("returns null when the column cannot be formatted", () => {
renderInMenu(
,
);
expect(screen.queryByText("Format")).toBeNull();
});
it("returns null when the data type has no format options", () => {
renderInMenu(
,
);
expect(screen.queryByText("Format")).toBeNull();
});
});