test(web): add 210 tests for lib utils, hooks, and UI components
Lib utilities: format (38), sanitize (12), project-colors (18), csv-export (14). Hooks: useDebounce (8), useTableSort (22), useLocalStorage (18), useColumnConfig (19). Components: BatchActionBar (17), SortableColumnHeader (14), FilterChips (14), ErrorBoundary (16). Web test suite: 63 → 75 files, 343 → 553 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BatchActionBar } from "./BatchActionBar.js";
|
||||
|
||||
describe("BatchActionBar", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders nothing when count is 0", () => {
|
||||
const { container } = render(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders the bar when count is greater than 0", () => {
|
||||
render(<BatchActionBar count={3} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the correct count", () => {
|
||||
render(<BatchActionBar count={42} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByText("42 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders action buttons", () => {
|
||||
const actions = [
|
||||
{ label: "Edit", onClick: vi.fn() },
|
||||
{ label: "Archive", onClick: vi.fn() },
|
||||
];
|
||||
render(<BatchActionBar count={2} actions={actions} onClear={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Clear button", () => {
|
||||
render(<BatchActionBar count={1} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("action variants", () => {
|
||||
it("applies danger styling to actions with variant='danger'", () => {
|
||||
const actions = [{ label: "Delete", onClick: vi.fn(), variant: "danger" as const }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
const btn = screen.getByRole("button", { name: "Delete" });
|
||||
expect(btn.className).toContain("bg-red-600");
|
||||
});
|
||||
|
||||
it("applies default styling to actions without a variant", () => {
|
||||
const actions = [{ label: "Edit", onClick: vi.fn() }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
const btn = screen.getByRole("button", { name: "Edit" });
|
||||
expect(btn.className).toContain("bg-white/10");
|
||||
});
|
||||
|
||||
it("applies default styling to actions with variant='default'", () => {
|
||||
const actions = [{ label: "Edit", onClick: vi.fn(), variant: "default" as const }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
const btn = screen.getByRole("button", { name: "Edit" });
|
||||
expect(btn.className).toContain("bg-white/10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disabled state", () => {
|
||||
it("disables an action button when disabled prop is true", () => {
|
||||
const actions = [{ label: "Export", onClick: vi.fn(), disabled: true }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables an action button when disabled prop is false", () => {
|
||||
const actions = [{ label: "Export", onClick: vi.fn(), disabled: false }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Export" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not fire onClick when action button is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClick = vi.fn();
|
||||
const actions = [{ label: "Export", onClick: handleClick, disabled: true }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Export" }));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactions", () => {
|
||||
it("calls action onClick when an action button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClick = vi.fn();
|
||||
const actions = [{ label: "Edit", onClick: handleClick }];
|
||||
render(<BatchActionBar count={1} actions={actions} onClear={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onClear when the Clear button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClear = vi.fn();
|
||||
render(<BatchActionBar count={1} actions={[]} onClear={handleClear} />);
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(handleClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls each action's onClick independently", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleEdit = vi.fn();
|
||||
const handleDelete = vi.fn();
|
||||
const actions = [
|
||||
{ label: "Edit", onClick: handleEdit },
|
||||
{ label: "Delete", onClick: handleDelete, variant: "danger" as const },
|
||||
];
|
||||
render(<BatchActionBar count={2} actions={actions} onClear={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(handleEdit).toHaveBeenCalledOnce();
|
||||
expect(handleDelete).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(handleDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional rendering on count change", () => {
|
||||
it("renders when count transitions from 0 to positive", () => {
|
||||
const { rerender } = render(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
|
||||
|
||||
rerender(<BatchActionBar count={5} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByText("5 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the bar when count goes back to 0", () => {
|
||||
const { rerender } = render(<BatchActionBar count={3} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
||||
|
||||
rerender(<BatchActionBar count={0} actions={[]} onClear={vi.fn()} />);
|
||||
expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ErrorInfo } from "react";
|
||||
import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js";
|
||||
|
||||
// Suppress React's console.error output during error boundary tests
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// A component that unconditionally throws
|
||||
function ThrowingChild({ message = "Test error" }: { message?: string }) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// A component that throws only when `shouldThrow` is true
|
||||
function ConditionalThrower({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
if (shouldThrow) throw new Error("Conditional error");
|
||||
return <div>OK</div>;
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
describe("normal rendering", () => {
|
||||
it("renders children when no error is thrown", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Hello World</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("Hello World")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple children normally", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error catching", () => {
|
||||
it("catches a thrown error and renders the default fallback UI", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the error message in the default fallback", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild message="Custom error message" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("Custom error message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a custom fallback node instead of the default when provided", () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Custom fallback</div>}>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText("Custom fallback")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onError callback when an error is caught", () => {
|
||||
const handleError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary onError={handleError}>
|
||||
<ThrowingChild message="Tracked error" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(handleError).toHaveBeenCalledOnce();
|
||||
const [error, info] = handleError.mock.calls[0] as [Error, ErrorInfo];
|
||||
expect(error.message).toBe("Tracked error");
|
||||
expect(info).toHaveProperty("componentStack");
|
||||
});
|
||||
|
||||
it("does not call onError when no error is thrown", () => {
|
||||
const handleError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary onError={handleError}>
|
||||
<div>No error here</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(handleError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("default fallback UI", () => {
|
||||
it("renders the 'Something went wrong' heading", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 'Try again' button", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 'Go to dashboard' button", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Go to dashboard" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a generic message when error.message is empty", () => {
|
||||
function ThrowEmptyMessage() {
|
||||
const e = new Error("");
|
||||
throw e;
|
||||
}
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowEmptyMessage />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("An unexpected error occurred. The team has been notified."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset / Try again", () => {
|
||||
it("clears the error state and re-renders children when Try again is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Use a mutable ref-like object to control throwing from outside JSX
|
||||
let shouldThrow = true;
|
||||
|
||||
function ControlledThrower() {
|
||||
if (shouldThrow) throw new Error("Controlled error");
|
||||
return <div>OK</div>;
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ControlledThrower />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
|
||||
// Stop throwing, then click "Try again" so ErrorBoundary resets its state
|
||||
shouldThrow = false;
|
||||
await user.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
// Trigger a re-render so ControlledThrower runs with shouldThrow=false
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ControlledThrower />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("OK")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultErrorFallback", () => {
|
||||
it("renders the heading", () => {
|
||||
render(<DefaultErrorFallback error={new Error("Boom")} reset={vi.fn()} />);
|
||||
expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the error message", () => {
|
||||
render(<DefaultErrorFallback error={new Error("Specific error")} reset={vi.fn()} />);
|
||||
expect(screen.getByText("Specific error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to generic text when error message is empty", () => {
|
||||
render(<DefaultErrorFallback error={new Error("")} reset={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByText("An unexpected error occurred. The team has been notified."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls reset when Try again is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleReset = vi.fn();
|
||||
render(<DefaultErrorFallback error={new Error("Err")} reset={handleReset} />);
|
||||
await user.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(handleReset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("navigates to /dashboard when Go to dashboard is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
// jsdom does not navigate; spy on the assignment
|
||||
const originalHref = window.location.href;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: originalHref },
|
||||
writable: true,
|
||||
});
|
||||
render(<DefaultErrorFallback error={new Error("Err")} reset={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Go to dashboard" }));
|
||||
expect(window.location.href).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FilterChips, type Chip } from "./FilterChips.js";
|
||||
|
||||
describe("FilterChips", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders nothing when chips array is empty", () => {
|
||||
const { container } = render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders chip labels when chips are provided", () => {
|
||||
const chips: Chip[] = [
|
||||
{ label: "Active", onRemove: vi.fn() },
|
||||
{ label: "Design", onRemove: vi.fn() },
|
||||
];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a remove button for each chip", () => {
|
||||
const chips: Chip[] = [
|
||||
{ label: "Active", onRemove: vi.fn() },
|
||||
{ label: "Design", onRemove: vi.fn() },
|
||||
];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Remove filter: Active" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove filter: Design" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the 'Clear all' button when chips are present", () => {
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render 'Clear all' when chips array is empty", () => {
|
||||
render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a single chip correctly", () => {
|
||||
const chips: Chip[] = [{ label: "Pending", onRemove: vi.fn() }];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove filter: Pending" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactions", () => {
|
||||
it("calls onRemove of the correct chip when its remove button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemoveActive = vi.fn();
|
||||
const handleRemoveDesign = vi.fn();
|
||||
const chips: Chip[] = [
|
||||
{ label: "Active", onRemove: handleRemoveActive },
|
||||
{ label: "Design", onRemove: handleRemoveDesign },
|
||||
];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove filter: Active" }));
|
||||
expect(handleRemoveActive).toHaveBeenCalledOnce();
|
||||
expect(handleRemoveDesign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onRemove for the second chip independently", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemoveActive = vi.fn();
|
||||
const handleRemoveDesign = vi.fn();
|
||||
const chips: Chip[] = [
|
||||
{ label: "Active", onRemove: handleRemoveActive },
|
||||
{ label: "Design", onRemove: handleRemoveDesign },
|
||||
];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove filter: Design" }));
|
||||
expect(handleRemoveDesign).toHaveBeenCalledOnce();
|
||||
expect(handleRemoveActive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClearAll when the Clear all button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClearAll = vi.fn();
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
|
||||
render(<FilterChips chips={chips} onClearAll={handleClearAll} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
expect(handleClearAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not call other handlers when Clear all is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemove = vi.fn();
|
||||
const handleClearAll = vi.fn();
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: handleRemove }];
|
||||
render(<FilterChips chips={chips} onClearAll={handleClearAll} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
expect(handleRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional rendering on chip changes", () => {
|
||||
it("shows chips after transitioning from empty to non-empty", () => {
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
|
||||
const { rerender } = render(<FilterChips chips={[]} onClearAll={vi.fn()} />);
|
||||
expect(screen.queryByText("Active")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides when all chips are removed", () => {
|
||||
const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
|
||||
const { rerender } = render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
|
||||
rerender(<FilterChips chips={[]} onClearAll={vi.fn()} />);
|
||||
expect(screen.queryByText("Active")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chip remove button aria-label", () => {
|
||||
it("uses the chip label in the aria-label", () => {
|
||||
const chips: Chip[] = [{ label: "In Progress", onRemove: vi.fn() }];
|
||||
render(<FilterChips chips={chips} onClearAll={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove filter: In Progress" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SortableColumnHeader } from "./SortableColumnHeader.js";
|
||||
|
||||
// Helper: render inside a table/thead/tr to satisfy HTML semantics
|
||||
function renderInTable(ui: React.ReactElement) {
|
||||
return render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{ui}</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SortableColumnHeader", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders the label text", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a button for the sortable label", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Budget"
|
||||
field="budget"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /budget/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders inside a <th> element", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Status"
|
||||
field="status"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("th")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sort direction icons", () => {
|
||||
it("shows no active direction when sortField does not match field", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="budget"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Neither chevron should be brand-colored
|
||||
const paths = container.querySelectorAll("path");
|
||||
paths.forEach((path) => {
|
||||
expect(path.className.baseVal).not.toContain("stroke-brand-600");
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights the up chevron when sorted asc on this field", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const paths = container.querySelectorAll("path");
|
||||
// First path = up chevron (asc)
|
||||
expect(paths[0]?.className.baseVal).toContain("stroke-brand-600");
|
||||
// Second path = down chevron should be gray
|
||||
expect(paths[1]?.className.baseVal).toContain("stroke-gray-300");
|
||||
});
|
||||
|
||||
it("highlights the down chevron when sorted desc on this field", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir="desc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const paths = container.querySelectorAll("path");
|
||||
// First path = up chevron should be gray
|
||||
expect(paths[0]?.className.baseVal).toContain("stroke-gray-300");
|
||||
// Second path = down chevron (desc)
|
||||
expect(paths[1]?.className.baseVal).toContain("stroke-brand-600");
|
||||
});
|
||||
|
||||
it("shows no active direction when sortDir is null for this field", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const paths = container.querySelectorAll("path");
|
||||
paths.forEach((path) => {
|
||||
expect(path.className.baseVal).not.toContain("stroke-brand-600");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactions", () => {
|
||||
it("calls onSort with the field when the button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSort = vi.fn();
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={handleSort}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /name/i }));
|
||||
expect(handleSort).toHaveBeenCalledOnce();
|
||||
expect(handleSort).toHaveBeenCalledWith("name");
|
||||
});
|
||||
|
||||
it("passes the correct field string when multiple columns exist", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSort = vi.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortableColumnHeader
|
||||
label="Budget"
|
||||
field="budget"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /budget/i }));
|
||||
expect(handleSort).toHaveBeenCalledWith("budget");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alignment", () => {
|
||||
it("applies justify-start for left alignment (default)", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const div = container.querySelector("th > div");
|
||||
expect(div?.className).toContain("justify-start");
|
||||
});
|
||||
|
||||
it("applies justify-end for right alignment", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Amount"
|
||||
field="amount"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
align="right"
|
||||
/>,
|
||||
);
|
||||
const div = container.querySelector("th > div");
|
||||
expect(div?.className).toContain("justify-end");
|
||||
});
|
||||
|
||||
it("applies justify-center for center alignment", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Status"
|
||||
field="status"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
align="center"
|
||||
/>,
|
||||
);
|
||||
const div = container.querySelector("th > div");
|
||||
expect(div?.className).toContain("justify-center");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tooltip", () => {
|
||||
it("does not render an info tooltip when tooltip prop is absent", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "More information" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an info tooltip button when tooltip prop is provided", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
tooltip="Helpful hint"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "More information" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom className", () => {
|
||||
it("forwards className to the <th> element", () => {
|
||||
const { container } = renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={null}
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
className="w-48"
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("th")?.className).toContain("w-48");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user