Files
WebForm-mw/components/fields/FieldRenderer.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

595 lines
20 KiB
TypeScript

"use client";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import type { FieldConfig, SelectOption } from "@/types/form";
import type {
UseFormRegister,
UseFormSetValue,
FieldValues,
FieldErrors,
Control,
} from "react-hook-form";
interface FieldRendererProps {
field: FieldConfig;
register: UseFormRegister<FieldValues>;
/** RHF setValue — file fields use it to write the uploaded {id, file_name}
* back into form state after /api/upload returns. */
setValue: UseFormSetValue<FieldValues>;
errors: FieldErrors;
/** Form control — required for currency live-preview formatting. */
control: Control<FieldValues>;
/** For readonly display fields, the value to render. */
readonlyValue?: unknown;
/**
* Options resolved at runtime (from /api/data). When `field.optionGroupId`
* is set, look up options here first; fall back to the field's hard-coded
* `options` array if absent.
*/
resolvedOptions?: SelectOption[];
/** Form auth pair, threaded through to file fields for /api/upload. */
cid: string;
cs: string;
/** Called by file fields when an upload starts (+1) / finishes (-1). The
* form uses the running count to block submit while uploads are in flight. */
onUploadStateChange?: (delta: 1 | -1) => void;
}
const DATE_MIN_DEFAULT = "1900-01-01";
const DATE_MAX_DEFAULT = "2100-12-31";
/**
* Renders a single field appropriate to its `type`. All inputs share a common
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
* to help/error text, aria-invalid set when in error, and visible focus.
*
* Currency, percent, and number all use input type="number" with appropriate
* `step` and inputMode for mobile keyboards. We keep formatting light — the
* server is the source of truth for normalization.
*
* Required-field validation: when `field.required` is true, RHF's register
* receives a string error message so the inline ErrorText has something to
* display. Without that, validation would block submit silently.
*/
export function FieldRenderer({
field,
register,
setValue,
errors,
control,
readonlyValue,
resolvedOptions,
cid,
cs,
onUploadStateChange,
}: FieldRendererProps) {
const id = `field-${field.name}`;
const helpId = field.help ? `${id}-help` : undefined;
const errorId = errors[field.name] ? `${id}-error` : undefined;
const describedBy = [helpId, errorId].filter(Boolean).join(" ") || undefined;
const errorMsg = errors[field.name]?.message as string | undefined;
const effectiveOptions = resolvedOptions ?? field.options ?? [];
const requiredOpt = field.required ? `${field.label} is required.` : false;
const baseInputClass =
"w-full rounded-md border border-rule bg-paper px-3 py-2 text-ink " +
"placeholder:text-ink-mute/70 shadow-sm transition " +
"focus:border-leaf-600 focus:outline-none focus:ring-2 focus:ring-leaf-500/30 " +
"disabled:bg-paper-2 disabled:text-ink-mute " +
"aria-invalid:border-clay-500 aria-invalid:ring-clay-500/25";
// ── Readonly display field ──────────────────────────────────────────────
if (field.type === "readonly") {
// File-shaped readonly value: {id, file_name} from a CiviCRM file field
// (e.g. Certificate of Incorporation). Render the filename rather than
// "[object Object]".
let display: string;
if (
readonlyValue != null &&
typeof readonlyValue === "object" &&
!Array.isArray(readonlyValue) &&
"file_name" in (readonlyValue as Record<string, unknown>)
) {
const fn = (readonlyValue as { file_name?: unknown }).file_name;
display = typeof fn === "string" && fn ? fn : "Attachment on file";
} else {
const opt = effectiveOptions.find((o) => o.value === readonlyValue);
display =
readonlyValue == null || readonlyValue === ""
? "—"
: opt?.label ?? String(readonlyValue);
}
return (
<div className="space-y-1">
<Label id={id} field={field} />
<div
id={id}
role="textbox"
aria-readonly="true"
className="rounded-md border border-rule bg-paper-2/40 px-3 py-2 text-ink-soft"
>
{display}
</div>
{field.help && <Help id={helpId!}>{field.help}</Help>}
</div>
);
}
// ── Boolean (checkbox) ──────────────────────────────────────────────────
if (field.type === "boolean") {
return (
<div className="flex items-start gap-3 py-1">
<input
id={id}
type="checkbox"
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
{...register(field.name, { required: requiredOpt })}
className="mt-0.5 h-4 w-4 rounded border-rule text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
/>
<div className="flex-1">
<label htmlFor={id} className="font-medium text-ink cursor-pointer">
{field.label}
{field.required && <RequiredMark />}
</label>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
</div>
);
}
// ── Textarea ────────────────────────────────────────────────────────────
if (field.type === "textarea") {
return (
<div className="space-y-1">
<Label id={id} field={field} />
<textarea
id={id}
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
aria-required={field.required || undefined}
placeholder={field.placeholder}
maxLength={field.maxLength}
rows={4}
{...register(field.name, { required: requiredOpt })}
className={baseInputClass + " min-h-[7rem] leading-6"}
/>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
// ── Select ──────────────────────────────────────────────────────────────
if (field.type === "select") {
return (
<div className="space-y-1">
<Label id={id} field={field} />
<select
id={id}
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
aria-required={field.required || undefined}
{...register(field.name, { required: requiredOpt })}
className={baseInputClass}
defaultValue=""
>
<option value="" disabled>
Select
</option>
{effectiveOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
// ── Multiselect (rendered as a checkbox group) ─────────────────────────
if (field.type === "multiselect") {
return (
<fieldset
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
className="space-y-2"
>
<legend className="block text-sm font-medium text-ink">
{field.label}
{field.required && <RequiredMark />}
</legend>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{effectiveOptions.map((o, idx) => {
const optId = `${id}-${idx}`;
return (
<label
key={o.value}
htmlFor={optId}
className="flex items-start gap-2 rounded border border-rule bg-paper px-3 py-2 hover:bg-paper-2/60 cursor-pointer transition-colors"
>
<input
id={optId}
type="checkbox"
value={o.value}
{...register(field.name)}
className="mt-0.5 h-4 w-4 rounded border-rule text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
/>
<span className="text-sm text-ink-soft">{o.label}</span>
</label>
);
})}
</div>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</fieldset>
);
}
// ── File ────────────────────────────────────────────────────────────────
if (field.type === "file") {
return (
<FileField
field={field}
id={id}
helpId={helpId}
errorId={errorId}
errorMsg={errorMsg}
describedBy={describedBy}
control={control}
setValue={setValue}
cid={cid}
cs={cs}
onUploadStateChange={onUploadStateChange}
register={register}
requiredOpt={requiredOpt}
/>
);
}
// ── Numeric family: number / currency / percent ─────────────────────────
if (field.type === "number" || field.type === "currency" || field.type === "percent") {
const prefix = field.type === "currency" ? "$" : null;
const suffix = field.type === "percent" ? "%" : null;
return (
<div className="space-y-1">
<Label id={id} field={field} />
<div className="relative">
{prefix && (
<span
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-mute"
>
{prefix}
</span>
)}
<input
id={id}
type="number"
inputMode="decimal"
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
aria-required={field.required || undefined}
placeholder={field.placeholder}
step={field.step ?? (field.type === "number" ? 1 : 0.01)}
min={field.min}
max={field.max}
{...register(field.name, {
required: requiredOpt,
valueAsNumber: true,
})}
className={
baseInputClass +
" tabular-nums" +
(prefix ? " pl-7" : "") +
(suffix ? " pr-8" : "")
}
/>
{suffix && (
<span
aria-hidden
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-ink-mute"
>
{suffix}
</span>
)}
</div>
{field.type === "currency" && (
<CurrencyPreview control={control} name={field.name} />
)}
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
// ── Date ────────────────────────────────────────────────────────────────
if (field.type === "date") {
const dateMin = typeof field.min === "string" ? field.min : DATE_MIN_DEFAULT;
const dateMax = typeof field.max === "string" ? field.max : DATE_MAX_DEFAULT;
return (
<div className="space-y-1">
<Label id={id} field={field} />
<input
id={id}
type="date"
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
aria-required={field.required || undefined}
min={dateMin}
max={dateMax}
{...register(field.name, { required: requiredOpt })}
className={baseInputClass}
/>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
// ── Default: text-like (text / email / phone) ───────────────────────────
const inputType =
field.type === "email" ? "email" :
field.type === "phone" ? "tel" :
"text";
return (
<div className="space-y-1">
<Label id={id} field={field} />
<input
id={id}
type={inputType}
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
aria-required={field.required || undefined}
placeholder={field.placeholder}
maxLength={field.maxLength}
autoComplete={
field.type === "email" ? "email" :
field.type === "phone" ? "tel" :
undefined
}
{...register(field.name, { required: requiredOpt })}
className={baseInputClass}
/>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
const currencyFmt = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
});
function CurrencyPreview({
control,
name,
}: {
control: Control<FieldValues>;
name: string;
}) {
const raw = useWatch({ control, name });
if (raw === undefined || raw === null || raw === "") return null;
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n)) return null;
return (
<p className="text-xs tabular-nums text-ink-mute" aria-live="polite">
{currencyFmt.format(n)}
</p>
);
}
/**
* For file fields: detects whether a previously-uploaded attachment is
* carried in this field's prefill value (RHF state) and surfaces a small
* banner with a paperclip glyph. Falls silent once the user picks a new
* file (RHF value becomes a FileList) so it doesn't contradict their
* fresh upload. Filename is derived from whatever shape Civi returned —
* a bare string filename, an object with `file_name`/`name`/`label`, or
* a numeric file id (in which case we render a generic message).
*/
function FilePriorIndicator({
control,
name,
}: {
control: Control<FieldValues>;
name: string;
}) {
const value = useWatch({ control, name });
if (value === null || value === undefined || value === "") return null;
// A FileList means the user has just picked a new file — they don't
// need a reminder about what *used* to be on file.
if (typeof FileList !== "undefined" && value instanceof FileList) return null;
let filename: string | null = null;
if (typeof value === "string") {
filename = value;
} else if (typeof value === "object" && value !== null) {
const o = value as Record<string, unknown>;
const cand = o.file_name ?? o.name ?? o.label ?? o.filename;
if (typeof cand === "string") filename = cand;
}
return (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-md border border-rule-soft bg-leaf-50/60 px-3 py-2 text-xs"
>
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="mt-[1px] h-3.5 w-3.5 flex-shrink-0 text-leaf-700"
>
<path d="M11.5 4.5 L6 10 a2 2 0 1 0 2.83 2.83 L13.5 7.5 a3.5 3.5 0 0 0 -4.95 -4.95 L3.5 7.5" />
</svg>
<span className="text-ink-soft leading-snug">
Attachment on file
{filename ? (
<>
: <span className="font-medium text-ink break-all">{filename}</span>
</>
) : null}
. Choose a new file below to replace it, or leave blank to keep it.
</span>
</div>
);
}
/**
* Upload-on-pick file field. The moment the user selects a file the
* browser sends it to /api/upload; on success we replace RHF state with
* the returned {id, file_name}. That same shape is what submit serializes
* and what the prefill path produces for prior attachments — so the rest
* of the pipeline doesn't care whether the value originated as prefill
* or as a fresh upload.
*
* The native <input type="file"> is intentionally NOT register()'d here:
* its `value` is a FileList that doesn't survive JSON.stringify, which is
* the whole bug we're closing. We manage state manually via setValue.
*/
function FileField({
field,
id,
helpId,
errorId,
errorMsg,
describedBy,
control,
setValue,
cid,
cs,
onUploadStateChange,
}: {
field: FieldConfig;
id: string;
helpId?: string;
errorId?: string;
errorMsg?: string;
describedBy?: string;
control: Control<FieldValues>;
setValue: UseFormSetValue<FieldValues>;
cid: string;
cs: string;
onUploadStateChange?: (delta: 1 | -1) => void;
register: UseFormRegister<FieldValues>;
requiredOpt: string | false;
}) {
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
// Target Civi field reference — whichever side this field maps to.
const fieldRef = field.civiField ?? field.civiContactField ?? "";
async function handlePick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploadError(null);
setUploading(true);
onUploadStateChange?.(1);
const fd = new FormData();
fd.set("file", file);
fd.set("cid", cid);
fd.set("cs", cs);
fd.set("fieldRef", fieldRef);
try {
const res = await fetch("/api/upload", { method: "POST", body: fd });
const json = (await res.json().catch(() => ({}))) as {
id?: number;
file_name?: string;
error?: string;
};
if (!res.ok) {
setUploadError(json.error ?? `Upload failed (HTTP ${res.status}).`);
// Reset the file input so the user can retry the same file.
e.target.value = "";
} else if (json.id != null && json.file_name) {
setValue(field.name, { id: json.id, file_name: json.file_name }, {
shouldDirty: true,
shouldValidate: true,
});
} else {
setUploadError("Upload succeeded but no file id was returned.");
}
} catch (err) {
setUploadError(err instanceof Error ? err.message : "Upload network error.");
e.target.value = "";
} finally {
setUploading(false);
onUploadStateChange?.(-1);
}
}
return (
<div className="space-y-1.5">
<Label id={id} field={field} />
<FilePriorIndicator control={control} name={field.name} />
<input
id={id}
type="file"
disabled={uploading}
aria-describedby={describedBy}
aria-invalid={errorMsg || uploadError ? true : undefined}
aria-required={field.required || undefined}
onChange={handlePick}
className="block w-full text-sm text-ink-soft file:mr-3 file:rounded-md file:border-0 file:bg-leaf-100 file:px-3 file:py-2 file:text-leaf-800 file:text-sm file:font-medium hover:file:bg-leaf-200 cursor-pointer transition disabled:cursor-wait disabled:opacity-60"
/>
{uploading && (
<p role="status" aria-live="polite" className="text-xs text-ink-soft">
Uploading
</p>
)}
{field.help && <Help id={helpId!}>{field.help}</Help>}
{uploadError && <ErrorText id={errorId ?? `${id}-error`}>{uploadError}</ErrorText>}
{!uploadError && errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
function Label({ id, field }: { id: string; field: FieldConfig }) {
return (
<label htmlFor={id} className="block text-sm font-medium text-ink">
{field.label}
{field.required && <RequiredMark />}
</label>
);
}
function RequiredMark() {
return (
<span aria-label="required" className="ml-1 text-clay-700">
*
</span>
);
}
function Help({ id, children }: { id: string; children: React.ReactNode }) {
return (
<p id={id} className="text-xs text-ink-mute leading-relaxed">
{children}
</p>
);
}
function ErrorText({ id, children }: { id: string; children: React.ReactNode }) {
return (
<p id={id} role="alert" className="text-xs font-medium text-clay-700">
{children}
</p>
);
}