diff --git a/apps/web/src/components/ui/ColumnTogglePanel.test.tsx b/apps/web/src/components/ui/ColumnTogglePanel.test.tsx new file mode 100644 index 0000000..5c24199 --- /dev/null +++ b/apps/web/src/components/ui/ColumnTogglePanel.test.tsx @@ -0,0 +1,239 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, within } from "~/test-utils.js"; +import userEvent from "@testing-library/user-event"; +import { ColumnTogglePanel } from "./ColumnTogglePanel.js"; +import type { ColumnDef } from "@capakraken/shared"; + +// Mock useAnchoredOverlay – in jsdom there is no real layout engine, so we +// just return stable refs and a no-op handleOpenChange. +vi.mock("~/hooks/useAnchoredOverlay.js", () => ({ + useAnchoredOverlay: ({ onClose }: { onClose: () => void }) => ({ + triggerRef: { current: null }, + panelRef: { current: null }, + position: { top: 0, left: 0 }, + handleOpenChange: (open: boolean) => { + if (!open) onClose(); + }, + }), +})); + +// ----------------------------------------------------------------------- +// Fixtures +// ----------------------------------------------------------------------- + +const builtinColumns: ColumnDef[] = [ + { key: "name", label: "Name", defaultVisible: true, hideable: false }, + { key: "role", label: "Role", defaultVisible: true, hideable: true }, + { key: "dept", label: "Dept", defaultVisible: false, hideable: true }, +]; + +const customColumns: ColumnDef[] = [ + { key: "cf_level", label: "Level", defaultVisible: true, hideable: true, isCustom: true }, +]; + +const allColumns: ColumnDef[] = [...builtinColumns, ...customColumns]; + +const defaultKeys = ["name", "role"]; +const visibleKeys = ["name", "role", "cf_level"]; + +function renderPanel(overrides: Partial> = {}) { + const onSetVisible = vi.fn(); + const utils = render( + , + ); + return { ...utils, onSetVisible }; +} + +// ----------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------- + +describe("ColumnTogglePanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("trigger button", () => { + it("renders the column-visibility toggle button", () => { + renderPanel(); + expect(screen.getByRole("button", { name: /column visibility/i })).toBeInTheDocument(); + }); + + it("panel is not visible before the button is clicked", () => { + renderPanel(); + expect(screen.queryByText("Columns")).not.toBeInTheDocument(); + }); + + it("opens the panel when the trigger button is clicked", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + expect(screen.getByText("Columns")).toBeInTheDocument(); + }); + + it("closes the panel when the trigger button is clicked again", async () => { + const user = userEvent.setup(); + renderPanel(); + const trigger = screen.getByRole("button", { name: /column visibility/i }); + await user.click(trigger); + await user.click(trigger); + expect(screen.queryByText("Columns")).not.toBeInTheDocument(); + }); + }); + + describe("panel content – builtin columns", () => { + async function openPanel() { + const user = userEvent.setup(); + const utils = renderPanel(); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + return { user, ...utils }; + } + + it("renders all builtin column labels inside the panel", async () => { + await openPanel(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Role")).toBeInTheDocument(); + expect(screen.getByText("Dept")).toBeInTheDocument(); + }); + + it("shows visible columns as checked", async () => { + await openPanel(); + // "name" and "role" are in visibleKeys + const roleCheckbox = screen.getByRole("checkbox", { name: /role/i }); + expect(roleCheckbox).toBeChecked(); + }); + + it("shows non-visible hideable columns as unchecked", async () => { + await openPanel(); + const deptCheckbox = screen.getByRole("checkbox", { name: /dept/i }); + expect(deptCheckbox).not.toBeChecked(); + }); + + it("disables the checkbox for non-hideable columns", async () => { + await openPanel(); + const nameCheckbox = screen.getByRole("checkbox", { name: /name/i }); + expect(nameCheckbox).toBeDisabled(); + }); + + it("enables the checkbox for hideable columns", async () => { + await openPanel(); + const roleCheckbox = screen.getByRole("checkbox", { name: /role/i }); + expect(roleCheckbox).not.toBeDisabled(); + }); + }); + + describe("panel content – custom columns", () => { + async function openPanel() { + const user = userEvent.setup(); + const utils = renderPanel(); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + return { user, ...utils }; + } + + it("renders the 'Custom Fields' section header", async () => { + await openPanel(); + expect(screen.getByText("Custom Fields")).toBeInTheDocument(); + }); + + it("renders custom column labels", async () => { + await openPanel(); + expect(screen.getByText("Level")).toBeInTheDocument(); + }); + + it("does not render 'Custom Fields' header when there are no custom columns", async () => { + const user = userEvent.setup(); + renderPanel({ allColumns: builtinColumns }); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + expect(screen.queryByText("Custom Fields")).not.toBeInTheDocument(); + }); + }); + + describe("toggling visibility", () => { + it("calls onSetVisible without a key when a visible hideable column is unchecked", async () => { + const user = userEvent.setup(); + const { onSetVisible } = renderPanel(); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + await user.click(screen.getByRole("checkbox", { name: /role/i })); + expect(onSetVisible).toHaveBeenCalledOnce(); + const [keys] = onSetVisible.mock.calls[0] as [string[]]; + expect(keys).not.toContain("role"); + }); + + it("calls onSetVisible with the key added when a hidden hideable column is checked", async () => { + const user = userEvent.setup(); + const { onSetVisible } = renderPanel(); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + await user.click(screen.getByRole("checkbox", { name: /dept/i })); + expect(onSetVisible).toHaveBeenCalledOnce(); + const [keys] = onSetVisible.mock.calls[0] as [string[]]; + expect(keys).toContain("dept"); + }); + + it("does NOT call onSetVisible when a non-hideable column checkbox is clicked", async () => { + const user = userEvent.setup(); + const { onSetVisible } = renderPanel(); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + // The non-hideable checkbox is disabled – userEvent won't fire onChange on disabled inputs. + const nameCheckbox = screen.getByRole("checkbox", { name: /name/i }); + await user.click(nameCheckbox); + expect(onSetVisible).not.toHaveBeenCalled(); + }); + }); + + describe("reset button", () => { + it("renders the Reset button inside the panel", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument(); + }); + + it("calls onSetVisible with defaultKeys when Reset is clicked", async () => { + const user = userEvent.setup(); + const { onSetVisible } = renderPanel(); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + await user.click(screen.getByRole("button", { name: /reset/i })); + expect(onSetVisible).toHaveBeenCalledWith(defaultKeys); + }); + }); + + describe("drag-and-drop reordering", () => { + it("calls onSetVisible with reordered keys after a drop onto a different row", async () => { + const user = userEvent.setup(); + const { onSetVisible } = renderPanel({ + visibleKeys: ["name", "role", "dept"], + allColumns: [ + { key: "name", label: "Name", defaultVisible: true, hideable: false }, + { key: "role", label: "Role", defaultVisible: true, hideable: true }, + { key: "dept", label: "Dept", defaultVisible: true, hideable: true }, + ], + }); + await user.click(screen.getByRole("button", { name: /column visibility/i })); + + // Simulate dragStart on "role" row and drop onto "dept" row using fireEvent + // (userEvent doesn't support drag-and-drop; we use direct fireEvent). + const { fireEvent } = await import("@testing-library/react"); + const rows = screen.getAllByRole("checkbox"); + // role is index 1, dept is index 2 + const roleRow = rows[1]!.closest("div[draggable]") as HTMLElement; + const deptRow = rows[2]!.closest("div[draggable]") as HTMLElement; + expect(roleRow).not.toBeNull(); + expect(deptRow).not.toBeNull(); + + fireEvent.dragStart(roleRow); + fireEvent.dragOver(deptRow); + fireEvent.drop(deptRow); + + expect(onSetVisible).toHaveBeenCalledOnce(); + const [keys] = onSetVisible.mock.calls[0] as [string[]]; + // After moving "role" onto "dept": ["name", "dept", "role"] + expect(keys.indexOf("dept")).toBeLessThan(keys.indexOf("role")); + }); + }); +}); diff --git a/apps/web/src/components/ui/DateRangePresets.test.tsx b/apps/web/src/components/ui/DateRangePresets.test.tsx new file mode 100644 index 0000000..d345e38 --- /dev/null +++ b/apps/web/src/components/ui/DateRangePresets.test.tsx @@ -0,0 +1,196 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "~/test-utils.js"; +import userEvent from "@testing-library/user-event"; +import { DateRangePresets } from "./DateRangePresets.js"; + +describe("DateRangePresets", () => { + describe("rendering", () => { + it("renders all four preset buttons", () => { + render(); + expect(screen.getByRole("button", { name: "This month" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "This quarter" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next 3 months" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "This year" })).toBeInTheDocument(); + }); + + it("all buttons have type=button to avoid accidental form submission", () => { + render(); + const buttons = screen.getAllByRole("button"); + for (const btn of buttons) { + expect(btn).toHaveAttribute("type", "button"); + } + }); + + it("applies a custom className to the wrapper", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("my-custom-class"); + }); + + it("renders without error when className is omitted", () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + }); + + describe("onSelect callback", () => { + it("calls onSelect with start and end date strings when 'This month' is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This month" })); + expect(onSelect).toHaveBeenCalledOnce(); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(start <= end).toBe(true); + }); + + it("calls onSelect with start and end date strings when 'This quarter' is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This quarter" })); + expect(onSelect).toHaveBeenCalledOnce(); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(start <= end).toBe(true); + }); + + it("calls onSelect with start and end date strings when 'Next 3 months' is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "Next 3 months" })); + expect(onSelect).toHaveBeenCalledOnce(); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(start <= end).toBe(true); + }); + + it("calls onSelect with start and end date strings when 'This year' is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This year" })); + expect(onSelect).toHaveBeenCalledOnce(); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(start <= end).toBe(true); + }); + + it("calls onSelect exactly once per click and not before any interaction", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + expect(onSelect).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "This year" })); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it("calls onSelect independently for each separate button click", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This month" })); + await user.click(screen.getByRole("button", { name: "This year" })); + expect(onSelect).toHaveBeenCalledTimes(2); + const firstCall = onSelect.mock.calls[0] as [string, string]; + const secondCall = onSelect.mock.calls[1] as [string, string]; + // "This month" start should differ from "This year" end + expect(firstCall[1]).not.toBe(secondCall[1]); + }); + }); + + describe("date correctness", () => { + // The component uses `new Date(y, m, d).toISOString().slice(0, 10)` which + // converts local-midnight dates to UTC. We compute expected values the same + // way so the tests are timezone-safe. + const toIso = (d: Date) => d.toISOString().slice(0, 10); + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date("2024-03-15T12:00:00")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("'This month' returns the first and last day of the current month", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This month" })); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toBe(toIso(new Date(2024, 2, 1))); + expect(end).toBe(toIso(new Date(2024, 3, 0))); + }); + + it("'This quarter' returns Q1 bounds for a March date", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This quarter" })); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toBe(toIso(new Date(2024, 0, 1))); + expect(end).toBe(toIso(new Date(2024, 3, 0))); + }); + + it("'This year' returns Jan 1 to Dec 31 of the current year", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This year" })); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toBe(toIso(new Date(2024, 0, 1))); + expect(end).toBe(toIso(new Date(2024, 11, 31))); + }); + + it("'Next 3 months' start equals today", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "Next 3 months" })); + const [start] = onSelect.mock.calls[0] as [string, string]; + expect(start).toBe(toIso(new Date(2024, 2, 15))); + }); + + it("'This quarter' returns Q2 bounds for an April date", async () => { + vi.setSystemTime(new Date("2024-04-10T12:00:00")); + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This quarter" })); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toBe(toIso(new Date(2024, 3, 1))); + expect(end).toBe(toIso(new Date(2024, 6, 0))); + }); + + it("'This quarter' returns Q3 bounds for a July date", async () => { + vi.setSystemTime(new Date("2024-07-01T12:00:00")); + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This quarter" })); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toBe(toIso(new Date(2024, 6, 1))); + expect(end).toBe(toIso(new Date(2024, 9, 0))); + }); + + it("'This quarter' returns Q4 bounds for an October date", async () => { + vi.setSystemTime(new Date("2024-10-20T12:00:00")); + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "This quarter" })); + const [start, end] = onSelect.mock.calls[0] as [string, string]; + expect(start).toBe(toIso(new Date(2024, 9, 1))); + expect(end).toBe(toIso(new Date(2024, 12, 0))); + }); + }); +}); diff --git a/apps/web/src/components/ui/ShimmerSkeleton.test.tsx b/apps/web/src/components/ui/ShimmerSkeleton.test.tsx new file mode 100644 index 0000000..a3282b0 --- /dev/null +++ b/apps/web/src/components/ui/ShimmerSkeleton.test.tsx @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "~/test-utils.js"; +import { ShimmerSkeleton, ShimmerGroup, ShimmerStyles } from "./ShimmerSkeleton.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Returns the single shimmer div rendered by ShimmerSkeleton. */ +function getSkeleton(container: HTMLElement) { + return container.firstChild as HTMLElement; +} + +// --------------------------------------------------------------------------- +// ShimmerSkeleton +// --------------------------------------------------------------------------- + +describe("ShimmerSkeleton", () => { + describe("variant defaults", () => { + it("applies the shimmer-skeleton base class", () => { + const { container } = render(); + expect(getSkeleton(container)).toHaveClass("shimmer-skeleton"); + }); + + it("defaults to the 'rect' variant: 100% wide, 40px tall, rounded-md", () => { + const { container } = render(); + const el = getSkeleton(container); + expect(el.style.width).toBe("100%"); + expect(el.style.height).toBe("40px"); + expect(el).toHaveClass("rounded-md"); + }); + + it("'text' variant uses 100% width, 1em height, rounded", () => { + const { container } = render(); + const el = getSkeleton(container); + expect(el.style.width).toBe("100%"); + expect(el.style.height).toBe("1em"); + expect(el).toHaveClass("rounded"); + }); + + it("'circle' variant uses 40px width, 40px height, rounded-full", () => { + const { container } = render(); + const el = getSkeleton(container); + expect(el.style.width).toBe("40px"); + expect(el.style.height).toBe("40px"); + expect(el).toHaveClass("rounded-full"); + }); + + it("'card' variant uses 100% width, 120px height, rounded-xl", () => { + const { container } = render(); + const el = getSkeleton(container); + expect(el.style.width).toBe("100%"); + expect(el.style.height).toBe("120px"); + expect(el).toHaveClass("rounded-xl"); + }); + }); + + describe("explicit width / height", () => { + it("accepts a numeric width and converts it to px", () => { + const { container } = render(); + expect(getSkeleton(container).style.width).toBe("200px"); + }); + + it("accepts a string width directly", () => { + const { container } = render(); + expect(getSkeleton(container).style.width).toBe("50%"); + }); + + it("accepts a numeric height and converts it to px", () => { + const { container } = render(); + expect(getSkeleton(container).style.height).toBe("80px"); + }); + + it("accepts a string height directly", () => { + const { container } = render(); + expect(getSkeleton(container).style.height).toBe("3rem"); + }); + + it("explicit width overrides the variant default", () => { + const { container } = render(); + expect(getSkeleton(container).style.width).toBe("64px"); + }); + + it("explicit height overrides the variant default", () => { + const { container } = render(); + expect(getSkeleton(container).style.height).toBe("200px"); + }); + }); + + describe("rounded prop", () => { + const roundedCases: Array<[React.ComponentProps["rounded"], string]> = [ + ["sm", "rounded-sm"], + ["md", "rounded-md"], + ["lg", "rounded-lg"], + ["xl", "rounded-xl"], + ["2xl", "rounded-2xl"], + ["full", "rounded-full"], + ]; + + for (const [rounded, expectedClass] of roundedCases) { + it(`rounded="${rounded}" applies class ${expectedClass}`, () => { + const { container } = render(); + expect(getSkeleton(container)).toHaveClass(expectedClass); + }); + } + + it("explicit rounded prop overrides the variant default", () => { + // 'circle' default is rounded-full; override with 'md' + const { container } = render(); + const el = getSkeleton(container); + expect(el).toHaveClass("rounded-md"); + expect(el).not.toHaveClass("rounded-full"); + }); + }); + + describe("className prop", () => { + it("appends extra className to the element", () => { + const { container } = render(); + const el = getSkeleton(container); + expect(el).toHaveClass("mt-4"); + expect(el).toHaveClass("w-1/2"); + }); + + it("preserves the shimmer-skeleton base class when className is provided", () => { + const { container } = render(); + expect(getSkeleton(container)).toHaveClass("shimmer-skeleton"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// ShimmerGroup +// --------------------------------------------------------------------------- + +/** Returns all shimmer divs that are direct children of the ShimmerGroup wrapper. */ +function getGroupChildren(container: HTMLElement): HTMLElement[] { + // ShimmerGroup renders a single wrapper div; its children are the skeleton divs. + const wrapper = container.firstChild as HTMLElement; + return Array.from(wrapper.children) as HTMLElement[]; +} + +describe("ShimmerGroup", () => { + it("renders all children", () => { + const { container } = render( + + + + + , + ); + expect(getGroupChildren(container)).toHaveLength(3); + }); + + it("injects animationDelay on each valid React child", () => { + // ShimmerGroup uses cloneElement to inject style.animationDelay. + // Use plain divs so the style prop is forwarded to the DOM. + const { container } = render( + +
+
+
+ , + ); + const children = getGroupChildren(container); + expect(["", "0ms"]).toContain(children[0]!.style.animationDelay); + expect(children[1]!.style.animationDelay).toBe("100ms"); + expect(children[2]!.style.animationDelay).toBe("200ms"); + }); + + it("uses 50ms as the default stagger delay", () => { + const { container } = render( + +
+
+ , + ); + const children = getGroupChildren(container); + expect(["", "0ms"]).toContain(children[0]!.style.animationDelay); + expect(children[1]!.style.animationDelay).toBe("50ms"); + }); + + it("applies a custom className to the wrapper div", () => { + const { container } = render( + + + , + ); + expect(container.firstChild).toHaveClass("grid"); + expect(container.firstChild).toHaveClass("grid-cols-3"); + }); + + it("passes through non-element children without crashing", () => { + // A text node is not a valid React element and should be returned as-is. + const { container } = render( + + {"plain text"} + + , + ); + expect(container).toHaveTextContent("plain text"); + // One skeleton element should still be present. + expect(container.querySelectorAll(".shimmer-skeleton")).toHaveLength(1); + }); + + it("preserves existing style properties when merged with animationDelay", () => { + // Use plain divs with explicit style so cloneElement merges correctly. + const { container } = render( + +
+
+ , + ); + const children = getGroupChildren(container); + expect(children[1]!.style.animationDelay).toBe("75ms"); + expect(children[0]!.style.width).toBe("120px"); + expect(children[0]!.style.height).toBe("30px"); + expect(children[1]!.style.width).toBe("120px"); + expect(children[1]!.style.height).toBe("30px"); + }); +}); + +// --------------------------------------------------------------------------- +// ShimmerStyles +// --------------------------------------------------------------------------- + +describe("ShimmerStyles", () => { + it("renders a