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",
|
||||
};
|
||||
Reference in New Issue
Block a user