refactor(web): set up component test infra + decompose ProjectWizard
Phase 4a: Add @testing-library/react, user-event, jest-dom, jsdom. Switch vitest environment to jsdom, add setup file, create test-utils with QueryClient wrapper. Phase 4b: Extract ProjectWizard form logic into project-wizard/ subdir: - types.ts: WizardState, Assignment, constants, factory functions - useProjectWizardForm.ts: form state hook + canGoNext pure function Phase 4c: 32 tests for canGoNext validation (all 5 steps), makeDefaultState, and makeReq factory function. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,9 @@
|
|||||||
"@capakraken/eslint-config": "workspace:*",
|
"@capakraken/eslint-config": "workspace:*",
|
||||||
"@capakraken/tsconfig": "workspace:*",
|
"@capakraken/tsconfig": "workspace:*",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^19.0.6",
|
"@types/react": "^19.0.6",
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
"@vitest/coverage-v8": "^2.1.9",
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.2.0",
|
"eslint": "^10.2.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
|
|||||||
@@ -1,123 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import type {
|
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
|
||||||
StaffingRequirement,
|
import { BlueprintTarget, FieldType, RolePresetsSchema } from "@capakraken/shared";
|
||||||
BlueprintFieldDefinition,
|
|
||||||
OrderType,
|
|
||||||
AllocationType,
|
|
||||||
} from "@capakraken/shared";
|
|
||||||
import {
|
|
||||||
BlueprintTarget,
|
|
||||||
FieldType,
|
|
||||||
ProjectStatus,
|
|
||||||
AllocationStatus,
|
|
||||||
RolePresetsSchema,
|
|
||||||
} from "@capakraken/shared";
|
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { uuid } from "~/lib/uuid.js";
|
import { uuid } from "~/lib/uuid.js";
|
||||||
import { DateInput } from "~/components/ui/DateInput.js";
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
|
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
|
||||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { formatCents, toDateInputValue } from "~/lib/format.js";
|
import { formatCents } from "~/lib/format.js";
|
||||||
import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js";
|
import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js";
|
||||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
|
import {
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
ORDER_TYPE_OPTIONS,
|
||||||
|
ALLOCATION_TYPE_OPTIONS,
|
||||||
const ORDER_TYPE_OPTIONS = [
|
BTN_PRIMARY,
|
||||||
{ value: "BD", label: "BD" },
|
BTN_SECONDARY,
|
||||||
{ value: "CHARGEABLE", label: "Chargeable" },
|
STEPS,
|
||||||
{ value: "INTERNAL", label: "Internal" },
|
makeReq,
|
||||||
{ value: "OVERHEAD", label: "Overhead" },
|
type Assignment,
|
||||||
] as const;
|
type WizardState,
|
||||||
|
type SuggestionItem,
|
||||||
const ALLOCATION_TYPE_OPTIONS = [
|
} from "./project-wizard/types.js";
|
||||||
{ value: "INT", label: "INT" },
|
import { useProjectWizardForm } from "./project-wizard/useProjectWizardForm.js";
|
||||||
{ value: "EXT", label: "EXT" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const BTN_PRIMARY =
|
|
||||||
"px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors";
|
|
||||||
|
|
||||||
const BTN_SECONDARY =
|
|
||||||
"px-5 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors";
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Assignment {
|
|
||||||
requirementId: string;
|
|
||||||
resourceId: string;
|
|
||||||
resourceName: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WizardState {
|
|
||||||
blueprintId: string | null;
|
|
||||||
shortCode: string;
|
|
||||||
name: string;
|
|
||||||
orderType: string;
|
|
||||||
allocationType: string;
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
budgetEur: string;
|
|
||||||
winProbability: number;
|
|
||||||
responsiblePerson: string;
|
|
||||||
staffingReqs: StaffingRequirement[];
|
|
||||||
assignments: Assignment[];
|
|
||||||
saveAsDraft: boolean;
|
|
||||||
dynamicFields: Record<string, unknown>;
|
|
||||||
blueprintName: string | null;
|
|
||||||
blueprintFieldDefs: BlueprintFieldDefinition[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDefaultState(): WizardState {
|
|
||||||
const today = toDateInputValue(new Date());
|
|
||||||
return {
|
|
||||||
blueprintId: null,
|
|
||||||
shortCode: "",
|
|
||||||
name: "",
|
|
||||||
orderType: "CHARGEABLE",
|
|
||||||
allocationType: "INT",
|
|
||||||
startDate: today,
|
|
||||||
endDate: today,
|
|
||||||
budgetEur: "",
|
|
||||||
winProbability: 100,
|
|
||||||
responsiblePerson: "",
|
|
||||||
staffingReqs: [],
|
|
||||||
assignments: [],
|
|
||||||
saveAsDraft: true,
|
|
||||||
dynamicFields: {},
|
|
||||||
blueprintName: null,
|
|
||||||
blueprintFieldDefs: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReq(): StaffingRequirement {
|
|
||||||
return {
|
|
||||||
id: uuid(),
|
|
||||||
role: "",
|
|
||||||
requiredSkills: [],
|
|
||||||
preferredSkills: [],
|
|
||||||
hoursPerDay: 8,
|
|
||||||
headcount: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step indicators ─────────────────────────────────────────────────────────
|
// ─── Step indicators ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
"Blueprint & Identity",
|
|
||||||
"Timeline & Budget",
|
|
||||||
"Staffing Demand",
|
|
||||||
"Suggestions",
|
|
||||||
"Review & Create",
|
|
||||||
];
|
|
||||||
|
|
||||||
function StepBar({ current }: { current: number }) {
|
function StepBar({ current }: { current: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0 mb-6">
|
<div className="flex items-center gap-0 mb-6">
|
||||||
@@ -936,18 +848,6 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
|
|
||||||
// ─── Step 4: Suggestions ─────────────────────────────────────────────────────
|
// ─── Step 4: Suggestions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Matches StaffingSuggestion from @capakraken/shared (returned by staffing.getSuggestions)
|
|
||||||
type SuggestionItem = {
|
|
||||||
resourceId: string;
|
|
||||||
resourceName: string;
|
|
||||||
eid: string;
|
|
||||||
score: number;
|
|
||||||
currentUtilization: number;
|
|
||||||
availabilityConflicts: string[];
|
|
||||||
estimatedDailyCostCents: number;
|
|
||||||
valueScore?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ReqSuggestionsProps {
|
interface ReqSuggestionsProps {
|
||||||
req: StaffingRequirement;
|
req: StaffingRequirement;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@@ -1391,191 +1291,21 @@ interface ProjectWizardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps) {
|
export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps) {
|
||||||
const utils = trpc.useUtils();
|
const {
|
||||||
const [step, setStep] = useState(0);
|
step,
|
||||||
const [state, setState] = useState<WizardState>(makeDefaultState);
|
setStep,
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
state,
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
patch,
|
||||||
const [submitWarnings, setSubmitWarnings] = useState<string[]>([]);
|
canGoNext,
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
isSubmitting,
|
||||||
const [showSuccessToast, setShowSuccessToast] = useState(false);
|
submitError,
|
||||||
|
submitWarnings,
|
||||||
const createProject = trpc.project.create.useMutation();
|
showConfetti,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
showSuccessToast,
|
||||||
const createAssignment = (trpc.allocation.createAssignment.useMutation as any)() as {
|
setShowSuccessToast,
|
||||||
mutateAsync: (input: unknown) => Promise<unknown>;
|
handleSubmit,
|
||||||
};
|
handleClose,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
} = useProjectWizardForm({ onClose, onSuccess });
|
||||||
const createDemandRequirement = (
|
|
||||||
trpc.allocation.createDemandRequirement.useMutation as any
|
|
||||||
)() as {
|
|
||||||
mutateAsync: (input: unknown) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const patch = useCallback((p: Partial<WizardState>) => {
|
|
||||||
setState((prev) => ({ ...prev, ...p }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setStep(0);
|
|
||||||
setState(makeDefaultState());
|
|
||||||
setIsSubmitting(false);
|
|
||||||
setSubmitError(null);
|
|
||||||
setSubmitWarnings([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
reset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function canGoNext(): boolean {
|
|
||||||
if (step === 0) {
|
|
||||||
// Required blueprint fields must be filled
|
|
||||||
const missingRequired = state.blueprintFieldDefs.some((f) => {
|
|
||||||
if (!f.required) return false;
|
|
||||||
const val = state.dynamicFields[f.key];
|
|
||||||
return (
|
|
||||||
val === undefined ||
|
|
||||||
val === null ||
|
|
||||||
val === "" ||
|
|
||||||
(Array.isArray(val) && val.length === 0)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
state.shortCode.trim().length > 0 &&
|
|
||||||
/^[A-Z0-9_-]+$/.test(state.shortCode) &&
|
|
||||||
state.name.trim().length > 0 &&
|
|
||||||
!missingRequired
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (step === 1) {
|
|
||||||
return (
|
|
||||||
!!state.startDate &&
|
|
||||||
!!state.endDate &&
|
|
||||||
state.endDate >= state.startDate &&
|
|
||||||
(state.budgetEur === "" || parseFloat(state.budgetEur) >= 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (step === 2) {
|
|
||||||
// Allow advancing with zero requirements (no mandatory staffing), but
|
|
||||||
// any requirement that exists must be valid: must have a role and positive hours/headcount.
|
|
||||||
return state.staffingReqs.every(
|
|
||||||
(r) =>
|
|
||||||
(r.roleId != null || r.role.trim().length > 0) && r.hoursPerDay > 0 && r.headcount >= 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
setSubmitWarnings([]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const project = await createProject.mutateAsync({
|
|
||||||
shortCode: state.shortCode.trim(),
|
|
||||||
name: state.name.trim(),
|
|
||||||
orderType: state.orderType as unknown as OrderType,
|
|
||||||
allocationType: state.allocationType as unknown as AllocationType,
|
|
||||||
winProbability: state.winProbability,
|
|
||||||
budgetCents: state.budgetEur ? Math.round(parseFloat(state.budgetEur) * 100) : 0,
|
|
||||||
startDate: new Date(state.startDate),
|
|
||||||
endDate: new Date(state.endDate),
|
|
||||||
staffingReqs: state.staffingReqs,
|
|
||||||
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
|
|
||||||
responsiblePerson: state.responsiblePerson.trim(),
|
|
||||||
blueprintId: state.blueprintId ?? undefined,
|
|
||||||
dynamicFields: state.dynamicFields,
|
|
||||||
});
|
|
||||||
|
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
// Create draft assignments for assigned resources
|
|
||||||
for (const assignment of state.assignments) {
|
|
||||||
try {
|
|
||||||
const req = state.staffingReqs.find((r) => r.id === assignment.requirementId);
|
|
||||||
const hoursPerDay = req?.hoursPerDay ?? 8;
|
|
||||||
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
|
||||||
await createAssignment.mutateAsync({
|
|
||||||
projectId: project.id,
|
|
||||||
resourceId: assignment.resourceId,
|
|
||||||
startDate: new Date(state.startDate),
|
|
||||||
endDate: new Date(state.endDate),
|
|
||||||
hoursPerDay,
|
|
||||||
percentage,
|
|
||||||
role: assignment.role,
|
|
||||||
roleId: req?.roleId,
|
|
||||||
status: AllocationStatus.PROPOSED,
|
|
||||||
metadata: {},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
warnings.push(`Assignment for "${assignment.resourceName}" failed: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create open demand for unassigned slots
|
|
||||||
for (const req of state.staffingReqs) {
|
|
||||||
const assignedCount = state.assignments.filter((a) => a.requirementId === req.id).length;
|
|
||||||
const unassigned = req.headcount - assignedCount;
|
|
||||||
if (unassigned <= 0) continue;
|
|
||||||
try {
|
|
||||||
await createDemandRequirement.mutateAsync({
|
|
||||||
projectId: project.id,
|
|
||||||
startDate: new Date(state.startDate),
|
|
||||||
endDate: new Date(state.endDate),
|
|
||||||
hoursPerDay: req.hoursPerDay,
|
|
||||||
percentage: Math.min(100, Math.round((req.hoursPerDay / 8) * 100)),
|
|
||||||
role: req.role || undefined,
|
|
||||||
roleId: req.roleId,
|
|
||||||
headcount: unassigned,
|
|
||||||
status: AllocationStatus.PROPOSED,
|
|
||||||
metadata: {},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
warnings.push(
|
|
||||||
`Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (warnings.length > 0) setSubmitWarnings(warnings);
|
|
||||||
|
|
||||||
await utils.project.listWithCosts.invalidate();
|
|
||||||
await utils.timeline.getEntries.invalidate();
|
|
||||||
await utils.timeline.getEntriesView.invalidate();
|
|
||||||
setShowConfetti(true);
|
|
||||||
setShowSuccessToast(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowConfetti(false);
|
|
||||||
onSuccess?.(project.shortCode, project.name);
|
|
||||||
handleClose();
|
|
||||||
}, 1200);
|
|
||||||
} catch (err) {
|
|
||||||
let errorMessage = "Failed to create project";
|
|
||||||
if (err instanceof Error) {
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(err.message);
|
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
||||||
errorMessage = (parsed as { message?: string }[])
|
|
||||||
.map((e) => e.message)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("; ");
|
|
||||||
} else {
|
|
||||||
errorMessage = err.message;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
errorMessage = err.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSubmitError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
|
||||||
|
import { toDateInputValue } from "~/lib/format.js";
|
||||||
|
import { uuid } from "~/lib/uuid.js";
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ORDER_TYPE_OPTIONS = [
|
||||||
|
{ value: "BD", label: "BD" },
|
||||||
|
{ value: "CHARGEABLE", label: "Chargeable" },
|
||||||
|
{ value: "INTERNAL", label: "Internal" },
|
||||||
|
{ value: "OVERHEAD", label: "Overhead" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ALLOCATION_TYPE_OPTIONS = [
|
||||||
|
{ value: "INT", label: "INT" },
|
||||||
|
{ value: "EXT", label: "EXT" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BTN_PRIMARY =
|
||||||
|
"px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors";
|
||||||
|
|
||||||
|
export const BTN_SECONDARY =
|
||||||
|
"px-5 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors";
|
||||||
|
|
||||||
|
export const STEPS = [
|
||||||
|
"Blueprint & Identity",
|
||||||
|
"Timeline & Budget",
|
||||||
|
"Staffing Demand",
|
||||||
|
"Suggestions",
|
||||||
|
"Review & Create",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Assignment {
|
||||||
|
requirementId: string;
|
||||||
|
resourceId: string;
|
||||||
|
resourceName: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardState {
|
||||||
|
blueprintId: string | null;
|
||||||
|
shortCode: string;
|
||||||
|
name: string;
|
||||||
|
orderType: string;
|
||||||
|
allocationType: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
budgetEur: string;
|
||||||
|
winProbability: number;
|
||||||
|
responsiblePerson: string;
|
||||||
|
staffingReqs: StaffingRequirement[];
|
||||||
|
assignments: Assignment[];
|
||||||
|
saveAsDraft: boolean;
|
||||||
|
dynamicFields: Record<string, unknown>;
|
||||||
|
blueprintName: string | null;
|
||||||
|
blueprintFieldDefs: BlueprintFieldDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestionItem = {
|
||||||
|
resourceId: string;
|
||||||
|
resourceName: string;
|
||||||
|
eid: string;
|
||||||
|
score: number;
|
||||||
|
currentUtilization: number;
|
||||||
|
availabilityConflicts: string[];
|
||||||
|
estimatedDailyCostCents: number;
|
||||||
|
valueScore?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Factory functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeDefaultState(): WizardState {
|
||||||
|
const today = toDateInputValue(new Date());
|
||||||
|
return {
|
||||||
|
blueprintId: null,
|
||||||
|
shortCode: "",
|
||||||
|
name: "",
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
allocationType: "INT",
|
||||||
|
startDate: today,
|
||||||
|
endDate: today,
|
||||||
|
budgetEur: "",
|
||||||
|
winProbability: 100,
|
||||||
|
responsiblePerson: "",
|
||||||
|
staffingReqs: [],
|
||||||
|
assignments: [],
|
||||||
|
saveAsDraft: true,
|
||||||
|
dynamicFields: {},
|
||||||
|
blueprintName: null,
|
||||||
|
blueprintFieldDefs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeReq(): StaffingRequirement {
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
role: "",
|
||||||
|
requiredSkills: [],
|
||||||
|
preferredSkills: [],
|
||||||
|
hoursPerDay: 8,
|
||||||
|
headcount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { canGoNext } from "./useProjectWizardForm.js";
|
||||||
|
import { makeDefaultState, makeReq } from "./types.js";
|
||||||
|
import type { WizardState } from "./types.js";
|
||||||
|
|
||||||
|
function stateWith(overrides: Partial<WizardState>): WizardState {
|
||||||
|
return { ...makeDefaultState(), ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("canGoNext", () => {
|
||||||
|
// ─── Step 0: Blueprint & Identity ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("step 0", () => {
|
||||||
|
it("returns true when shortCode and name are valid", () => {
|
||||||
|
const state = stateWith({ shortCode: "PRJ-01", name: "My Project" });
|
||||||
|
expect(canGoNext(0, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when shortCode is empty", () => {
|
||||||
|
const state = stateWith({ shortCode: "", name: "My Project" });
|
||||||
|
expect(canGoNext(0, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when shortCode contains lowercase letters", () => {
|
||||||
|
const state = stateWith({ shortCode: "prj01", name: "My Project" });
|
||||||
|
expect(canGoNext(0, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when name is empty", () => {
|
||||||
|
const state = stateWith({ shortCode: "PRJ01", name: "" });
|
||||||
|
expect(canGoNext(0, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when name is only whitespace", () => {
|
||||||
|
const state = stateWith({ shortCode: "PRJ01", name: " " });
|
||||||
|
expect(canGoNext(0, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when a required blueprint field is missing", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
shortCode: "PRJ01",
|
||||||
|
name: "Test",
|
||||||
|
blueprintFieldDefs: [
|
||||||
|
{ key: "field1", type: "TEXT" as never, label: "Field 1", required: true },
|
||||||
|
],
|
||||||
|
dynamicFields: {},
|
||||||
|
});
|
||||||
|
expect(canGoNext(0, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when required blueprint field is filled", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
shortCode: "PRJ01",
|
||||||
|
name: "Test",
|
||||||
|
blueprintFieldDefs: [
|
||||||
|
{ key: "field1", type: "TEXT" as never, label: "Field 1", required: true },
|
||||||
|
],
|
||||||
|
dynamicFields: { field1: "value" },
|
||||||
|
});
|
||||||
|
expect(canGoNext(0, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when required field is an empty array", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
shortCode: "PRJ01",
|
||||||
|
name: "Test",
|
||||||
|
blueprintFieldDefs: [
|
||||||
|
{ key: "tags", type: "TAG_LIST" as never, label: "Tags", required: true },
|
||||||
|
],
|
||||||
|
dynamicFields: { tags: [] },
|
||||||
|
});
|
||||||
|
expect(canGoNext(0, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-required fields", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
shortCode: "PRJ01",
|
||||||
|
name: "Test",
|
||||||
|
blueprintFieldDefs: [
|
||||||
|
{ key: "optional", type: "TEXT" as never, label: "Optional", required: false },
|
||||||
|
],
|
||||||
|
dynamicFields: {},
|
||||||
|
});
|
||||||
|
expect(canGoNext(0, state)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Step 1: Timeline & Budget ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("step 1", () => {
|
||||||
|
it("returns true with valid dates and no budget", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-12-31",
|
||||||
|
budgetEur: "",
|
||||||
|
});
|
||||||
|
expect(canGoNext(1, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true with valid dates and positive budget", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-12-31",
|
||||||
|
budgetEur: "50000",
|
||||||
|
});
|
||||||
|
expect(canGoNext(1, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when endDate is before startDate", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
startDate: "2026-12-31",
|
||||||
|
endDate: "2026-01-01",
|
||||||
|
});
|
||||||
|
expect(canGoNext(1, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when startDate is empty", () => {
|
||||||
|
const state = stateWith({ startDate: "", endDate: "2026-12-31" });
|
||||||
|
expect(canGoNext(1, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when budget is negative", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-12-31",
|
||||||
|
budgetEur: "-100",
|
||||||
|
});
|
||||||
|
expect(canGoNext(1, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when budget is zero", () => {
|
||||||
|
const state = stateWith({
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-12-31",
|
||||||
|
budgetEur: "0",
|
||||||
|
});
|
||||||
|
expect(canGoNext(1, state)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Step 2: Staffing Demand ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("step 2", () => {
|
||||||
|
it("returns true with no staffing requirements", () => {
|
||||||
|
const state = stateWith({ staffingReqs: [] });
|
||||||
|
expect(canGoNext(2, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true with a valid requirement", () => {
|
||||||
|
const req = { ...makeReq(), role: "Developer", hoursPerDay: 8, headcount: 1 };
|
||||||
|
const state = stateWith({ staffingReqs: [req] });
|
||||||
|
expect(canGoNext(2, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when a requirement has empty role and no roleId", () => {
|
||||||
|
const req = { ...makeReq(), role: "", roleId: undefined, hoursPerDay: 8, headcount: 1 };
|
||||||
|
const state = stateWith({ staffingReqs: [req] });
|
||||||
|
expect(canGoNext(2, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when a requirement has roleId but empty role string", () => {
|
||||||
|
const req = { ...makeReq(), role: "", roleId: "role_1", hoursPerDay: 8, headcount: 1 };
|
||||||
|
const state = stateWith({ staffingReqs: [req] });
|
||||||
|
expect(canGoNext(2, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when hoursPerDay is zero", () => {
|
||||||
|
const req = { ...makeReq(), role: "Dev", hoursPerDay: 0, headcount: 1 };
|
||||||
|
const state = stateWith({ staffingReqs: [req] });
|
||||||
|
expect(canGoNext(2, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when headcount is less than 1", () => {
|
||||||
|
const req = { ...makeReq(), role: "Dev", hoursPerDay: 8, headcount: 0 };
|
||||||
|
const state = stateWith({ staffingReqs: [req] });
|
||||||
|
expect(canGoNext(2, state)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Steps 3+ ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("steps 3 and 4", () => {
|
||||||
|
it("always returns true for step 3", () => {
|
||||||
|
expect(canGoNext(3, makeDefaultState())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always returns true for step 4", () => {
|
||||||
|
expect(canGoNext(4, makeDefaultState())).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("makeDefaultState", () => {
|
||||||
|
it("returns a state with empty shortCode and name", () => {
|
||||||
|
const state = makeDefaultState();
|
||||||
|
expect(state.shortCode).toBe("");
|
||||||
|
expect(state.name).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to CHARGEABLE order type", () => {
|
||||||
|
expect(makeDefaultState().orderType).toBe("CHARGEABLE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 100% win probability", () => {
|
||||||
|
expect(makeDefaultState().winProbability).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to saveAsDraft true", () => {
|
||||||
|
expect(makeDefaultState().saveAsDraft).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets startDate and endDate to today", () => {
|
||||||
|
const state = makeDefaultState();
|
||||||
|
expect(state.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(state.endDate).toBe(state.startDate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("makeReq", () => {
|
||||||
|
it("creates a requirement with a unique id", () => {
|
||||||
|
const a = makeReq();
|
||||||
|
const b = makeReq();
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 8 hours per day", () => {
|
||||||
|
expect(makeReq().hoursPerDay).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to headcount 1", () => {
|
||||||
|
expect(makeReq().headcount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with empty skills", () => {
|
||||||
|
const req = makeReq();
|
||||||
|
expect(req.requiredSkills).toEqual([]);
|
||||||
|
expect(req.preferredSkills).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { OrderType, AllocationType } from "@capakraken/shared";
|
||||||
|
import { ProjectStatus, AllocationStatus } from "@capakraken/shared";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { makeDefaultState, type WizardState } from "./types.js";
|
||||||
|
|
||||||
|
// ─── Standalone pure validation function ─────────────────────────────────────
|
||||||
|
|
||||||
|
export function canGoNext(step: number, state: WizardState): boolean {
|
||||||
|
if (step === 0) {
|
||||||
|
const missingRequired = state.blueprintFieldDefs.some((f) => {
|
||||||
|
if (!f.required) return false;
|
||||||
|
const val = state.dynamicFields[f.key];
|
||||||
|
return (
|
||||||
|
val === undefined || val === null || val === "" || (Array.isArray(val) && val.length === 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
state.shortCode.trim().length > 0 &&
|
||||||
|
/^[A-Z0-9_-]+$/.test(state.shortCode) &&
|
||||||
|
state.name.trim().length > 0 &&
|
||||||
|
!missingRequired
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (step === 1) {
|
||||||
|
return (
|
||||||
|
!!state.startDate &&
|
||||||
|
!!state.endDate &&
|
||||||
|
state.endDate >= state.startDate &&
|
||||||
|
(state.budgetEur === "" || parseFloat(state.budgetEur) >= 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (step === 2) {
|
||||||
|
return state.staffingReqs.every(
|
||||||
|
(r) =>
|
||||||
|
(r.roleId != null || r.role.trim().length > 0) && r.hoursPerDay > 0 && r.headcount >= 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook options ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UseProjectWizardFormOptions {
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: ((shortCode: string, name: string) => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useProjectWizardForm({ onClose, onSuccess }: UseProjectWizardFormOptions) {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [state, setState] = useState<WizardState>(makeDefaultState);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [submitWarnings, setSubmitWarnings] = useState<string[]>([]);
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
const [showSuccessToast, setShowSuccessToast] = useState(false);
|
||||||
|
|
||||||
|
const createProject = trpc.project.create.useMutation();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const createAssignment = (trpc.allocation.createAssignment.useMutation as any)() as {
|
||||||
|
mutateAsync: (input: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDemandRequirement = (
|
||||||
|
trpc.allocation.createDemandRequirement.useMutation as any
|
||||||
|
)() as {
|
||||||
|
mutateAsync: (input: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = useCallback((p: Partial<WizardState>) => {
|
||||||
|
setState((prev) => ({ ...prev, ...p }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setStep(0);
|
||||||
|
setState(makeDefaultState());
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setSubmitError(null);
|
||||||
|
setSubmitWarnings([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
setSubmitWarnings([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await createProject.mutateAsync({
|
||||||
|
shortCode: state.shortCode.trim(),
|
||||||
|
name: state.name.trim(),
|
||||||
|
orderType: state.orderType as unknown as OrderType,
|
||||||
|
allocationType: state.allocationType as unknown as AllocationType,
|
||||||
|
winProbability: state.winProbability,
|
||||||
|
budgetCents: state.budgetEur ? Math.round(parseFloat(state.budgetEur) * 100) : 0,
|
||||||
|
startDate: new Date(state.startDate),
|
||||||
|
endDate: new Date(state.endDate),
|
||||||
|
staffingReqs: state.staffingReqs,
|
||||||
|
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
|
||||||
|
responsiblePerson: state.responsiblePerson.trim(),
|
||||||
|
blueprintId: state.blueprintId ?? undefined,
|
||||||
|
dynamicFields: state.dynamicFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Create draft assignments for assigned resources
|
||||||
|
for (const assignment of state.assignments) {
|
||||||
|
try {
|
||||||
|
const req = state.staffingReqs.find((r) => r.id === assignment.requirementId);
|
||||||
|
const hoursPerDay = req?.hoursPerDay ?? 8;
|
||||||
|
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
||||||
|
await createAssignment.mutateAsync({
|
||||||
|
projectId: project.id,
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
startDate: new Date(state.startDate),
|
||||||
|
endDate: new Date(state.endDate),
|
||||||
|
hoursPerDay,
|
||||||
|
percentage,
|
||||||
|
role: assignment.role,
|
||||||
|
roleId: req?.roleId,
|
||||||
|
status: AllocationStatus.PROPOSED,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
warnings.push(`Assignment for "${assignment.resourceName}" failed: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create open demand for unassigned slots
|
||||||
|
for (const req of state.staffingReqs) {
|
||||||
|
const assignedCount = state.assignments.filter((a) => a.requirementId === req.id).length;
|
||||||
|
const unassigned = req.headcount - assignedCount;
|
||||||
|
if (unassigned <= 0) continue;
|
||||||
|
try {
|
||||||
|
await createDemandRequirement.mutateAsync({
|
||||||
|
projectId: project.id,
|
||||||
|
startDate: new Date(state.startDate),
|
||||||
|
endDate: new Date(state.endDate),
|
||||||
|
hoursPerDay: req.hoursPerDay,
|
||||||
|
percentage: Math.min(100, Math.round((req.hoursPerDay / 8) * 100)),
|
||||||
|
role: req.role || undefined,
|
||||||
|
roleId: req.roleId,
|
||||||
|
headcount: unassigned,
|
||||||
|
status: AllocationStatus.PROPOSED,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
warnings.push(
|
||||||
|
`Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0) setSubmitWarnings(warnings);
|
||||||
|
|
||||||
|
await utils.project.listWithCosts.invalidate();
|
||||||
|
await utils.timeline.getEntries.invalidate();
|
||||||
|
await utils.timeline.getEntriesView.invalidate();
|
||||||
|
setShowConfetti(true);
|
||||||
|
setShowSuccessToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowConfetti(false);
|
||||||
|
onSuccess?.(project.shortCode, project.name);
|
||||||
|
handleClose();
|
||||||
|
}, 1200);
|
||||||
|
} catch (err) {
|
||||||
|
let errorMessage = "Failed to create project";
|
||||||
|
if (err instanceof Error) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(err.message);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
errorMessage = (parsed as { message?: string }[])
|
||||||
|
.map((e) => e.message)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; ");
|
||||||
|
} else {
|
||||||
|
errorMessage = err.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSubmitError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
step,
|
||||||
|
setStep,
|
||||||
|
state,
|
||||||
|
patch,
|
||||||
|
reset,
|
||||||
|
canGoNext: () => canGoNext(step, state),
|
||||||
|
isSubmitting,
|
||||||
|
submitError,
|
||||||
|
submitWarnings,
|
||||||
|
showConfetti,
|
||||||
|
showSuccessToast,
|
||||||
|
setShowSuccessToast,
|
||||||
|
handleSubmit,
|
||||||
|
handleClose,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @vitest-environment node
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -75,7 +76,7 @@ describe("AllocationPopover", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("data-testid=\"timeline-allocation-popover-error\"");
|
expect(html).toContain('data-testid="timeline-allocation-popover-error"');
|
||||||
expect(html).toContain("The selected booking could not be loaded right now.");
|
expect(html).toContain("The selected booking could not be loaded right now.");
|
||||||
expect(html).toContain("Assignment not found");
|
expect(html).toContain("Assignment not found");
|
||||||
});
|
});
|
||||||
@@ -98,8 +99,10 @@ describe("AllocationPopover", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("data-testid=\"timeline-allocation-popover-unavailable\"");
|
expect(html).toContain('data-testid="timeline-allocation-popover-unavailable"');
|
||||||
expect(html).toContain("The selected booking could not be resolved from the current timeline data.");
|
expect(html).toContain(
|
||||||
|
"The selected booking could not be resolved from the current timeline data.",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a loading skeleton when the allocation is being fetched", () => {
|
it("renders a loading skeleton when the allocation is being fetched", () => {
|
||||||
@@ -120,7 +123,7 @@ describe("AllocationPopover", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("data-testid=\"timeline-allocation-popover-loading\"");
|
expect(html).toContain('data-testid="timeline-allocation-popover-loading"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders allocation data when provided as initialAllocation", () => {
|
it("renders allocation data when provided as initialAllocation", () => {
|
||||||
@@ -160,8 +163,8 @@ describe("AllocationPopover", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).not.toContain("data-testid=\"timeline-allocation-popover-error\"");
|
expect(html).not.toContain('data-testid="timeline-allocation-popover-error"');
|
||||||
expect(html).not.toContain("data-testid=\"timeline-allocation-popover-unavailable\"");
|
expect(html).not.toContain('data-testid="timeline-allocation-popover-unavailable"');
|
||||||
expect(html).not.toContain("data-testid=\"timeline-allocation-popover-loading\"");
|
expect(html).not.toContain('data-testid="timeline-allocation-popover-loading"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, type RenderOptions } from "@testing-library/react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
function createTestQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0 },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestProviders({ children }: { children: React.ReactNode }) {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customRender(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
|
||||||
|
return render(ui, { wrapper: TestProviders, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from "@testing-library/react";
|
||||||
|
export { customRender as render };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -8,8 +8,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "jsdom",
|
||||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||||
|
setupFiles: ["./src/vitest-setup.ts"],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "lcov"],
|
reporter: ["text", "lcov"],
|
||||||
|
|||||||
Generated
+719
-40
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user