Files
WebForm-mw/components/StageSection.tsx
T
Joel Brock 2400931a04 File upload pipeline: wire end-to-end via APIv4 File.create
Closes the file-upload gap. Files now actually land in CiviCRM (verified
empirically against the live Civi instance via spike scripts).

Spike findings (see scripts/spike-file-upload.mjs):
  - APIv4 Attachment is NOT exposed on this Civi
  - APIv4 File + EntityFile ARE exposed; File.create accepts inline
    base64 `content` and returns a usable file id
  - Custom file fields store the file id directly in the custom column,
    so EntityFile linkage is unnecessary for this use case
  - Round-trip via Contact.update + Contact.get .file_name join verified
    on a real org contact

Pipeline:

  Renderer (FileField) picks up onChange  →
    POST /api/upload (multipart) with file + cid + cs + fieldRef  →
      verifyChecksum, MIME allowlist + magic-byte sniff, 5 MB cap  →
        civi.File.create({ file_name, mime_type, content: base64 })  →
          returns { id, file_name }  →
            renderer stores in RHF state via setValue
  Form submit  →
    POST /api/submit (JSON) with the {id, file_name} value  →
      submit detects the file shape and writes the id as the value of
      the activity/contact custom field

File changes:

  app/api/upload/route.ts
    Replaced the 501 stub with the real File.create call. Comment
    documents that EntityFile linkage is intentionally skipped and that
    orphan cleanup is owned by a CiviCRM scheduled job.

  app/api/submit/route.ts
    For type:"file" values shaped as {id, file_name}, write the id as
    the custom field value (activity or contact, depending on the
    civiField / civiContactField the field declares).

  components/fields/FieldRenderer.tsx
    Replaced the bare <input type=file> register() with FileField, an
    upload-on-pick subcomponent. The native input is NOT register()'d:
    its FileList value was the original bug. FileField owns its
    uploading + error state and writes {id, file_name} via setValue on
    success. Submit is blocked upstream while uploads are in flight.

  components/StageSection.tsx, components/EngagementForm.tsx
    Thread setValue, cid, cs, and an onUploadStateChange callback
    through to FieldRenderer. EngagementForm tracks uploads-in-flight
    count; onSubmit refuses to submit while the count is > 0.

  config/form.ts
    Promotes Certificate_of_Incorporation from readonly to a real
    file field now that the pipeline works.

  app/api/data/route.ts
    Drops the readonly carveout that was only needed while the
    certificate was readonly.

  scripts/list-civi-entities.mjs (new)
    APIv4 entity probe + APIv3 attachment-API probe. Used to determine
    that File (not Attachment) was the right entity on this Civi.

  scripts/spike-file-upload.mjs (new)
    The actual end-to-end test that proved out the pipeline before
    wiring. Safe to re-run on any Civi instance during future audits.

Not in this change:
  - Orphan attachment cleanup (CiviCRM scheduled job, Civi admin scope)
  - Per-field MIME allowlists (single global list for v1)
  - S3 / presigned-URL path for >5 MB files (deferred; capped at 5 MB
    today to stay under Amplify Lambda's 6 MB sync payload limit)
2026-06-05 07:48:57 -07:00

468 lines
15 KiB
TypeScript

"use client";
import { useEffect, useId, useMemo, useState } from "react";
import type { FieldConfig, StageSectionConfig, SelectOption } from "@/types/form";
import type {
UseFormRegister,
UseFormSetValue,
FieldValues,
FieldErrors,
Control,
} from "react-hook-form";
import { FieldRenderer } from "./fields/FieldRenderer";
import { MatrixGroup } from "./MatrixGroup";
import { StageIcon } from "./StageIcon";
import { evaluate } from "@/lib/conditional";
interface StageSectionProps {
section: StageSectionConfig;
register: UseFormRegister<FieldValues>;
setValue: UseFormSetValue<FieldValues>;
control: Control<FieldValues>;
errors: FieldErrors;
/** Live form values, used to evaluate per-field visibility rules. */
formValues: Record<string, unknown>;
/** Whether this is the section that matches the current org stage. */
isCurrent: boolean;
/**
* Whether this stage is ahead of the org's current stage. Locked sections
* still render so the user can see what's coming, but the fields inside
* are wrapped in a disabled fieldset and never written on submit.
*/
locked: boolean;
/** Whether the section starts open. */
defaultOpen: boolean;
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
options: Record<number, SelectOption[]>;
/** Form auth pair, passed through to file fields for /api/upload. */
cid: string;
cs: string;
/** Called by file fields when an upload starts/finishes; lets the form
* track in-flight uploads and block submit until they settle. */
onUploadStateChange?: (delta: 1 | -1) => void;
}
/**
* One accordion card per stage section. Header carries:
* - A hand-drawn stage icon (left)
* - The stage label (display serif)
* - A "Current stage" pill in terracotta if applicable
* - A field-count summary on the right
* - A chevron that rotates on open
*
* The card border thickens and tints leaf-green when current; otherwise it
* sits quietly on the cream paper.
*/
export function StageSection({
section,
register,
setValue,
control,
errors,
formValues,
isCurrent,
locked,
defaultOpen,
options,
cid,
cs,
onUploadStateChange,
}: StageSectionProps) {
const [open, setOpen] = useState(defaultOpen);
const headingId = useId();
const panelId = useId();
// Listen for programmatic expand requests (fired by EngagementForm when a
// submit fails on a required field inside a collapsed section).
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ sectionId: string }>).detail;
if (detail?.sectionId === section.id) setOpen(true);
};
document.addEventListener("coop-checkin:expand-section", handler);
return () => document.removeEventListener("coop-checkin:expand-section", handler);
}, [section.id]);
const matrixFieldNames = useMemo(() => {
const names = new Set<string>();
for (const m of section.matrixGroups ?? []) {
for (const row of m.rows) for (const f of row.fields) names.add(f);
}
return names;
}, [section.matrixGroups]);
// Names of fields claimed by a fieldGroup. These get pulled out of the
// standalone per-field grid and rendered inside their group's card. If a
// name appears in multiple groups, the first wins.
const groupedFieldNames = useMemo(() => {
const names = new Set<string>();
for (const g of section.fieldGroups ?? []) {
for (const fname of g.fields) names.add(fname);
}
return names;
}, [section.fieldGroups]);
const fieldsByName = useMemo(() => {
const map = new Map<string, FieldConfig>();
for (const f of section.fields) map.set(f.name, f);
return map;
}, [section.fields]);
// Materialize each group as its list of *visible* fields (with the group's
// declared order preserved). A group with zero visible fields renders
// nothing.
const visibleGroups = useMemo(
() =>
(section.fieldGroups ?? [])
.map((g) => ({
group: g,
fields: g.fields
.map((name) => fieldsByName.get(name))
.filter((f): f is FieldConfig => !!f)
.filter((f) => evaluate(f.visibleWhen, formValues))
.filter((f) => !matrixFieldNames.has(f.name)),
}))
.filter((g) => g.fields.length > 0),
[section.fieldGroups, fieldsByName, formValues, matrixFieldNames],
);
const visibleFields = section.fields.filter(
(f) =>
evaluate(f.visibleWhen, formValues) &&
!matrixFieldNames.has(f.name) &&
!groupedFieldNames.has(f.name),
);
const fieldCount =
visibleFields.length +
visibleGroups.reduce((n, g) => n + g.fields.length, 0) +
(section.matrixGroups ?? []).reduce(
(n, g) => n + g.rows.reduce((m, r) => m + r.fields.length, 0),
0,
);
const cardClass = isCurrent
? "border-2 border-leaf-600 shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_8px_24px_-12px_rgba(60,80,40,0.18)] bg-white/95"
: locked
? "border border-dashed border-rule bg-paper-2/30 shadow-none"
: "border border-rule bg-white/95 shadow-sm";
const buttonClass = isCurrent
? "bg-leaf-50/60"
: locked
? "hover:bg-paper-2/50"
: "hover:bg-paper-2/60";
return (
<section
aria-labelledby={headingId}
className={"overflow-hidden rounded-lg transition " + cardClass}
>
<h2 id={headingId} className="m-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
buttonClass
}
>
<StageRankMark rank={section.rank} isCurrent={isCurrent} locked={locked} />
<span className="flex-1 min-w-0">
<span
className={
"block font-display text-lg font-medium leading-tight tracking-tight sm:text-xl " +
(locked ? "text-ink-soft" : "text-ink")
}
>
{section.label}
</span>
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
{isCurrent && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-clay-200 bg-clay-100/70 px-2 py-0.5 font-medium text-clay-700 uppercase tracking-[0.08em]">
<span className="h-1.5 w-1.5 rounded-full bg-clay-600" aria-hidden />
Current stage
</span>
)}
{locked && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-rule bg-paper px-2 py-0.5 font-medium text-ink-mute uppercase tracking-[0.08em]">
<LockGlyph />
Upcoming
</span>
)}
<span className="tabular-nums">
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
</span>
</span>
</span>
<Chevron open={open} />
</button>
</h2>
<div
id={panelId}
role="region"
aria-labelledby={headingId}
hidden={!open}
className={"border-t " + (locked ? "border-rule" : "border-rule-soft")}
>
<fieldset
disabled={locked}
aria-disabled={locked || undefined}
className={
"px-5 py-6 sm:px-7 sm:py-7 " +
(locked ? "opacity-80" : "")
}
>
{locked && <LockedBanner />}
{section.intro && (
<p className="mb-6 max-w-prose text-[15px] leading-relaxed text-ink-soft">
{section.intro}
</p>
)}
{(section.matrixGroups ?? []).length > 0 && (
<div className="mb-7 space-y-5">
{section.matrixGroups!.map((g) => (
<MatrixGroup key={g.id} group={g} register={register} />
))}
</div>
)}
{visibleGroups.length > 0 && (
<div className="mb-7 space-y-5">
{visibleGroups.map(({ group, fields }) => (
<FieldGroupCard
key={group.id}
label={group.label}
intro={group.intro}
fields={fields}
formValues={formValues}
options={options}
register={register}
setValue={setValue}
control={control}
errors={errors}
cid={cid}
cs={cs}
onUploadStateChange={onUploadStateChange}
/>
))}
</div>
)}
{visibleFields.length === 0 &&
visibleGroups.length === 0 &&
(section.matrixGroups ?? []).length === 0 ? (
<p className="text-sm italic text-ink-mute">No fields are visible at this stage.</p>
) : visibleFields.length === 0 ? null : (
<div className="grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2">
{visibleFields.map((f) => {
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
const wide =
f.type === "textarea" ||
f.type === "boolean" ||
f.type === "multiselect";
return (
<div key={f.name} className={wide ? "md:col-span-2" : ""}>
<FieldRenderer
field={f}
register={register}
setValue={setValue}
control={control}
errors={errors}
readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions}
cid={cid}
cs={cs}
onUploadStateChange={onUploadStateChange}
/>
</div>
);
})}
</div>
)}
</fieldset>
</div>
</section>
);
}
/**
* Stage rank mark: the number sits in a small circle, with the hand-drawn
* icon offset behind it. When current, the ring is leaf-green and the
* number is white-on-green; otherwise it's quiet.
*/
function StageRankMark({
rank,
isCurrent,
locked,
}: {
rank: number;
isCurrent: boolean;
locked: boolean;
}) {
const iconClass = isCurrent
? "text-leaf-700 opacity-100"
: locked
? "text-ink-mute opacity-30"
: "text-leaf-600 opacity-50";
const badgeClass = isCurrent
? "bg-leaf-700 text-paper"
: locked
? "bg-paper text-ink-mute border border-dashed border-rule"
: "bg-paper text-ink-soft border border-rule";
return (
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
<StageIcon
rank={rank}
className={"absolute inset-0 h-12 w-12 transition-opacity " + iconClass}
/>
<span
className={
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
badgeClass
}
style={{ marginLeft: 28, marginTop: 28 }}
>
{rank}
</span>
</span>
);
}
function LockGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
>
<rect x="2.5" y="5.5" width="7" height="5" rx="0.75" />
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
</svg>
);
}
function LockedBanner() {
return (
<div
role="note"
className="mb-5 flex items-start gap-3 rounded-md border border-dashed border-rule bg-paper px-4 py-3"
>
<span aria-hidden className="mt-0.5 text-ink-mute">
<LockGlyph />
</span>
<p className="text-xs leading-relaxed text-ink-soft">
<span className="font-medium text-ink-soft">A look ahead.</span>{" "}
These fields will become editable when your co-op reaches this stage. They&apos;re visible
now so you can preview the Framework you&apos;ll be working through.
</p>
</div>
);
}
/**
* Visual cluster of related fields inside a section (e.g. "Market Study"
* grouping its date + upload fields). Lightweight treatment: a leaf-tinted
* left rule and an optional uppercase mini-label. No boxed background or
* heavy padding — preserves the horizontal space the inner 2-col grid has
* to work with, while still signaling "these belong together."
*/
function FieldGroupCard({
label,
intro,
fields,
formValues,
options,
register,
setValue,
control,
errors,
cid,
cs,
onUploadStateChange,
}: {
label?: string;
intro?: string;
fields: FieldConfig[];
formValues: Record<string, unknown>;
options: Record<number, SelectOption[]>;
register: UseFormRegister<FieldValues>;
setValue: UseFormSetValue<FieldValues>;
control: Control<FieldValues>;
errors: FieldErrors;
cid: string;
cs: string;
onUploadStateChange?: (delta: 1 | -1) => void;
}) {
return (
<div className="border-l-2 border-leaf-300/60 pl-3 sm:pl-4">
{label && (
<h3 className="font-display text-[11px] font-medium uppercase tracking-[0.1em] text-ink-soft">
{label}
</h3>
)}
{intro && (
<p
className={
"max-w-prose text-xs leading-relaxed text-ink-mute " +
(label ? "mt-0.5" : "")
}
>
{intro}
</p>
)}
<div
className={
"grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2 " +
(label || intro ? "mt-2" : "")
}
>
{fields.map((f) => {
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
const wide =
f.type === "textarea" || f.type === "boolean" || f.type === "multiselect";
return (
<div key={f.name} className={wide ? "md:col-span-2" : ""}>
<FieldRenderer
field={f}
register={register}
setValue={setValue}
control={control}
errors={errors}
readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions}
cid={cid}
cs={cs}
onUploadStateChange={onUploadStateChange}
/>
</div>
);
})}
</div>
</div>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
aria-hidden
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
>
<path d="M5 8 L10 13 L15 8" />
</svg>
);
}