Form: required submitter info section above the stage pathway

Captures the form-filler's name and email on every check-in. Both fields
are required; values write back to Check_in_data__organizing_.Survey_completed_by
and Survey_completed_by_email on the activity, giving us a per-submission
record of who filled out which check-in.

Implementation:
- config/form.ts: new submitterInfo section (rank -1) at the head of the
  sections array. rank -1 keeps it out of the past/current/future stage
  pathway computation.
- components/EngagementForm.tsx: filter the submitter section out of
  sectionsToRender and render it directly with FieldRenderer inside a
  bordered card above the stage list. The fields still flow through RHF
  registration, onInvalid scroll-to-error, and the onSubmit visibility
  filter the same as any other field.

The staff report auto-discovers these fields via CustomField.get since
they live in Check_in_data__organizing_, so the field history shows up
in the report with no extra wiring.
This commit is contained in:
Joel Brock
2026-06-10 09:03:19 -07:00
parent 2e1244aa47
commit bd859ce906
2 changed files with 86 additions and 3 deletions
+55 -2
View File
@@ -19,6 +19,7 @@ function sectionForField(
import { evaluate } from "@/lib/conditional"; import { evaluate } from "@/lib/conditional";
import { STAGE_OPTION_GROUP_ID } from "@/config/form"; import { STAGE_OPTION_GROUP_ID } from "@/config/form";
import { StageSection } from "./StageSection"; import { StageSection } from "./StageSection";
import { FieldRenderer } from "./fields/FieldRenderer";
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft"; import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
interface EngagementFormProps { interface EngagementFormProps {
@@ -241,6 +242,26 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0; const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
// Section pathway state — past / current / future is determined entirely
// by stage rank vs the org's current rank. Sections never hide; future
// stages render in a locked treatment so users can preview what's ahead.
//
// Section-level visibleWhen is still consulted on submit to strip
// locked-stage values from the payload (see onSubmit below), so a future
// stage's data never gets accidentally written.
// The submitter-info section (rank: -1) lives outside the stage pathway —
// it renders directly above the section list (see JSX below), not inside
// the rail/marker accordion model used for Stage 0..5. Filter it out here
// so its rank doesn't pollute the past/current/future calculation.
const submitterSection = useMemo(
() => config.sections.find((s) => s.id === "submitter_info"),
[config.sections],
);
const stageSections = useMemo(
() => config.sections.filter((s) => s.id !== "submitter_info"),
[config.sections],
);
// Section pathway state — past / current / future is determined entirely // Section pathway state — past / current / future is determined entirely
// by stage rank vs the org's current rank. Sections never hide; future // by stage rank vs the org's current rank. Sections never hide; future
// stages render in a locked treatment so users can preview what's ahead. // stages render in a locked treatment so users can preview what's ahead.
@@ -250,12 +271,12 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
// stage's data never gets accidentally written. // stage's data never gets accidentally written.
const sectionsToRender = useMemo( const sectionsToRender = useMemo(
() => () =>
config.sections.map((s) => { stageSections.map((s) => {
const pathwayState: PathwayState = const pathwayState: PathwayState =
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future"; s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
return { section: s, pathwayState, locked: pathwayState === "future" }; return { section: s, pathwayState, locked: pathwayState === "future" };
}), }),
[config.sections, currentRank], [stageSections, currentRank],
); );
// Track which sections the user has manually opened/closed so we can // Track which sections the user has manually opened/closed so we can
@@ -398,6 +419,38 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
}} /> }} />
)} )}
{submitterSection && (
<section
aria-labelledby="submitter-info-heading"
className="rounded-2xl border border-rule bg-paper p-4 sm:p-5"
>
<h2
id="submitter-info-heading"
className="font-display text-lg font-medium text-ink"
>
{submitterSection.label}
</h2>
{submitterSection.intro && (
<p className="mt-1 text-sm text-ink-soft">{submitterSection.intro}</p>
)}
<div className="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-2">
{submitterSection.fields.map((f) => (
<FieldRenderer
key={f.name}
field={f}
register={register}
setValue={setValue}
control={control}
errors={errors}
cid={cid}
cs={cs}
onUploadStateChange={handleUploadStateChange}
/>
))}
</div>
</section>
)}
<ol className="relative space-y-5 md:pl-12"> <ol className="relative space-y-5 md:pl-12">
{sectionsToRender.map((entry, i) => { {sectionsToRender.map((entry, i) => {
const { section, pathwayState, locked } = entry; const { section, pathwayState, locked } = entry;
+31 -1
View File
@@ -54,6 +54,36 @@ const G5 = "Stage_5";
// route through Contact.update on submit and Contact.get at form load. // route through Contact.update on submit and Contact.get at form load.
const G_ORG = "Food_Co_op_Organizing"; const G_ORG = "Food_Co_op_Organizing";
// Submitter information — captured on every check-in. Rendered above the
// stage pathway (see EngagementForm) so it's not threaded into the past /
// current / future stage rail. Both fields are required on every submission.
// rank: -1 keeps the section out of the rank-based pathway computation;
// EngagementForm filters this section out of `sectionsToRender` and renders
// it directly via FieldRenderer above the stage list.
const submitterInfo: StageSectionConfig = {
rank: -1,
id: "submitter_info",
label: "Survey submitter",
intro:
"Who's filling this out? We log this with each submission so we can follow up if needed.",
fields: [
{
name: "Survey_completed_by",
label: "Survey completed by",
type: "text",
required: true,
civiField: `${G0}.Survey_completed_by`,
},
{
name: "Survey_completed_by_email",
label: "Survey completed by — email",
type: "email",
required: true,
civiField: `${G0}.Survey_completed_by_email`,
},
],
};
// Stage 0 — Survey (always visible) // Stage 0 — Survey (always visible)
const stage0: StageSectionConfig = { const stage0: StageSectionConfig = {
rank: 0, rank: 0,
@@ -753,7 +783,7 @@ export const formConfig: FormConfig = {
subtitle: subtitle:
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.", "Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.",
stageField: "current_stage", stageField: "current_stage",
sections: [stage0, stage1, stage2, stage3, stage4, stage5], sections: [submitterInfo, stage0, stage1, stage2, stage3, stage4, stage5],
}; };
/** Convenience: flatten all field configs across all sections for lookup by name. */ /** Convenience: flatten all field configs across all sections for lookup by name. */