Polish pass: removed lastTouched dead code, selective useWatch perf, token sweep, required-field messages, scroll-to-first-error, file prefill display, matrix sticky shadow

This commit is contained in:
Joel Brock
2026-05-09 22:49:25 -07:00
parent e452fbb15f
commit 0d84b9654b
4 changed files with 180 additions and 89 deletions

View File

@@ -1,8 +1,21 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import type { FormConfig, FormDataPayload, SubmitPayload } from "@/types/form";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm, useWatch, type FieldErrors, type FieldValues } from "react-hook-form";
import type { FormConfig, FormDataPayload, SubmitPayload, StageSectionConfig } from "@/types/form";
function sectionForField(
sections: ReadonlyArray<StageSectionConfig>,
fieldName: string,
): StageSectionConfig | undefined {
for (const s of sections) {
if (s.fields.some((f) => f.name === fieldName)) return s;
for (const m of s.matrixGroups ?? []) {
for (const row of m.rows) if (row.fields.includes(fieldName)) return s;
}
}
return undefined;
}
import { evaluate } from "@/lib/conditional";
import { StageSection } from "./StageSection";
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
@@ -43,11 +56,33 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
register,
handleSubmit,
reset,
control,
watch,
setFocus,
formState: { errors, isDirty },
} = useForm({ mode: "onBlur" });
const formValues = watch();
// Subscribe ONLY to current_stage. That's the single field that affects
// section visibility, so re-rendering the whole form on every keystroke
// (which `watch()` with no args would do) is wasteful — particularly with
// 120 fields and a 39-cell matrix in Stage 5.
//
// The auto-save effect uses watch's subscription API for side-effect-only
// change tracking (no re-render).
const currentStageValue =
(useWatch({ control, name: "current_stage" }) as string | undefined) ?? "";
// State map passed to the conditional engine and to FieldRenderer for
// readonly display values. Only fields referenced by visibility rules or
// by readonly displays need to be here. Both readonly fields in this form
// (current_stage, stage_at_submission) display the org's current stage.
const evalState = useMemo(
() => ({
current_stage: currentStageValue,
stage_at_submission: currentStageValue,
}),
[currentStageValue],
);
// ── Initial load ───────────────────────────────────────────────────────
useEffect(() => {
@@ -103,34 +138,46 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
}, [cid, cs, reset, config.stageField]);
// ── Auto-save draft on idle ────────────────────────────────────────────
// Debounce — save 1.5s after the user stops editing.
// Debounce — save 1.5s after the user stops editing. Uses watch's
// subscription API so we get notified on every change WITHOUT re-rendering
// the form on each keystroke.
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (load.kind !== "ready") return;
if (!isDirty) return;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveDraft(cid, formValues);
setDraftSavedAt(new Date().toISOString());
}, 1500);
const sub = watch((values) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveDraft(cid, values as Record<string, unknown>);
setDraftSavedAt(new Date().toISOString());
}, 1500);
});
return () => {
sub.unsubscribe();
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [formValues, isDirty, load.kind, cid]);
}, [load.kind, cid, watch]);
// ── Section ordering ───────────────────────────────────────────────────
const sectionsToRender = useMemo(
() =>
config.sections.map((s) => ({
section: s,
sectionVisible: evaluate(s.visibleWhen, formValues),
sectionVisible: evaluate(s.visibleWhen, evalState),
})),
[config.sections, formValues],
[config.sections, evalState],
);
const currentStageValue = formValues[config.stageField] as string | undefined;
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
// Track which sections the user has manually opened/closed so we can
// programmatically re-open them when validation surfaces an error within.
// Used by the onInvalid handler below.
const expandSection = useCallback((sectionId: string) => {
document.dispatchEvent(
new CustomEvent("coop-checkin:expand-section", { detail: { sectionId } }),
);
}, []);
// ── Render ─────────────────────────────────────────────────────────────
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
@@ -183,8 +230,35 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
}
};
// ── Invalid-submit handling ────────────────────────────────────────────
// When required-field validation blocks submit, RHF calls onInvalid with
// the errors map. Find the first error's section, expand it, scroll into
// view, focus the field. Saves users from chasing a silent failure.
const onInvalid = (errs: FieldErrors<FieldValues>) => {
const firstErrorField = Object.keys(errs)[0];
if (!firstErrorField) return;
const section = sectionForField(config.sections, firstErrorField);
if (section) {
expandSection(section.id);
// Allow the section to expand before focusing.
requestAnimationFrame(() => {
try {
setFocus(firstErrorField);
} catch {
// setFocus throws if the field isn't registered; safe to ignore.
}
const el = document.getElementById(`field-${firstErrorField}`);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
});
}
setSubmitState({
kind: "error",
message: "Please fill the required fields highlighted below before submitting.",
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5" noValidate>
<SubmissionContextHeader
orgName={load.data.orgName}
currentStage={load.data.currentStage}
@@ -210,7 +284,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
section={section}
register={register}
errors={errors}
formValues={formValues}
formValues={evalState}
isCurrent={section.rank === currentRank}
defaultOpen={section.rank === currentRank || section.rank === 0}
options={load.data.options ?? {}}