From 2ca2d378a4d217dc7119e4950244cbc5c8061e62 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Thu, 21 May 2026 12:27:22 -0700 Subject: [PATCH] Add sync-help-from-civi script + field-group rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/StageSection.tsx | 129 ++++++++++++- config/form.ts | 23 +++ package.json | 3 +- scripts/sync-help-from-civi.mjs | 316 ++++++++++++++++++++++++++++++++ types/form.ts | 32 ++++ 5 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 scripts/sync-help-from-civi.mjs diff --git a/components/StageSection.tsx b/components/StageSection.tsx index baed759..b0a0b59 100644 --- a/components/StageSection.tsx +++ b/components/StageSection.tsx @@ -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(); + 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(); + 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({ ))} )} - {visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? ( + {visibleGroups.length > 0 && ( +
+ {visibleGroups.map(({ group, fields }) => ( + + ))} +
+ )} + {visibleFields.length === 0 && + visibleGroups.length === 0 && + (section.matrixGroups ?? []).length === 0 ? (

No fields are visible at this stage.

) : visibleFields.length === 0 ? null : (
@@ -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; + options: Record; + register: UseFormRegister; + control: Control; + errors: FieldErrors; +}) { + return ( +
+ {label && ( +

+ {label} +

+ )} + {intro && ( +

+ {intro} +

+ )} +
+ {fields.map((f) => { + const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined; + const wide = + f.type === "textarea" || f.type === "boolean" || f.type === "multiselect"; + return ( +
+ +
+ ); + })} +
+
+ ); +} + function Chevron({ open }: { open: boolean }) { return ( { pre, post } + for (const row of result.values ?? []) { + map.set(`${row["custom_group_id.name"]}.${row.name}`, { + pre: row.help_pre ?? "", + post: row.help_post ?? "", + }); + } + return map; +} + +// ── form.ts parser ───────────────────────────────────────────────────── +// Build a masked copy of the file where `//` line comments and block +// comments are replaced with spaces of equal length. Offsets stay +// aligned with the original. We do NOT mask `${G0}`-style template- +// literal substitutions: each one contains a matched `{` and `}` that +// balance to net zero, so the brace walker ignores them naturally, and +// keeping them intact lets the marker regex still capture the group key. +function mask(text) { + return text + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) + .replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length)); +} + +function parseFormTs(text) { + const masked = mask(text); + const fields = []; + const markerRe = /\bciviField:\s*`\$\{(G\d)\}\.(\w+)`/g; + let m; + // Scan the masked text so commented-out `civiField:` lines don't get + // picked up and paired with the wrong enclosing `name:`. + while ((m = markerRe.exec(masked)) !== null) { + // Backward walk: find the `{` that opens the enclosing object. + let depth = 0; + let openOff = -1; + for (let i = m.index; i >= 0; i--) { + const ch = masked[i]; + if (ch === "}") depth++; + else if (ch === "{") { + if (depth === 0) { + openOff = i; + break; + } + depth--; + } + } + if (openOff < 0) continue; + + // Forward walk: find the matching `}`. + let closeOff = -1; + depth = 0; + for (let i = openOff; i < masked.length; i++) { + const ch = masked[i]; + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { + closeOff = i; + break; + } + } + } + if (closeOff < 0) continue; + + const block = text.slice(openOff, closeOff + 1); + const nameM = block.match(/\bname:\s*"([^"]+)"/); + if (!nameM) continue; + const helpM = block.match(/\bhelp:\s*"((?:[^"\\]|\\.)*)"/); + const helpIdxInBlock = helpM ? block.indexOf(helpM[0]) : -1; + + fields.push({ + name: nameM[1], + groupKey: m[1], + civiName: m[2], + currentHelp: helpM ? helpM[1] : null, + blockStart: openOff, + blockEnd: closeOff + 1, + helpStart: helpIdxInBlock >= 0 ? openOff + helpIdxInBlock : null, + helpEnd: helpIdxInBlock >= 0 ? openOff + helpIdxInBlock + helpM[0].length : null, + }); + } + return fields; +} + +// ── Diff + in-place rewrite ──────────────────────────────────────────── +function escForJsString(s) { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); +} + +function buildChanges(fields, civiMap) { + const changes = []; + for (const f of fields) { + const groupName = GROUPS[f.groupKey]; + if (!groupName) continue; + const civi = civiMap.get(`${groupName}.${f.civiName}`); + if (!civi) { + changes.push({ field: f, kind: "missing-in-civi" }); + continue; + } + const civiHelp = (civi.pre || civi.post || "").trim(); + if (!civiHelp) { + // Civi has no help. Don't blank out a non-empty config help - surface it + // for awareness but don't rewrite. + if (f.currentHelp) changes.push({ field: f, kind: "civi-empty" }); + continue; + } + if (f.currentHelp === civiHelp) continue; + changes.push({ + field: f, + kind: f.currentHelp ? "differs" : "missing-in-config", + civiHelp, + }); + } + return changes; +} + +function rewriteText(text, changes) { + // Bottom-up so earlier offsets stay valid as we splice. + const writable = changes + .filter((c) => c.kind === "differs" || c.kind === "missing-in-config") + .sort((a, b) => b.field.blockStart - a.field.blockStart); + + let out = text; + for (const c of writable) { + const f = c.field; + const newLiteral = `help: "${escForJsString(c.civiHelp)}"`; + if (f.helpStart != null) { + out = out.slice(0, f.helpStart) + newLiteral + out.slice(f.helpEnd); + continue; + } + const block = out.slice(f.blockStart, f.blockEnd); + const isOneLine = !block.includes("\n"); + if (isOneLine) { + const closeIdx = f.blockEnd - 1; // position of `}` + const before = out.slice(0, closeIdx); + const trailM = before.match(/[\s,]+$/); + const stripped = trailM ? before.slice(0, before.length - trailM[0].length) : before; + out = stripped + `, ${newLiteral} ` + out.slice(closeIdx); + } else { + // Multi-line: insert a new help: line just before the closing `}` line, + // using the indent of the first property after `{`. + const indentM = block.match(/\{\s*\n([ \t]+)\S/); + const indent = indentM ? indentM[1] : " "; + const closeIdx = f.blockEnd - 1; + const lastNL = out.lastIndexOf("\n", closeIdx); + const beforeClose = out.slice(0, lastNL); + const afterNL = out.slice(lastNL); + out = beforeClose + "\n" + indent + newLiteral + "," + afterNL; + } + } + return out; +} + +function truncate(s, n = 90) { + if (s.length <= n) return s; + return s.slice(0, n - 1) + "..."; +} + +// ── Main ─────────────────────────────────────────────────────────────── +async function main() { + const text = await readFile(FORM_TS_PATH, "utf8"); + const fields = parseFormTs(text); + console.log(`Parsed ${fields.length} field(s) with civiField from config/form.ts`); + + if (DEBUG) { + for (const f of fields) { + const grp = GROUPS[f.groupKey] ?? f.groupKey; + console.log( + ` - ${f.name} -> ${grp}.${f.civiName} ` + + (f.currentHelp ? `(help: ${truncate(f.currentHelp, 60)})` : "(no help)"), + ); + } + return; + } + + const civiMap = await fetchCiviHelp(); + console.log(`Fetched ${civiMap.size} CustomField row(s) from Civi`); + + const changes = buildChanges(fields, civiMap); + if (changes.length === 0) { + console.log("\nAll help text matches Civi. Nothing to do."); + return; + } + + console.log(`\n${changes.length} difference(s):\n`); + for (const c of changes) { + const f = c.field; + switch (c.kind) { + case "missing-in-civi": + console.log(` - ${f.name}: no matching CustomField in Civi (orphan in form.ts?)`); + if (f.currentHelp) console.log(` form: ${truncate(f.currentHelp)}`); + break; + case "civi-empty": + console.log(` - ${f.name}: form has help, Civi help is empty (keeping form's value, no rewrite)`); + console.log(` form: ${truncate(f.currentHelp)}`); + break; + case "missing-in-config": + console.log(` - ${f.name}: missing in config, will add`); + console.log(` civi: ${truncate(c.civiHelp)}`); + break; + case "differs": + console.log(` - ${f.name}: differs`); + console.log(` form: ${truncate(f.currentHelp)}`); + console.log(` civi: ${truncate(c.civiHelp)}`); + break; + } + } + + if (!WRITE) { + console.log("\n(Dry run. Re-run with --write to apply changes.)"); + return; + } + + const updated = rewriteText(text, changes); + await writeFile(FORM_TS_PATH, updated, "utf8"); + console.log("\nWrote changes to config/form.ts"); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/types/form.ts b/types/form.ts index a984245..63435f5 100644 --- a/types/form.ts +++ b/types/form.ts @@ -128,6 +128,29 @@ export interface MatrixGroupConfig { }>; } +/** + * Visual cluster for closely-related fields inside a section — e.g. a + * "Market Study" pair (date + file upload) that should read as one item + * with two inputs. Pure presentation: fields referenced here still live + * in `StageSectionConfig.fields` and submit/visibility logic walks them + * the same way as standalone fields. The renderer pulls grouped fields + * out of the section's per-field grid and renders them in their own + * bordered card above (or interleaved with) the ungrouped fields. + */ +export interface FieldGroupConfig { + /** Unique id within the section, e.g. "market_study". */ + id: string; + /** Group heading shown above the cluster. Omit for a heading-less card. */ + label?: string; + /** Optional helper text rendered below the label. */ + intro?: string; + /** + * Names of fields in the parent section that belong to this group, in + * left-to-right / top-to-bottom render order. + */ + fields: string[]; +} + export interface StageSectionConfig { /** Stage rank, 0..5. Used by the conditional engine and the accordion. */ rank: number; @@ -144,6 +167,15 @@ export interface StageSectionConfig { */ visibleWhen?: VisibilityRule; fields: FieldConfig[]; + /** + * Optional visual clusters of related fields. Fields referenced here are + * still defined in `fields` above; this list is an overlay that tells the + * renderer to draw them as a sub-card with a shared heading. A field + * named in more than one group is rendered in the first group it appears + * in. Field-level `visibleWhen` still applies inside a group — a fully + * hidden group renders nothing. + */ + fieldGroups?: FieldGroupConfig[]; /** * Matrix groups rendered above the per-field grid. Fields referenced in * any matrix are excluded from the per-field grid (so a Y1 monthly sales