Wire real CiviCRM DEV custom fields and dynamic option fetching
This commit is contained in:
@@ -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 ?? {}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user