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
@@ -1,7 +1,6 @@
"use client"; "use client";
import { createPortal } from "react-dom"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from "react";
import { useUrlFilters } from "~/hooks/useUrlFilters.js"; import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js"; import { useDebounce } from "~/hooks/useDebounce.js";
import Link from "next/link"; import Link from "next/link";
@@ -35,118 +34,21 @@ import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js"; import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js"; import { useRowOrder } from "~/hooks/useRowOrder.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js"; import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js";
import {
type ModalState = type ModalState,
| { type: "closed" } type ConfirmState,
| { type: "create" } type ActiveFilter,
| { type: "edit"; resource: Resource } type BooleanFilter,
| { type: "import" } type ResourceListPage,
| { type: "bulkEdit" }; type CountryOption,
DEFAULT_HIDDEN_RESOURCE_TYPES,
type ConfirmState = DEFAULT_BOOLEAN_FILTER,
| { type: "closed" } RESOURCE_TYPE_LABELS,
| { type: "batchDeactivate"; ids: string[] } } from "./resource-client/types.js";
| { type: "deactivate"; resource: Resource } import { FilterDropdown } from "./resource-client/FilterDropdown.js";
| { type: "delete"; resource: Resource } import { BooleanBadge } from "./resource-client/BooleanBadge.js";
| { type: "batchDelete"; ids: string[] };
type ActiveFilter = "active" | "inactive" | "all";
type BooleanFilter = "all" | "yes" | "no";
type ResourceListPage = {
resources: Resource[];
total: number;
nextCursor?: string | null;
};
type CountryOption = {
id: string;
name: string;
};
const DEFAULT_HIDDEN_RESOURCE_TYPES = [ResourceType.FREELANCER] as const;
const DEFAULT_BOOLEAN_FILTER: BooleanFilter = "no";
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
[ResourceType.EMPLOYEE]: "Employee",
[ResourceType.FREELANCER]: "Freelancer",
[ResourceType.APPRENTICE]: "Apprentice",
[ResourceType.INTERN]: "Intern",
[ResourceType.STUDENT]: "Student",
};
function FilterDropdown({
label,
children,
widthClassName = "w-80",
buttonClassName = "min-w-56",
tooltipContent,
}: {
label: string;
children: ReactNode;
widthClassName?: string;
buttonClassName?: string;
tooltipContent?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
open: isOpen,
onClose: () => setIsOpen(false),
matchTriggerWidth: true,
});
return (
<div ref={triggerRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const nextOpen = !isOpen;
handleOpenChange(nextOpen);
setIsOpen(nextOpen);
}}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{isOpen ? "▲" : "▼"}</span>
</button>
{tooltipContent ? <InfoTooltip content={tooltipContent} width="w-72" /> : null}
</div>
{isOpen &&
createPortal(
<div
ref={panelRef}
style={{
position: "fixed",
top: position.top,
left: position.left,
minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
{children}
</div>,
document.body,
)}
</div>
);
}
function BooleanBadge({ value }: { value: boolean }) {
return (
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
value
? "bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
}`}
>
{value ? "Yes" : "No"}
</span>
);
}
export function ResourcesClient() { export function ResourcesClient() {
const [resourceUrlFilters, setResourceUrlFilters] = useUrlFilters({ const [resourceUrlFilters, setResourceUrlFilters] = useUrlFilters({
@@ -0,0 +1,15 @@
"use client";
export function BooleanBadge({ value }: { value: boolean }) {
return (
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
value
? "bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
}`}
>
{value ? "Yes" : "No"}
</span>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
export function FilterDropdown({
label,
children,
widthClassName = "w-80",
buttonClassName = "min-w-56",
tooltipContent,
}: {
label: string;
children: ReactNode;
widthClassName?: string;
buttonClassName?: string;
tooltipContent?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
open: isOpen,
onClose: () => setIsOpen(false),
matchTriggerWidth: true,
});
return (
<div ref={triggerRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const nextOpen = !isOpen;
handleOpenChange(nextOpen);
setIsOpen(nextOpen);
}}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{isOpen ? "▲" : "▼"}</span>
</button>
{tooltipContent ? <InfoTooltip content={tooltipContent} width="w-72" /> : null}
</div>
{isOpen &&
createPortal(
<div
ref={panelRef}
style={{
position: "fixed",
top: position.top,
left: position.left,
minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
{children}
</div>,
document.body,
)}
</div>
);
}
@@ -0,0 +1,40 @@
import type { Resource } from "@capakraken/shared";
import { ResourceType } from "@capakraken/shared";
export type ModalState =
| { type: "closed" }
| { type: "create" }
| { type: "edit"; resource: Resource }
| { type: "import" }
| { type: "bulkEdit" };
export type ConfirmState =
| { type: "closed" }
| { type: "batchDeactivate"; ids: string[] }
| { type: "deactivate"; resource: Resource }
| { type: "delete"; resource: Resource }
| { type: "batchDelete"; ids: string[] };
export type ActiveFilter = "active" | "inactive" | "all";
export type BooleanFilter = "all" | "yes" | "no";
export type ResourceListPage = {
resources: Resource[];
total: number;
nextCursor?: string | null;
};
export type CountryOption = {
id: string;
name: string;
};
export const DEFAULT_HIDDEN_RESOURCE_TYPES = [ResourceType.FREELANCER] as const;
export const DEFAULT_BOOLEAN_FILTER: BooleanFilter = "no";
export const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
[ResourceType.EMPLOYEE]: "Employee",
[ResourceType.FREELANCER]: "Freelancer",
[ResourceType.APPRENTICE]: "Apprentice",
[ResourceType.INTERN]: "Intern",
[ResourceType.STUDENT]: "Student",
};
@@ -41,7 +41,14 @@ describe("canGoNext", () => {
shortCode: "PRJ01", shortCode: "PRJ01",
name: "Test", name: "Test",
blueprintFieldDefs: [ blueprintFieldDefs: [
{ key: "field1", type: "TEXT" as never, label: "Field 1", required: true }, {
id: "f1",
key: "field1",
type: "TEXT" as never,
label: "Field 1",
required: true,
order: 0,
},
], ],
dynamicFields: {}, dynamicFields: {},
}); });
@@ -53,7 +60,14 @@ describe("canGoNext", () => {
shortCode: "PRJ01", shortCode: "PRJ01",
name: "Test", name: "Test",
blueprintFieldDefs: [ blueprintFieldDefs: [
{ key: "field1", type: "TEXT" as never, label: "Field 1", required: true }, {
id: "f1",
key: "field1",
type: "TEXT" as never,
label: "Field 1",
required: true,
order: 0,
},
], ],
dynamicFields: { field1: "value" }, dynamicFields: { field1: "value" },
}); });
@@ -65,7 +79,14 @@ describe("canGoNext", () => {
shortCode: "PRJ01", shortCode: "PRJ01",
name: "Test", name: "Test",
blueprintFieldDefs: [ blueprintFieldDefs: [
{ key: "tags", type: "TAG_LIST" as never, label: "Tags", required: true }, {
id: "f2",
key: "tags",
type: "TAG_LIST" as never,
label: "Tags",
required: true,
order: 0,
},
], ],
dynamicFields: { tags: [] }, dynamicFields: { tags: [] },
}); });
@@ -77,7 +98,14 @@ describe("canGoNext", () => {
shortCode: "PRJ01", shortCode: "PRJ01",
name: "Test", name: "Test",
blueprintFieldDefs: [ blueprintFieldDefs: [
{ key: "optional", type: "TEXT" as never, label: "Optional", required: false }, {
id: "f3",
key: "optional",
type: "TEXT" as never,
label: "Optional",
required: false,
order: 0,
},
], ],
dynamicFields: {}, dynamicFields: {},
}); });
@@ -153,7 +181,7 @@ describe("canGoNext", () => {
}); });
it("returns false when a requirement has empty role and no roleId", () => { it("returns false when a requirement has empty role and no roleId", () => {
const req = { ...makeReq(), role: "", roleId: undefined, hoursPerDay: 8, headcount: 1 }; const req = { ...makeReq(), role: "", hoursPerDay: 8, headcount: 1 };
const state = stateWith({ staffingReqs: [req] }); const state = stateWith({ staffingReqs: [req] });
expect(canGoNext(2, state)).toBe(false); expect(canGoNext(2, state)).toBe(false);
}); });
@@ -1,7 +1,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "~/test-utils.js"; import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { ErrorInfo } from "react"; import React, { type ErrorInfo } from "react";
import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js"; import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js";
// Suppress React's console.error output during error boundary tests // Suppress React's console.error output during error boundary tests
@@ -16,7 +16,7 @@ afterEach(() => {
}); });
// A component that unconditionally throws // A component that unconditionally throws
function ThrowingChild({ message = "Test error" }: { message?: string }) { function ThrowingChild({ message = "Test error" }: { message?: string }): React.ReactNode {
throw new Error(message); throw new Error(message);
} }
@@ -131,7 +131,7 @@ describe("ErrorBoundary", () => {
}); });
it("shows a generic message when error.message is empty", () => { it("shows a generic message when error.message is empty", () => {
function ThrowEmptyMessage() { function ThrowEmptyMessage(): React.ReactNode {
const e = new Error(""); const e = new Error("");
throw e; throw e;
} }
@@ -88,7 +88,9 @@ describe("ShimmerSkeleton", () => {
}); });
describe("rounded prop", () => { describe("rounded prop", () => {
const roundedCases: Array<[React.ComponentProps<typeof ShimmerSkeleton>["rounded"], string]> = [ const roundedCases: Array<
[NonNullable<React.ComponentProps<typeof ShimmerSkeleton>["rounded"]>, string]
> = [
["sm", "rounded-sm"], ["sm", "rounded-sm"],
["md", "rounded-md"], ["md", "rounded-md"],
["lg", "rounded-lg"], ["lg", "rounded-lg"],
+15 -14
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react"; import { renderHook, act } from "@testing-library/react";
import { FieldType } from "@capakraken/shared";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock next/navigation — must be registered before the module under test is // Mock next/navigation — must be registered before the module under test is
@@ -33,12 +34,12 @@ function setSearchParams(entries: [string, string][]) {
function lastReplaceUrl(): string { function lastReplaceUrl(): string {
const calls = mockReplace.mock.calls; const calls = mockReplace.mock.calls;
return calls[calls.length - 1][0] as string; return calls[calls.length - 1]![0] as string;
} }
function lastReplaceOpts(): unknown { function lastReplaceOpts(): unknown {
const calls = mockReplace.mock.calls; 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", () => { it("parses a single custom field filter from cf_/cft_ params", () => {
setSearchParams([ setSearchParams([
["cf_rating", "5"], ["cf_rating", "5"],
["cft_rating", "NUMBER"], ["cft_rating", FieldType.NUMBER],
]); ]);
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toEqual([ 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"]]); setSearchParams([["cf_tag", "hero"]]);
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toEqual([ 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"], ["cf_dept", "vfx"],
["cft_dept", "SELECT"], ["cft_dept", "SELECT"],
["cf_level", "senior"], ["cf_level", "senior"],
["cft_level", "TEXT"], ["cft_level", FieldType.TEXT],
]); ]);
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(2); expect(result.current.customFieldFilters).toHaveLength(2);
@@ -163,7 +164,7 @@ describe("useFilters", () => {
it("excludes cft_ entries from customFieldFilters (only cf_ entries are rows)", () => { it("excludes cft_ entries from customFieldFilters (only cf_ entries are rows)", () => {
setSearchParams([ setSearchParams([
["cf_x", "1"], ["cf_x", "1"],
["cft_x", "NUMBER"], ["cft_x", FieldType.NUMBER],
]); ]);
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(1); expect(result.current.customFieldFilters).toHaveLength(1);
@@ -172,7 +173,7 @@ describe("useFilters", () => {
it("skips custom field filter when the value is empty string", () => { it("skips custom field filter when the value is empty string", () => {
setSearchParams([ setSearchParams([
["cf_empty", ""], ["cf_empty", ""],
["cft_empty", "TEXT"], ["cft_empty", FieldType.TEXT],
]); ]);
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(0); expect(result.current.customFieldFilters).toHaveLength(0);
@@ -245,7 +246,7 @@ describe("useFilters", () => {
it("sets cf_ and cft_ params for a new custom field filter", () => { it("sets cf_ and cft_ params for a new custom field filter", () => {
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
act(() => { act(() => {
result.current.setCustomFieldFilter("score", "10", "NUMBER"); result.current.setCustomFieldFilter("score", "10", FieldType.NUMBER);
}); });
const url = lastReplaceUrl(); const url = lastReplaceUrl();
expect(url).toContain("cf_score=10"); expect(url).toContain("cf_score=10");
@@ -255,11 +256,11 @@ describe("useFilters", () => {
it("removes cf_ and cft_ params when value is empty", () => { it("removes cf_ and cft_ params when value is empty", () => {
setSearchParams([ setSearchParams([
["cf_score", "10"], ["cf_score", "10"],
["cft_score", "NUMBER"], ["cft_score", FieldType.NUMBER],
]); ]);
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
act(() => { act(() => {
result.current.setCustomFieldFilter("score", "", "NUMBER"); result.current.setCustomFieldFilter("score", "", FieldType.NUMBER);
}); });
const url = lastReplaceUrl(); const url = lastReplaceUrl();
expect(url).not.toContain("cf_score"); expect(url).not.toContain("cf_score");
@@ -270,11 +271,11 @@ describe("useFilters", () => {
setSearchParams([ setSearchParams([
["search", "alice"], ["search", "alice"],
["cf_score", "10"], ["cf_score", "10"],
["cft_score", "NUMBER"], ["cft_score", FieldType.NUMBER],
]); ]);
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
act(() => { act(() => {
result.current.setCustomFieldFilter("score", "", "NUMBER"); result.current.setCustomFieldFilter("score", "", FieldType.NUMBER);
}); });
const url = lastReplaceUrl(); const url = lastReplaceUrl();
expect(url).toContain("search=alice"); expect(url).toContain("search=alice");
@@ -283,7 +284,7 @@ describe("useFilters", () => {
it("calls router.replace with scroll: false", () => { it("calls router.replace with scroll: false", () => {
const { result } = renderHook(() => useFilters()); const { result } = renderHook(() => useFilters());
act(() => { act(() => {
result.current.setCustomFieldFilter("tag", "hero", "TEXT"); result.current.setCustomFieldFilter("tag", "hero", FieldType.TEXT);
}); });
expect(lastReplaceOpts()).toEqual({ scroll: false }); expect(lastReplaceOpts()).toEqual({ scroll: false });
}); });
+1 -1
View File
@@ -109,7 +109,7 @@ describe("useLocalStorage", () => {
}); });
it("handles object values", () => { it("handles object values", () => {
const { result } = renderHook(() => useLocalStorage("obj", { a: 1 })); const { result } = renderHook(() => useLocalStorage<Record<string, number>>("obj", { a: 1 }));
act(() => { act(() => {
result.current[1]({ a: 2, b: 3 }); result.current[1]({ a: 2, b: 3 });
@@ -52,6 +52,9 @@ function makeState(overrides: Partial<MultiSelectState> = {}): MultiSelectState
selectedAllocationIds: [], selectedAllocationIds: [],
selectedResourceIds: [], selectedResourceIds: [],
dateRange: null, dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
...overrides, ...overrides,
}; };
} }
@@ -362,7 +365,7 @@ describe("useMultiSelectIntersection", () => {
}); });
expect(setMultiSelectState).toHaveBeenCalledOnce(); expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as ( const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState, prev: MultiSelectState,
) => MultiSelectState; ) => MultiSelectState;
const prevState = makeState(); const prevState = makeState();
@@ -414,7 +417,7 @@ describe("useMultiSelectIntersection", () => {
}); });
expect(setMultiSelectState).toHaveBeenCalledOnce(); expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as ( const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState, prev: MultiSelectState,
) => MultiSelectState; ) => MultiSelectState;
const nextState = updaterFn(makeState()); const nextState = updaterFn(makeState());
@@ -475,7 +478,7 @@ describe("useMultiSelectIntersection", () => {
}); });
expect(setMultiSelectState).toHaveBeenCalledOnce(); expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as ( const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState, prev: MultiSelectState,
) => MultiSelectState; ) => MultiSelectState;
const nextState = updaterFn(makeState()); const nextState = updaterFn(makeState());
@@ -523,7 +526,7 @@ describe("useMultiSelectIntersection", () => {
}); });
expect(setMultiSelectState).toHaveBeenCalledOnce(); expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as ( const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState, prev: MultiSelectState,
) => MultiSelectState; ) => MultiSelectState;
const nextState = updaterFn(makeState()); const nextState = updaterFn(makeState());
@@ -573,7 +576,7 @@ describe("useMultiSelectIntersection", () => {
}); });
expect(setMultiSelectState).toHaveBeenCalledOnce(); expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as ( const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState, prev: MultiSelectState,
) => MultiSelectState; ) => MultiSelectState;
const nextState = updaterFn(makeState()); const nextState = updaterFn(makeState());
@@ -628,7 +631,7 @@ describe("useMultiSelectIntersection", () => {
}); });
expect(setMultiSelectState).toHaveBeenCalledOnce(); expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as ( const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState, prev: MultiSelectState,
) => MultiSelectState; ) => MultiSelectState;
const nextState = updaterFn(makeState()); const nextState = updaterFn(makeState());
@@ -684,7 +687,7 @@ describe("useMultiSelectIntersection", () => {
}); });
expect(setMultiSelectState).toHaveBeenCalledOnce(); expect(setMultiSelectState).toHaveBeenCalledOnce();
const updaterFn = setMultiSelectState.mock.calls[0][0] as ( const updaterFn = setMultiSelectState.mock.calls[0]![0] as (
prev: MultiSelectState, prev: MultiSelectState,
) => MultiSelectState; ) => MultiSelectState;
const nextState = updaterFn(makeState()); const nextState = updaterFn(makeState());
@@ -15,15 +15,18 @@ function fireKeydown(options: KeyboardEventInit) {
// Helper: create a scroll container element with controllable scrollLeft // Helper: create a scroll container element with controllable scrollLeft
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function makeScrollContainer(): HTMLDivElement { type ScrollDiv = HTMLDivElement & { _scrollLeft: number };
const el = document.createElement("div");
function makeScrollContainer(): ScrollDiv {
const el = document.createElement("div") as ScrollDiv;
el._scrollLeft = 0;
// jsdom does not actually scroll, but we can track assignments // jsdom does not actually scroll, but we can track assignments
Object.defineProperty(el, "scrollLeft", { Object.defineProperty(el, "scrollLeft", {
get() { get() {
return this._scrollLeft ?? 0; return (this as ScrollDiv)._scrollLeft;
}, },
set(v: number) { set(v: number) {
this._scrollLeft = v; (this as ScrollDiv)._scrollLeft = v;
}, },
configurable: true, configurable: true,
}); });
@@ -53,7 +56,7 @@ function setWindowsPlatform() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("useTimelineKeyboard", () => { describe("useTimelineKeyboard", () => {
let scrollEl: HTMLDivElement; let scrollEl: ScrollDiv;
let onDeleteSelected: ReturnType<typeof vi.fn>; let onDeleteSelected: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
+9 -9
View File
@@ -111,7 +111,7 @@ describe("useViewPrefs", () => {
act(() => { act(() => {
result.current.setSavedSort({ field: "age", dir: "asc" }); 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" } }); expect(written).toMatchObject({ sort: { field: "age", dir: "asc" } });
}); });
@@ -121,7 +121,7 @@ describe("useViewPrefs", () => {
act(() => { act(() => {
result.current.setSavedSort(null); 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>; const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
expect(written).not.toHaveProperty("sort"); expect(written).not.toHaveProperty("sort");
}); });
@@ -195,7 +195,7 @@ describe("useViewPrefs", () => {
result.current.setRowOrder(["c", "a", "b"]); result.current.setRowOrder(["c", "a", "b"]);
}); });
const written = JSON.parse( 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>; ) as Record<string, unknown>;
expect(written).toMatchObject({ rowOrder: ["c", "a", "b"] }); expect(written).toMatchObject({ rowOrder: ["c", "a", "b"] });
}); });
@@ -206,7 +206,7 @@ describe("useViewPrefs", () => {
act(() => { act(() => {
result.current.setRowOrder([]); 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>; const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
expect(written).not.toHaveProperty("rowOrder"); expect(written).not.toHaveProperty("rowOrder");
}); });
@@ -293,8 +293,8 @@ describe("useViewPrefs", () => {
act(() => { act(() => {
result.current.setRowOrder(["b", "a"]); result.current.setRowOrder(["b", "a"]);
}); });
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1]; const lastCall1 = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1]!;
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>; const written = JSON.parse(lastCall1[1] as string) as Record<string, unknown>;
expect(written).toMatchObject({ expect(written).toMatchObject({
sort: { field: "name", dir: "asc" }, sort: { field: "name", dir: "asc" },
rowOrder: ["b", "a"], rowOrder: ["b", "a"],
@@ -307,9 +307,9 @@ describe("useViewPrefs", () => {
act(() => { act(() => {
result.current.setSavedSort({ field: "date", dir: "desc" }); result.current.setSavedSort({ field: "date", dir: "desc" });
}); });
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1]; const lastCall2 = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1]!;
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>; const written2 = JSON.parse(lastCall2[1] as string) as Record<string, unknown>;
expect(written).toMatchObject({ expect(written2).toMatchObject({
sort: { field: "date", dir: "desc" }, sort: { field: "date", dir: "desc" },
rowOrder: ["x", "y"], rowOrder: ["x", "y"],
}); });
+3 -5
View File
@@ -48,9 +48,9 @@ describe("getPlanningEntryMutationId", () => {
it("returns sourceAllocationId when entityId is undefined", () => { it("returns sourceAllocationId when entityId is undefined", () => {
const result = getPlanningEntryMutationId({ const result = getPlanningEntryMutationId({
id: "id-1", id: "id-1",
entityId: undefined, entityId: undefined as string | undefined,
sourceAllocationId: "alloc-1", sourceAllocationId: "alloc-1",
}); } as Parameters<typeof getPlanningEntryMutationId>[0]);
expect(result).toBe("alloc-1"); expect(result).toBe("alloc-1");
}); });
@@ -78,9 +78,7 @@ describe("getPlanningEntryMutationId", () => {
it("returns id when both entityId and sourceAllocationId are undefined", () => { it("returns id when both entityId and sourceAllocationId are undefined", () => {
const result = getPlanningEntryMutationId({ const result = getPlanningEntryMutationId({
id: "id-1", id: "id-1",
entityId: undefined, } as Parameters<typeof getPlanningEntryMutationId>[0]);
sourceAllocationId: undefined,
});
expect(result).toBe("id-1"); expect(result).toBe("id-1");
}); });