test(web): add 291 tests for parsers, hooks, and UI components
Lib utilities: scopeImportParser (31), status-styles (58), planningEntryIds (10), uuid (11). Hooks: useFilters (28), useRowOrder (18), usePermissions (30), useViewPrefs (24). Components: AnimatedModal (14), DateInput (22), InfoTooltip (13), ProgressRing (19). Web test suite: 75 → 87 files, 553 → 844 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock next/navigation — must be registered before the module under test is
|
||||
// imported so that the mock factory replaces the real module at resolve time.
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockReplace = vi.fn();
|
||||
let mockSearchParamsEntries: [string, string][] = [];
|
||||
let mockPathname = "/resources";
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
usePathname: () => mockPathname,
|
||||
useSearchParams: () => {
|
||||
const params = new URLSearchParams(
|
||||
mockSearchParamsEntries.map(([k, v]) => `${k}=${v}`).join("&"),
|
||||
);
|
||||
return params;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { useFilters } = await import("./useFilters.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function setSearchParams(entries: [string, string][]) {
|
||||
mockSearchParamsEntries = entries;
|
||||
}
|
||||
|
||||
function lastReplaceUrl(): string {
|
||||
const calls = mockReplace.mock.calls;
|
||||
return calls[calls.length - 1][0] as string;
|
||||
}
|
||||
|
||||
function lastReplaceOpts(): unknown {
|
||||
const calls = mockReplace.mock.calls;
|
||||
return calls[calls.length - 1][1];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("useFilters", () => {
|
||||
beforeEach(() => {
|
||||
mockReplace.mockReset();
|
||||
mockSearchParamsEntries = [];
|
||||
mockPathname = "/resources";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initial state derivation
|
||||
// -------------------------------------------------------------------------
|
||||
describe("initial state — no params", () => {
|
||||
it("returns empty string for search when not set", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.search).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for chapter when not set", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.chapter).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for status when not set", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.status).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty array for customFieldFilters when not set", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.customFieldFilters).toEqual([]);
|
||||
});
|
||||
|
||||
it("hasActiveFilters is false when nothing is set", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.hasActiveFilters).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial state — params already in URL", () => {
|
||||
it("reads search from the search param", () => {
|
||||
setSearchParams([["search", "alice"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.search).toBe("alice");
|
||||
});
|
||||
|
||||
it("reads chapter from the chapter param", () => {
|
||||
setSearchParams([["chapter", "vfx"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.chapter).toBe("vfx");
|
||||
});
|
||||
|
||||
it("reads status from the status param", () => {
|
||||
setSearchParams([["status", "ACTIVE"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.status).toBe("ACTIVE");
|
||||
});
|
||||
|
||||
it("hasActiveFilters is true when search is non-empty", () => {
|
||||
setSearchParams([["search", "bob"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.hasActiveFilters).toBe(true);
|
||||
});
|
||||
|
||||
it("hasActiveFilters is true when chapter is non-empty", () => {
|
||||
setSearchParams([["chapter", "rigging"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.hasActiveFilters).toBe(true);
|
||||
});
|
||||
|
||||
it("hasActiveFilters is true when status is non-empty", () => {
|
||||
setSearchParams([["status", "DRAFT"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.hasActiveFilters).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// customFieldFilters parsing
|
||||
// -------------------------------------------------------------------------
|
||||
describe("customFieldFilters parsing", () => {
|
||||
it("parses a single custom field filter from cf_/cft_ params", () => {
|
||||
setSearchParams([
|
||||
["cf_rating", "5"],
|
||||
["cft_rating", "NUMBER"],
|
||||
]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.customFieldFilters).toEqual([
|
||||
{ key: "rating", value: "5", type: "NUMBER" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults type to TEXT when cft_ param is absent", () => {
|
||||
setSearchParams([["cf_tag", "hero"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.customFieldFilters).toEqual([
|
||||
{ key: "tag", value: "hero", type: "TEXT" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple custom field filters", () => {
|
||||
setSearchParams([
|
||||
["cf_dept", "vfx"],
|
||||
["cft_dept", "SELECT"],
|
||||
["cf_level", "senior"],
|
||||
["cft_level", "TEXT"],
|
||||
]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.customFieldFilters).toHaveLength(2);
|
||||
const keys = result.current.customFieldFilters.map((f) => f.key);
|
||||
expect(keys).toContain("dept");
|
||||
expect(keys).toContain("level");
|
||||
});
|
||||
|
||||
it("excludes cft_ entries from customFieldFilters (only cf_ entries are rows)", () => {
|
||||
setSearchParams([
|
||||
["cf_x", "1"],
|
||||
["cft_x", "NUMBER"],
|
||||
]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.customFieldFilters).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips custom field filter when the value is empty string", () => {
|
||||
setSearchParams([
|
||||
["cf_empty", ""],
|
||||
["cft_empty", "TEXT"],
|
||||
]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.customFieldFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hasActiveFilters is true when customFieldFilters is non-empty", () => {
|
||||
setSearchParams([["cf_color", "red"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
expect(result.current.hasActiveFilters).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setFilter
|
||||
// -------------------------------------------------------------------------
|
||||
describe("setFilter", () => {
|
||||
it("calls router.replace with the updated param", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setFilter("search", "robot");
|
||||
});
|
||||
expect(mockReplace).toHaveBeenCalledOnce();
|
||||
const url = lastReplaceUrl();
|
||||
expect(url).toContain("search=robot");
|
||||
});
|
||||
|
||||
it("removes the param when value is empty string", () => {
|
||||
setSearchParams([["search", "robot"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setFilter("search", "");
|
||||
});
|
||||
const url = lastReplaceUrl();
|
||||
expect(url).not.toContain("search=");
|
||||
});
|
||||
|
||||
it("preserves existing unrelated params when setting a filter", () => {
|
||||
setSearchParams([["chapter", "vfx"]]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setFilter("search", "dragon");
|
||||
});
|
||||
const url = lastReplaceUrl();
|
||||
expect(url).toContain("chapter=vfx");
|
||||
expect(url).toContain("search=dragon");
|
||||
});
|
||||
|
||||
it("calls router.replace with scroll: false", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setFilter("status", "ACTIVE");
|
||||
});
|
||||
expect(lastReplaceOpts()).toEqual({ scroll: false });
|
||||
});
|
||||
|
||||
it("uses the current pathname in the replace URL", () => {
|
||||
mockPathname = "/projects";
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setFilter("search", "x");
|
||||
});
|
||||
expect(lastReplaceUrl()).toMatch(/^\/projects\?/);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setCustomFieldFilter
|
||||
// -------------------------------------------------------------------------
|
||||
describe("setCustomFieldFilter", () => {
|
||||
it("sets cf_ and cft_ params for a new custom field filter", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setCustomFieldFilter("score", "10", "NUMBER");
|
||||
});
|
||||
const url = lastReplaceUrl();
|
||||
expect(url).toContain("cf_score=10");
|
||||
expect(url).toContain("cft_score=NUMBER");
|
||||
});
|
||||
|
||||
it("removes cf_ and cft_ params when value is empty", () => {
|
||||
setSearchParams([
|
||||
["cf_score", "10"],
|
||||
["cft_score", "NUMBER"],
|
||||
]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setCustomFieldFilter("score", "", "NUMBER");
|
||||
});
|
||||
const url = lastReplaceUrl();
|
||||
expect(url).not.toContain("cf_score");
|
||||
expect(url).not.toContain("cft_score");
|
||||
});
|
||||
|
||||
it("preserves other params when clearing a custom field filter", () => {
|
||||
setSearchParams([
|
||||
["search", "alice"],
|
||||
["cf_score", "10"],
|
||||
["cft_score", "NUMBER"],
|
||||
]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setCustomFieldFilter("score", "", "NUMBER");
|
||||
});
|
||||
const url = lastReplaceUrl();
|
||||
expect(url).toContain("search=alice");
|
||||
});
|
||||
|
||||
it("calls router.replace with scroll: false", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.setCustomFieldFilter("tag", "hero", "TEXT");
|
||||
});
|
||||
expect(lastReplaceOpts()).toEqual({ scroll: false });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// clearFilters
|
||||
// -------------------------------------------------------------------------
|
||||
describe("clearFilters", () => {
|
||||
it("calls router.replace with just the pathname (no query string)", () => {
|
||||
setSearchParams([
|
||||
["search", "alice"],
|
||||
["chapter", "vfx"],
|
||||
]);
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.clearFilters();
|
||||
});
|
||||
expect(lastReplaceUrl()).toBe(mockPathname);
|
||||
});
|
||||
|
||||
it("calls router.replace with scroll: false when clearing", () => {
|
||||
const { result } = renderHook(() => useFilters());
|
||||
act(() => {
|
||||
result.current.clearFilters();
|
||||
});
|
||||
expect(lastReplaceOpts()).toEqual({ scroll: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock next-auth/react so the hook can run without a real auth provider.
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockUseSession = vi.fn();
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: () => mockUseSession(),
|
||||
}));
|
||||
|
||||
const { usePermissions } = await import("./usePermissions.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build a mock session object with the given role.
|
||||
// ---------------------------------------------------------------------------
|
||||
function sessionWith(role: string) {
|
||||
return { data: { user: { role } } };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("usePermissions", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSession.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// No session / unauthenticated
|
||||
// -------------------------------------------------------------------------
|
||||
describe("when there is no session", () => {
|
||||
it("falls back to role USER when data is null", () => {
|
||||
mockUseSession.mockReturnValue({ data: null });
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.role).toBe("USER");
|
||||
});
|
||||
|
||||
it("falls back to role USER when data is undefined", () => {
|
||||
mockUseSession.mockReturnValue({ data: undefined });
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.role).toBe("USER");
|
||||
});
|
||||
|
||||
it("falls back to role USER when user has no role property", () => {
|
||||
mockUseSession.mockReturnValue({ data: { user: {} } });
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.role).toBe("USER");
|
||||
});
|
||||
|
||||
it("denies all privileged permissions for anonymous user", () => {
|
||||
mockUseSession.mockReturnValue({ data: null });
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewCosts).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
expect(result.current.canManageUsers).toBe(false);
|
||||
expect(result.current.canManageBlueprints).toBe(false);
|
||||
expect(result.current.canViewScores).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ADMIN role
|
||||
// -------------------------------------------------------------------------
|
||||
describe("ADMIN role", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSession.mockReturnValue(sessionWith("ADMIN"));
|
||||
});
|
||||
|
||||
it("exposes role as ADMIN", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.role).toBe("ADMIN");
|
||||
});
|
||||
|
||||
it("canViewCosts is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewCosts).toBe(true);
|
||||
});
|
||||
|
||||
it("canEdit is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canEdit).toBe(true);
|
||||
});
|
||||
|
||||
it("canManageUsers is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canManageUsers).toBe(true);
|
||||
});
|
||||
|
||||
it("canManageBlueprints is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canManageBlueprints).toBe(true);
|
||||
});
|
||||
|
||||
it("canViewScores is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewScores).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MANAGER role
|
||||
// -------------------------------------------------------------------------
|
||||
describe("MANAGER role", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSession.mockReturnValue(sessionWith("MANAGER"));
|
||||
});
|
||||
|
||||
it("exposes role as MANAGER", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.role).toBe("MANAGER");
|
||||
});
|
||||
|
||||
it("canViewCosts is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewCosts).toBe(true);
|
||||
});
|
||||
|
||||
it("canEdit is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canEdit).toBe(true);
|
||||
});
|
||||
|
||||
it("canManageUsers is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canManageUsers).toBe(false);
|
||||
});
|
||||
|
||||
it("canManageBlueprints is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canManageBlueprints).toBe(false);
|
||||
});
|
||||
|
||||
it("canViewScores is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewScores).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CONTROLLER role
|
||||
// -------------------------------------------------------------------------
|
||||
describe("CONTROLLER role", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSession.mockReturnValue(sessionWith("CONTROLLER"));
|
||||
});
|
||||
|
||||
it("exposes role as CONTROLLER", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.role).toBe("CONTROLLER");
|
||||
});
|
||||
|
||||
it("canViewCosts is true", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewCosts).toBe(true);
|
||||
});
|
||||
|
||||
it("canEdit is false (CONTROLLER is not in EDIT_ROLES)", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it("canManageUsers is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canManageUsers).toBe(false);
|
||||
});
|
||||
|
||||
it("canManageBlueprints is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canManageBlueprints).toBe(false);
|
||||
});
|
||||
|
||||
it("canViewScores is false (CONTROLLER is not in SCORE_ROLES)", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewScores).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// USER role (default / authenticated but unprivileged)
|
||||
// -------------------------------------------------------------------------
|
||||
describe("USER role", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSession.mockReturnValue(sessionWith("USER"));
|
||||
});
|
||||
|
||||
it("exposes role as USER", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.role).toBe("USER");
|
||||
});
|
||||
|
||||
it("canViewCosts is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewCosts).toBe(false);
|
||||
});
|
||||
|
||||
it("canEdit is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it("canManageUsers is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canManageUsers).toBe(false);
|
||||
});
|
||||
|
||||
it("canManageBlueprints is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canManageBlueprints).toBe(false);
|
||||
});
|
||||
|
||||
it("canViewScores is false", () => {
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewScores).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unknown / arbitrary role
|
||||
// -------------------------------------------------------------------------
|
||||
describe("unknown role", () => {
|
||||
it("denies all privileged permissions for an unknown role string", () => {
|
||||
mockUseSession.mockReturnValue(sessionWith("SUPER_DUPER_ADMIN"));
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canViewCosts).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
expect(result.current.canManageUsers).toBe(false);
|
||||
expect(result.current.canManageBlueprints).toBe(false);
|
||||
expect(result.current.canViewScores).toBe(false);
|
||||
});
|
||||
|
||||
it("still surfaces the raw role string for arbitrary roles", () => {
|
||||
mockUseSession.mockReturnValue(sessionWith("VIEWER"));
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.role).toBe("VIEWER");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useRowOrder } from "./useRowOrder.js";
|
||||
import type { ViewPrefsHandle } from "./useViewPrefs.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a minimal ViewPrefsHandle stub for testing. */
|
||||
function makePrefs(
|
||||
rowOrder: string[] = [],
|
||||
setRowOrder = vi.fn(),
|
||||
): Pick<ViewPrefsHandle, "rowOrder" | "setRowOrder"> {
|
||||
return { rowOrder, setRowOrder };
|
||||
}
|
||||
|
||||
type Row = { id: string; label: string };
|
||||
|
||||
function makeRows(ids: string[]): Row[] {
|
||||
return ids.map((id) => ({ id, label: `Label-${id}` }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("useRowOrder", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// orderedRows — sorting logic
|
||||
// -------------------------------------------------------------------------
|
||||
describe("orderedRows", () => {
|
||||
it("returns rows unchanged when activeSortField is set", () => {
|
||||
const rows = makeRows(["b", "a", "c"]);
|
||||
const prefs = makePrefs(["a", "b", "c"]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, "name", vi.fn()));
|
||||
// Even though rowOrder says a→b→c, the active sort must suppress it
|
||||
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("returns rows unchanged when rowOrder is empty", () => {
|
||||
const rows = makeRows(["b", "a", "c"]);
|
||||
const prefs = makePrefs([]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("sorts rows according to rowOrder when no sort is active", () => {
|
||||
const rows = makeRows(["b", "a", "c"]);
|
||||
const prefs = makePrefs(["a", "b", "c"]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("places rows missing from rowOrder at the end", () => {
|
||||
const rows = makeRows(["x", "a", "b", "y"]);
|
||||
// Only a and b have explicit positions; x and y go to the end
|
||||
const prefs = makePrefs(["a", "b"]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
const ids = result.current.orderedRows.map((r) => r.id);
|
||||
expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("x"));
|
||||
expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("x"));
|
||||
});
|
||||
|
||||
it("handles a rowOrder that is a partial subset of rows", () => {
|
||||
const rows = makeRows(["c", "a", "b"]);
|
||||
const prefs = makePrefs(["b", "c"]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
const ids = result.current.orderedRows.map((r) => r.id);
|
||||
expect(ids[0]).toBe("b");
|
||||
expect(ids[1]).toBe("c");
|
||||
// "a" has no saved position → appended after
|
||||
expect(ids[2]).toBe("a");
|
||||
});
|
||||
|
||||
it("does not mutate the original rows array", () => {
|
||||
const rows = makeRows(["c", "a", "b"]);
|
||||
const originalRef = rows;
|
||||
const prefs = makePrefs(["a", "b", "c"]);
|
||||
renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
expect(rows).toBe(originalRef);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// isCustomOrder
|
||||
// -------------------------------------------------------------------------
|
||||
describe("isCustomOrder", () => {
|
||||
it("is false when rowOrder is empty", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useRowOrder(makeRows(["a"]), makePrefs([]), null, vi.fn()),
|
||||
);
|
||||
expect(result.current.isCustomOrder).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when activeSortField is set (even if rowOrder is non-empty)", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useRowOrder(makeRows(["a", "b"]), makePrefs(["b", "a"]), "name", vi.fn()),
|
||||
);
|
||||
expect(result.current.isCustomOrder).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when rowOrder is non-empty and no sort is active", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useRowOrder(makeRows(["a", "b"]), makePrefs(["b", "a"]), null, vi.fn()),
|
||||
);
|
||||
expect(result.current.isCustomOrder).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// reorder
|
||||
// -------------------------------------------------------------------------
|
||||
describe("reorder", () => {
|
||||
it("calls setRowOrder with the reordered ID list", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("c", "a");
|
||||
});
|
||||
|
||||
// "c" dragged to position of "a": result should be [c, a, b]
|
||||
expect(setRowOrder).toHaveBeenCalledWith(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("calls resetSort to clear any active column sort", () => {
|
||||
const resetSort = vi.fn();
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs([], vi.fn());
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, resetSort));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("b", "a");
|
||||
});
|
||||
|
||||
expect(resetSort).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("is a no-op when draggedId equals targetId", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const resetSort = vi.fn();
|
||||
const rows = makeRows(["a", "b"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, resetSort));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("a", "a");
|
||||
});
|
||||
|
||||
expect(setRowOrder).not.toHaveBeenCalled();
|
||||
expect(resetSort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when draggedId is not found in current rows", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("unknown", "a");
|
||||
});
|
||||
|
||||
expect(setRowOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when targetId is not found in current rows", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("a", "unknown");
|
||||
});
|
||||
|
||||
expect(setRowOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves an item from the end to the beginning", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("c", "a");
|
||||
});
|
||||
|
||||
expect(setRowOrder).toHaveBeenCalledWith(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("moves an item from the beginning to the end", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("a", "c");
|
||||
});
|
||||
|
||||
expect(setRowOrder).toHaveBeenCalledWith(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("reorders correctly when a custom rowOrder is already applied", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
// Existing saved order: b, a, c → orderedRows will be [b, a, c]
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs(["b", "a", "c"], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
// Drag "c" (last) to the position of "b" (first)
|
||||
act(() => {
|
||||
result.current.reorder("c", "b");
|
||||
});
|
||||
|
||||
expect(setRowOrder).toHaveBeenCalledWith(["c", "b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// resetOrder
|
||||
// -------------------------------------------------------------------------
|
||||
describe("resetOrder", () => {
|
||||
it("calls setRowOrder with an empty array", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b"]);
|
||||
const prefs = makePrefs(["b", "a"], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.resetOrder();
|
||||
});
|
||||
|
||||
expect(setRowOrder).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the tRPC client — must be registered before the module is imported.
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockMutate = vi.fn();
|
||||
|
||||
vi.mock("~/lib/trpc/client.js", () => ({
|
||||
trpc: {
|
||||
user: {
|
||||
setColumnPreferences: {
|
||||
useMutation: () => ({ mutate: mockMutate }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { useViewPrefs } = await import("./useViewPrefs.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage stub — isolate tests from real browser storage
|
||||
// ---------------------------------------------------------------------------
|
||||
function createLocalStorageStub() {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("useViewPrefs", () => {
|
||||
let lsStub: ReturnType<typeof createLocalStorageStub>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
lsStub = createLocalStorageStub();
|
||||
vi.spyOn(window, "localStorage", "get").mockReturnValue(lsStub as unknown as Storage);
|
||||
mockMutate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// -------------------------------------------------------------------------
|
||||
describe("initial state", () => {
|
||||
it("savedSort is null when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.savedSort).toBeNull();
|
||||
});
|
||||
|
||||
it("rowOrder is an empty array when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.rowOrder).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads savedSort from localStorage on mount", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.savedSort).toEqual({ field: "name", dir: "asc" });
|
||||
});
|
||||
|
||||
it("reads rowOrder from localStorage on mount", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["b", "a", "c"] }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.rowOrder).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("reads from the correct localStorage key for the view", () => {
|
||||
renderHook(() => useViewPrefs("projects"));
|
||||
expect(lsStub.getItem).toHaveBeenCalledWith("viewprefs_projects");
|
||||
});
|
||||
|
||||
it("handles malformed JSON in localStorage gracefully", () => {
|
||||
lsStub.getItem.mockReturnValue("{broken json{{");
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.savedSort).toBeNull();
|
||||
expect(result.current.rowOrder).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setSavedSort
|
||||
// -------------------------------------------------------------------------
|
||||
describe("setSavedSort", () => {
|
||||
it("updates savedSort in state immediately", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "age", dir: "desc" });
|
||||
});
|
||||
expect(result.current.savedSort).toEqual({ field: "age", dir: "desc" });
|
||||
});
|
||||
|
||||
it("persists the sort to localStorage", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "age", dir: "asc" });
|
||||
});
|
||||
const written = JSON.parse(lsStub.setItem.mock.calls[0][1] as string) as unknown;
|
||||
expect(written).toMatchObject({ sort: { field: "age", dir: "asc" } });
|
||||
});
|
||||
|
||||
it("removes the sort key from localStorage when set to null", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort(null);
|
||||
});
|
||||
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
|
||||
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
|
||||
expect(written).not.toHaveProperty("sort");
|
||||
});
|
||||
|
||||
it("does not call the server mutation before the debounce delay", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "name", dir: "asc" });
|
||||
});
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the server mutation after the 600 ms debounce fires", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "name", dir: "asc" });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view: "resources", sort: { field: "name", dir: "asc" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("debounces multiple rapid calls — only fires once after the last call", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "name", dir: "asc" });
|
||||
result.current.setSavedSort({ field: "age", dir: "desc" });
|
||||
result.current.setSavedSort({ field: "status", dir: "asc" });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledOnce();
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sort: { field: "status", dir: "asc" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends null sort to the server mutation when cleared", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort(null);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view: "resources", sort: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setRowOrder
|
||||
// -------------------------------------------------------------------------
|
||||
describe("setRowOrder", () => {
|
||||
it("updates rowOrder in state immediately", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["c", "a", "b"]);
|
||||
});
|
||||
expect(result.current.rowOrder).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("persists the rowOrder to localStorage", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["c", "a", "b"]);
|
||||
});
|
||||
const written = JSON.parse(
|
||||
lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1][1] as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(written).toMatchObject({ rowOrder: ["c", "a", "b"] });
|
||||
});
|
||||
|
||||
it("removes the rowOrder key from localStorage when set to an empty array", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["a", "b"] }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder([]);
|
||||
});
|
||||
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
|
||||
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
|
||||
expect(written).not.toHaveProperty("rowOrder");
|
||||
});
|
||||
|
||||
it("does not call the server mutation before the debounce delay", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["a", "b"]);
|
||||
});
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the server mutation after the 600 ms debounce fires", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["a", "b"]);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view: "resources", rowOrder: ["a", "b"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends null rowOrder to the server mutation when the array is empty", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder([]);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view: "resources", rowOrder: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("debounces multiple rapid drag reorders into a single mutation", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["a", "b", "c"]);
|
||||
result.current.setRowOrder(["b", "a", "c"]);
|
||||
result.current.setRowOrder(["c", "b", "a"]);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledOnce();
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rowOrder: ["c", "b", "a"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// localStorage key isolation per view
|
||||
// -------------------------------------------------------------------------
|
||||
describe("localStorage isolation between views", () => {
|
||||
it("writes to the correct key for 'projects' view", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("projects"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "name", dir: "asc" });
|
||||
});
|
||||
expect(lsStub.setItem).toHaveBeenCalledWith("viewprefs_projects", expect.any(String));
|
||||
});
|
||||
|
||||
it("writes to the correct key for 'users' view", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("users"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["1", "2"]);
|
||||
});
|
||||
expect(lsStub.setItem).toHaveBeenCalledWith("viewprefs_users", expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Interaction: sort and rowOrder coexist in localStorage
|
||||
// -------------------------------------------------------------------------
|
||||
describe("combined sort + rowOrder state", () => {
|
||||
it("preserves existing sort when rowOrder is updated", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["b", "a"]);
|
||||
});
|
||||
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
|
||||
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
|
||||
expect(written).toMatchObject({
|
||||
sort: { field: "name", dir: "asc" },
|
||||
rowOrder: ["b", "a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing rowOrder when sort is updated", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["x", "y"] }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "date", dir: "desc" });
|
||||
});
|
||||
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
|
||||
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
|
||||
expect(written).toMatchObject({
|
||||
sort: { field: "date", dir: "desc" },
|
||||
rowOrder: ["x", "y"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user