Wire real CiviCRM DEV custom fields and dynamic option fetching

This commit is contained in:
Joel Brock
2026-05-09 20:42:35 -07:00
parent 54555c74d2
commit c58a49be6d
7 changed files with 663 additions and 213 deletions

View File

@@ -162,6 +162,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
formValues={formValues}
isCurrent={section.rank === currentRank}
defaultOpen={section.rank === currentRank || section.rank === 0}
options={load.data.options ?? {}}
/>
);
})}

View File

@@ -1,7 +1,7 @@
"use client";
import { useId, useState } from "react";
import type { StageSectionConfig } from "@/types/form";
import type { StageSectionConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
import { FieldRenderer } from "./fields/FieldRenderer";
import { evaluate } from "@/lib/conditional";
@@ -16,6 +16,8 @@ interface StageSectionProps {
isCurrent: boolean;
/** Whether the section starts open. */
defaultOpen: boolean;
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
options: Record<number, SelectOption[]>;
}
/**
@@ -30,6 +32,7 @@ export function StageSection({
formValues,
isCurrent,
defaultOpen,
options,
}: StageSectionProps) {
const [open, setOpen] = useState(defaultOpen);
const headingId = useId();
@@ -89,19 +92,24 @@ export function StageSection({
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p>
) : (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
{visibleFields.map((f) => (
<div
key={f.name}
className={f.type === "textarea" || f.type === "boolean" ? "md:col-span-2" : ""}
>
<FieldRenderer
field={f}
register={register}
errors={errors}
readonlyValue={formValues[f.name]}
/>
</div>
))}
{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}
errors={errors}
readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions}
/>
</div>
);
})}
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import type { FieldConfig } from "@/types/form";
import type { FieldConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
interface FieldRendererProps {
@@ -9,6 +9,12 @@ interface FieldRendererProps {
errors: FieldErrors;
/** 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[];
}
/**
@@ -20,12 +26,19 @@ interface FieldRendererProps {
* `step` and inputMode for mobile keyboards. We keep formatting light — the
* server is the source of truth for normalization.
*/
export function FieldRenderer({ field, register, errors, readonlyValue }: FieldRendererProps) {
export function FieldRenderer({
field,
register,
errors,
readonlyValue,
resolvedOptions,
}: 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 baseInputClass =
"w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-stone-900 " +
@@ -36,7 +49,12 @@ export function FieldRenderer({ field, register, errors, readonlyValue }: FieldR
// ── Readonly display field ──────────────────────────────────────────────
if (field.type === "readonly") {
const display = readonlyValue == null || readonlyValue === "" ? "—" : String(readonlyValue);
// Look up the human label if this readonly references an option-group field.
const opt = effectiveOptions.find((o) => o.value === readonlyValue);
const display =
readonlyValue == null || readonlyValue === ""
? "—"
: opt?.label ?? String(readonlyValue);
return (
<div className="space-y-1">
<Label id={id} field={field} />
@@ -116,7 +134,7 @@ export function FieldRenderer({ field, register, errors, readonlyValue }: FieldR
<option value="" disabled>
Select
</option>
{(field.options ?? []).map((o) => (
{effectiveOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
@@ -128,6 +146,45 @@ export function FieldRenderer({ field, register, errors, readonlyValue }: FieldR
);
}
// ── 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-stone-800">
{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-stone-200 px-3 py-2 hover:bg-stone-50 cursor-pointer"
>
<input
id={optId}
type="checkbox"
value={o.value}
{...register(field.name)}
className="mt-0.5 h-4 w-4 rounded border-stone-400 text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
/>
<span className="text-sm text-stone-800">{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 (