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:
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import Link from "next/link";
|
||||
@@ -35,118 +34,21 @@ import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||
|
||||
type ModalState =
|
||||
| { type: "closed" }
|
||||
| { type: "create" }
|
||||
| { type: "edit"; resource: Resource }
|
||||
| { type: "import" }
|
||||
| { type: "bulkEdit" };
|
||||
|
||||
type ConfirmState =
|
||||
| { type: "closed" }
|
||||
| { type: "batchDeactivate"; ids: string[] }
|
||||
| { type: "deactivate"; resource: Resource }
|
||||
| { type: "delete"; resource: Resource }
|
||||
| { 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>
|
||||
);
|
||||
}
|
||||
import {
|
||||
type ModalState,
|
||||
type ConfirmState,
|
||||
type ActiveFilter,
|
||||
type BooleanFilter,
|
||||
type ResourceListPage,
|
||||
type CountryOption,
|
||||
DEFAULT_HIDDEN_RESOURCE_TYPES,
|
||||
DEFAULT_BOOLEAN_FILTER,
|
||||
RESOURCE_TYPE_LABELS,
|
||||
} from "./resource-client/types.js";
|
||||
import { FilterDropdown } from "./resource-client/FilterDropdown.js";
|
||||
import { BooleanBadge } from "./resource-client/BooleanBadge.js";
|
||||
|
||||
export function ResourcesClient() {
|
||||
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",
|
||||
name: "Test",
|
||||
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: {},
|
||||
});
|
||||
@@ -53,7 +60,14 @@ describe("canGoNext", () => {
|
||||
shortCode: "PRJ01",
|
||||
name: "Test",
|
||||
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" },
|
||||
});
|
||||
@@ -65,7 +79,14 @@ describe("canGoNext", () => {
|
||||
shortCode: "PRJ01",
|
||||
name: "Test",
|
||||
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: [] },
|
||||
});
|
||||
@@ -77,7 +98,14 @@ describe("canGoNext", () => {
|
||||
shortCode: "PRJ01",
|
||||
name: "Test",
|
||||
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: {},
|
||||
});
|
||||
@@ -153,7 +181,7 @@ describe("canGoNext", () => {
|
||||
});
|
||||
|
||||
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] });
|
||||
expect(canGoNext(2, state)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ErrorInfo } from "react";
|
||||
import React, { type ErrorInfo } from "react";
|
||||
import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js";
|
||||
|
||||
// Suppress React's console.error output during error boundary tests
|
||||
@@ -16,7 +16,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("ErrorBoundary", () => {
|
||||
});
|
||||
|
||||
it("shows a generic message when error.message is empty", () => {
|
||||
function ThrowEmptyMessage() {
|
||||
function ThrowEmptyMessage(): React.ReactNode {
|
||||
const e = new Error("");
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,9 @@ describe("ShimmerSkeleton", () => {
|
||||
});
|
||||
|
||||
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"],
|
||||
["md", "rounded-md"],
|
||||
["lg", "rounded-lg"],
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -48,9 +48,9 @@ describe("getPlanningEntryMutationId", () => {
|
||||
it("returns sourceAllocationId when entityId is undefined", () => {
|
||||
const result = getPlanningEntryMutationId({
|
||||
id: "id-1",
|
||||
entityId: undefined,
|
||||
entityId: undefined as string | undefined,
|
||||
sourceAllocationId: "alloc-1",
|
||||
});
|
||||
} as Parameters<typeof getPlanningEntryMutationId>[0]);
|
||||
expect(result).toBe("alloc-1");
|
||||
});
|
||||
|
||||
@@ -78,9 +78,7 @@ describe("getPlanningEntryMutationId", () => {
|
||||
it("returns id when both entityId and sourceAllocationId are undefined", () => {
|
||||
const result = getPlanningEntryMutationId({
|
||||
id: "id-1",
|
||||
entityId: undefined,
|
||||
sourceAllocationId: undefined,
|
||||
});
|
||||
} as Parameters<typeof getPlanningEntryMutationId>[0]);
|
||||
expect(result).toBe("id-1");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user