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";
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",
};