refactor(web): extract ResourcesClient types + inline components, fix test TS errors

Extract types.ts, FilterDropdown.tsx, BooleanBadge.tsx from
ResourcesClient.tsx into resource-client/ subdirectory.
ResourcesClient reduced from 1,613 to 1,507 lines.

Fix TypeScript strict mode errors across 8 test files:
- Add id/order to BlueprintFieldDefinition test objects
- Use FieldType enum instead of string literals in useFilters
- Add non-null assertions for mock.calls array access
- Type ScrollDiv for jsdom scrollLeft workaround
- Fix exactOptionalPropertyTypes violations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 22:40:24 +02:00
parent dcac9952ca
commit d3f721ce58
13 changed files with 217 additions and 162 deletions
+15 -14
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { FieldType } from "@capakraken/shared";
// ---------------------------------------------------------------------------
// Mock next/navigation — must be registered before the module under test is
@@ -33,12 +34,12 @@ function setSearchParams(entries: [string, string][]) {
function lastReplaceUrl(): string {
const calls = mockReplace.mock.calls;
return calls[calls.length - 1][0] as string;
return calls[calls.length - 1]![0] as string;
}
function lastReplaceOpts(): unknown {
const calls = mockReplace.mock.calls;
return calls[calls.length - 1][1];
return calls[calls.length - 1]![1];
}
// ---------------------------------------------------------------------------
@@ -130,11 +131,11 @@ describe("useFilters", () => {
it("parses a single custom field filter from cf_/cft_ params", () => {
setSearchParams([
["cf_rating", "5"],
["cft_rating", "NUMBER"],
["cft_rating", FieldType.NUMBER],
]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toEqual([
{ key: "rating", value: "5", type: "NUMBER" },
{ key: "rating", value: "5", type: FieldType.NUMBER },
]);
});
@@ -142,7 +143,7 @@ describe("useFilters", () => {
setSearchParams([["cf_tag", "hero"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toEqual([
{ key: "tag", value: "hero", type: "TEXT" },
{ key: "tag", value: "hero", type: FieldType.TEXT },
]);
});
@@ -151,7 +152,7 @@ describe("useFilters", () => {
["cf_dept", "vfx"],
["cft_dept", "SELECT"],
["cf_level", "senior"],
["cft_level", "TEXT"],
["cft_level", FieldType.TEXT],
]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(2);
@@ -163,7 +164,7 @@ describe("useFilters", () => {
it("excludes cft_ entries from customFieldFilters (only cf_ entries are rows)", () => {
setSearchParams([
["cf_x", "1"],
["cft_x", "NUMBER"],
["cft_x", FieldType.NUMBER],
]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(1);
@@ -172,7 +173,7 @@ describe("useFilters", () => {
it("skips custom field filter when the value is empty string", () => {
setSearchParams([
["cf_empty", ""],
["cft_empty", "TEXT"],
["cft_empty", FieldType.TEXT],
]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(0);
@@ -245,7 +246,7 @@ describe("useFilters", () => {
it("sets cf_ and cft_ params for a new custom field filter", () => {
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setCustomFieldFilter("score", "10", "NUMBER");
result.current.setCustomFieldFilter("score", "10", FieldType.NUMBER);
});
const url = lastReplaceUrl();
expect(url).toContain("cf_score=10");
@@ -255,11 +256,11 @@ describe("useFilters", () => {
it("removes cf_ and cft_ params when value is empty", () => {
setSearchParams([
["cf_score", "10"],
["cft_score", "NUMBER"],
["cft_score", FieldType.NUMBER],
]);
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setCustomFieldFilter("score", "", "NUMBER");
result.current.setCustomFieldFilter("score", "", FieldType.NUMBER);
});
const url = lastReplaceUrl();
expect(url).not.toContain("cf_score");
@@ -270,11 +271,11 @@ describe("useFilters", () => {
setSearchParams([
["search", "alice"],
["cf_score", "10"],
["cft_score", "NUMBER"],
["cft_score", FieldType.NUMBER],
]);
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setCustomFieldFilter("score", "", "NUMBER");
result.current.setCustomFieldFilter("score", "", FieldType.NUMBER);
});
const url = lastReplaceUrl();
expect(url).toContain("search=alice");
@@ -283,7 +284,7 @@ describe("useFilters", () => {
it("calls router.replace with scroll: false", () => {
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setCustomFieldFilter("tag", "hero", "TEXT");
result.current.setCustomFieldFilter("tag", "hero", FieldType.TEXT);
});
expect(lastReplaceOpts()).toEqual({ scroll: false });
});
+1 -1
View File
@@ -109,7 +109,7 @@ describe("useLocalStorage", () => {
});
it("handles object values", () => {
const { result } = renderHook(() => useLocalStorage("obj", { a: 1 }));
const { result } = renderHook(() => useLocalStorage<Record<string, number>>("obj", { a: 1 }));
act(() => {
result.current[1]({ a: 2, b: 3 });
@@ -52,6 +52,9 @@ function makeState(overrides: Partial<MultiSelectState> = {}): MultiSelectState
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
...overrides,
};
}
@@ -362,7 +365,7 @@ describe("useMultiSelectIntersection", () => {
});
expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState,
) => MultiSelectState;
const prevState = makeState();
@@ -414,7 +417,7 @@ describe("useMultiSelectIntersection", () => {
});
expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState,
) => MultiSelectState;
const nextState = updaterFn(makeState());
@@ -475,7 +478,7 @@ describe("useMultiSelectIntersection", () => {
});
expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState,
) => MultiSelectState;
const nextState = updaterFn(makeState());
@@ -523,7 +526,7 @@ describe("useMultiSelectIntersection", () => {
});
expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState,
) => MultiSelectState;
const nextState = updaterFn(makeState());
@@ -573,7 +576,7 @@ describe("useMultiSelectIntersection", () => {
});
expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState,
) => MultiSelectState;
const nextState = updaterFn(makeState());
@@ -628,7 +631,7 @@ describe("useMultiSelectIntersection", () => {
});
expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState,
) => MultiSelectState;
const nextState = updaterFn(makeState());
@@ -684,7 +687,7 @@ describe("useMultiSelectIntersection", () => {
});
expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState,
) => MultiSelectState;
const nextState = updaterFn(makeState());
@@ -15,15 +15,18 @@ function fireKeydown(options: KeyboardEventInit) {
// Helper: create a scroll container element with controllable scrollLeft
// ---------------------------------------------------------------------------
function makeScrollContainer(): HTMLDivElement {
const el = document.createElement("div");
type ScrollDiv = HTMLDivElement & { _scrollLeft: number };
function makeScrollContainer(): ScrollDiv {
const el = document.createElement("div") as ScrollDiv;
el._scrollLeft = 0;
// jsdom does not actually scroll, but we can track assignments
Object.defineProperty(el, "scrollLeft", {
get() {
return this._scrollLeft ?? 0;
return (this as ScrollDiv)._scrollLeft;
},
set(v: number) {
this._scrollLeft = v;
(this as ScrollDiv)._scrollLeft = v;
},
configurable: true,
});
@@ -53,7 +56,7 @@ function setWindowsPlatform() {
// ---------------------------------------------------------------------------
describe("useTimelineKeyboard", () => {
let scrollEl: HTMLDivElement;
let scrollEl: ScrollDiv;
let onDeleteSelected: ReturnType<typeof vi.fn>;
beforeEach(() => {
+9 -9
View File
@@ -111,7 +111,7 @@ describe("useViewPrefs", () => {
act(() => {
result.current.setSavedSort({ field: "age", dir: "asc" });
});
const written = JSON.parse(lsStub.setItem.mock.calls[0][1] as string) as unknown;
const written = JSON.parse(lsStub.setItem.mock.calls[0]![1] as string) as unknown;
expect(written).toMatchObject({ sort: { field: "age", dir: "asc" } });
});
@@ -121,7 +121,7 @@ describe("useViewPrefs", () => {
act(() => {
result.current.setSavedSort(null);
});
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
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");
});
@@ -195,7 +195,7 @@ describe("useViewPrefs", () => {
result.current.setRowOrder(["c", "a", "b"]);
});
const written = JSON.parse(
lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1][1] as string,
lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1]![1] as string,
) as Record<string, unknown>;
expect(written).toMatchObject({ rowOrder: ["c", "a", "b"] });
});
@@ -206,7 +206,7 @@ describe("useViewPrefs", () => {
act(() => {
result.current.setRowOrder([]);
});
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
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");
});
@@ -293,8 +293,8 @@ describe("useViewPrefs", () => {
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>;
const lastCall1 = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1]!;
const written = JSON.parse(lastCall1[1] as string) as Record<string, unknown>;
expect(written).toMatchObject({
sort: { field: "name", dir: "asc" },
rowOrder: ["b", "a"],
@@ -307,9 +307,9 @@ describe("useViewPrefs", () => {
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({
const lastCall2 = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1]!;
const written2 = JSON.parse(lastCall2[1] as string) as Record<string, unknown>;
expect(written2).toMatchObject({
sort: { field: "date", dir: "desc" },
rowOrder: ["x", "y"],
});