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:
@@ -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 ?? {}}
|
||||
|
||||
Reference in New Issue
Block a user