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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useId, useMemo, useState } from "react";
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
import type { StageSectionConfig, SelectOption } from "@/types/form";
|
import type { FieldConfig, StageSectionConfig, SelectOption } from "@/types/form";
|
||||||
import type {
|
import type {
|
||||||
UseFormRegister,
|
UseFormRegister,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -79,12 +79,51 @@ export function StageSection({
|
|||||||
return names;
|
return names;
|
||||||
}, [section.matrixGroups]);
|
}, [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(
|
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 =
|
const fieldCount =
|
||||||
visibleFields.length +
|
visibleFields.length +
|
||||||
|
visibleGroups.reduce((n, g) => n + g.fields.length, 0) +
|
||||||
(section.matrixGroups ?? []).reduce(
|
(section.matrixGroups ?? []).reduce(
|
||||||
(n, g) => n + g.rows.reduce((m, r) => m + r.fields.length, 0),
|
(n, g) => n + g.rows.reduce((m, r) => m + r.fields.length, 0),
|
||||||
0,
|
0,
|
||||||
@@ -178,7 +217,26 @@ export function StageSection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<p className="text-sm italic text-ink-mute">No fields are visible at this stage.</p>
|
||||||
) : visibleFields.length === 0 ? null : (
|
) : visibleFields.length === 0 ? null : (
|
||||||
<div className="grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2">
|
<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 }) {
|
function Chevron({ open }: { open: boolean }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -365,6 +365,29 @@ const stage2: StageSectionConfig = {
|
|||||||
help: "What governance system does your board use, or how do you make decisions?",
|
help: "What governance system does your board use, or how do you make decisions?",
|
||||||
},
|
},
|
||||||
] as FieldConfig[],
|
] as FieldConfig[],
|
||||||
|
// Visual clustering — each pair (date + upload) reads as one item.
|
||||||
|
fieldGroups: [
|
||||||
|
{
|
||||||
|
id: "market_study",
|
||||||
|
label: "Market Study",
|
||||||
|
fields: ["Market_Study_Date", "Market_Study_Upload"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro_forma",
|
||||||
|
label: "Pro Forma",
|
||||||
|
fields: ["Pro_Forma_date_completed", "Pro_Forma_Upload"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "business_plan",
|
||||||
|
label: "Business Plan",
|
||||||
|
fields: ["Business_Plan", "Business_Plan_Upload"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "board_self_assessment",
|
||||||
|
label: "Board Self Assessment",
|
||||||
|
fields: ["Board_Self_Assessment", "Board_Self_Assessment_Upload"],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stage 3 — Connect & Gather
|
// Stage 3 — Connect & Gather
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// scripts/sync-help-from-civi.mjs
|
||||||
|
//
|
||||||
|
// One-off / on-demand sync of per-field help text from CiviCRM into
|
||||||
|
// config/form.ts. Civi stores help on CustomField as `help_pre` (shown
|
||||||
|
// above the input) and `help_post` (shown below); this script prefers
|
||||||
|
// help_pre and falls back to help_post.
|
||||||
|
//
|
||||||
|
// USAGE
|
||||||
|
// node --env-file=.env.local scripts/sync-help-from-civi.mjs # dry run
|
||||||
|
// node --env-file=.env.local scripts/sync-help-from-civi.mjs --write # apply changes
|
||||||
|
// node scripts/sync-help-from-civi.mjs --debug # print parsed fields, no Civi call
|
||||||
|
//
|
||||||
|
// Or via npm:
|
||||||
|
// npm run sync-help
|
||||||
|
// npm run sync-help -- --write
|
||||||
|
//
|
||||||
|
// REQUIRED ENV
|
||||||
|
// CIVI_BASE_URL, CIVI_API_KEY, CIVI_SITE_KEY
|
||||||
|
// (optional) CIVI_HTTP_AUTH_USER, CIVI_HTTP_AUTH_PASS for sites behind Basic Auth.
|
||||||
|
//
|
||||||
|
// WHY A SCRIPT, NOT A RUNTIME FETCH
|
||||||
|
// This form is low-traffic and the help text doesn't change once in
|
||||||
|
// production use. Running this manually when staff edit help in Civi
|
||||||
|
// is leaner than coupling every form load (or every build) to a Civi
|
||||||
|
// API call. Keeps git history honest: every help-text change shows up
|
||||||
|
// as a normal source edit you can review/revert.
|
||||||
|
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const WRITE = args.includes("--write");
|
||||||
|
const DEBUG = args.includes("--debug");
|
||||||
|
|
||||||
|
// Keep in sync with config/form.ts G0..G5 declarations. If a new stage
|
||||||
|
// custom group is added there, mirror it here.
|
||||||
|
const GROUPS = {
|
||||||
|
G0: "Check_in_data__organizing_",
|
||||||
|
G1: "Stage_1",
|
||||||
|
G2: "Stage_2",
|
||||||
|
G3: "Stage_3",
|
||||||
|
G4: "Stage_4",
|
||||||
|
G5: "Stage_5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FORM_TS_PATH = resolve(HERE, "..", "config", "form.ts");
|
||||||
|
|
||||||
|
// ── CiviCRM APIv4 client (minimal, matches lib/civicrm.ts) ─────────────
|
||||||
|
async function civi(entity, action, params) {
|
||||||
|
const {
|
||||||
|
CIVI_BASE_URL,
|
||||||
|
CIVI_API_KEY,
|
||||||
|
CIVI_SITE_KEY,
|
||||||
|
CIVI_HTTP_AUTH_USER,
|
||||||
|
CIVI_HTTP_AUTH_PASS,
|
||||||
|
} = process.env;
|
||||||
|
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY. " +
|
||||||
|
"Put them in .env.local and run via `node --env-file=.env.local ...`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
|
||||||
|
"X-Civi-Key": CIVI_SITE_KEY,
|
||||||
|
};
|
||||||
|
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
|
||||||
|
headers["Authorization"] =
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: new URLSearchParams({ params: JSON.stringify(params) }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Civi ${entity}.${action} failed (HTTP ${res.status}): ${await res.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCiviHelp() {
|
||||||
|
const result = await civi("CustomField", "get", {
|
||||||
|
select: ["name", "custom_group_id.name", "help_pre", "help_post"],
|
||||||
|
where: [["custom_group_id.name", "IN", Object.values(GROUPS)]],
|
||||||
|
limit: 0,
|
||||||
|
});
|
||||||
|
const map = new Map(); // "Group_Name.Field_Name" -> { 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);
|
||||||
|
});
|
||||||
@@ -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 {
|
export interface StageSectionConfig {
|
||||||
/** Stage rank, 0..5. Used by the conditional engine and the accordion. */
|
/** Stage rank, 0..5. Used by the conditional engine and the accordion. */
|
||||||
rank: number;
|
rank: number;
|
||||||
@@ -144,6 +167,15 @@ export interface StageSectionConfig {
|
|||||||
*/
|
*/
|
||||||
visibleWhen?: VisibilityRule;
|
visibleWhen?: VisibilityRule;
|
||||||
fields: FieldConfig[];
|
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
|
* 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
|
* any matrix are excluded from the per-field grid (so a Y1 monthly sales
|
||||||
|
|||||||
Reference in New Issue
Block a user