Add sync-help-from-civi script + field-group rendering
Two additions, both touching the form-config story: 1. scripts/sync-help-from-civi.mjs Diffs per-field help text in config/form.ts against CustomField rows in CiviCRM and (with --write) updates the file in place. Reads env from .env.local via Node's --env-file flag. Run as `npm run sync-help` or `npm run sync-help -- --write`. A --debug mode prints the parser's field list without calling Civi. Rationale: this form is low-traffic and help text doesn't change often once in production. A manual one-off sync is leaner than coupling every page load (or every build) to a Civi API call. 2. fieldGroups: visual clustering of related fields within a section New optional FieldGroupConfig overlay on StageSectionConfig — pure presentation, names existing fields by name so submit/visibility logic walks them unchanged. StageSection.tsx pulls grouped fields out of the standalone per-field grid and renders each group as its own bordered card with an optional heading. Stage 2 now clusters Market Study, Pro Forma, Business Plan, and Board Self Assessment (each a date + upload pair) into their own cards.
This commit is contained in:
+126
-3
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import type { StageSectionConfig, SelectOption } from "@/types/form";
|
||||
import type { FieldConfig, StageSectionConfig, SelectOption } from "@/types/form";
|
||||
import type {
|
||||
UseFormRegister,
|
||||
FieldValues,
|
||||
@@ -79,12 +79,51 @@ export function StageSection({
|
||||
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),
|
||||
(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,
|
||||
@@ -178,7 +217,26 @@ export function StageSection({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? (
|
||||
{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}
|
||||
control={control}
|
||||
errors={errors}
|
||||
/>
|
||||
))}
|
||||
</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">
|
||||
@@ -288,6 +346,71 @@ function LockedBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual cluster of related fields inside a section (e.g. "Market Study"
|
||||
* grouping its date + upload fields). Renders as a quiet bordered card
|
||||
* with an optional heading; the fields inside use the same two-column
|
||||
* grid rules as the standalone per-field grid above/below.
|
||||
*/
|
||||
function FieldGroupCard({
|
||||
label,
|
||||
intro,
|
||||
fields,
|
||||
formValues,
|
||||
options,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
}: {
|
||||
label?: string;
|
||||
intro?: string;
|
||||
fields: FieldConfig[];
|
||||
formValues: Record<string, unknown>;
|
||||
options: Record<number, SelectOption[]>;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
control: Control<FieldValues>;
|
||||
errors: FieldErrors;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-rule-soft bg-paper-2/30 px-4 py-4 sm:px-5 sm:py-5">
|
||||
{label && (
|
||||
<h3 className="font-display text-sm font-medium uppercase tracking-[0.08em] text-ink-soft">
|
||||
{label}
|
||||
</h3>
|
||||
)}
|
||||
{intro && (
|
||||
<p className={"max-w-prose text-xs leading-relaxed text-ink-mute " + (label ? "mt-1" : "")}>
|
||||
{intro}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
"grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2 " +
|
||||
(label || intro ? "mt-3" : "")
|
||||
}
|
||||
>
|
||||
{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}
|
||||
control={control}
|
||||
errors={errors}
|
||||
readonlyValue={formValues[f.name]}
|
||||
resolvedOptions={resolvedOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user