Template flow cleaned up

This commit is contained in:
adilallo
2026-04-20 16:45:15 -06:00
parent d3bb8cdd0f
commit c08cd62872
32 changed files with 1545 additions and 254 deletions
+145
View File
@@ -0,0 +1,145 @@
import type {
CommunityStructureChipSnapshotRow,
CreateFlowState,
} from "../../app/(app)/create/types";
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
import { methodSlugFromTitle } from "./methodSlugFromTitle";
type TemplateEntry = { title: unknown };
type TemplateSection = { categoryName: unknown; entries: unknown };
function isTemplateSection(x: unknown): x is TemplateSection {
if (!x || typeof x !== "object") return false;
const o = x as Record<string, unknown>;
return typeof o.categoryName === "string" && Array.isArray(o.entries);
}
function entryTitles(entries: unknown): string[] {
if (!Array.isArray(entries)) return [];
const out: string[] = [];
for (const raw of entries) {
if (!raw || typeof raw !== "object") continue;
const title = (raw as TemplateEntry).title;
if (typeof title !== "string") continue;
const trimmed = title.trim();
if (trimmed.length > 0) out.push(trimmed);
}
return out;
}
/** Normalise a Figma template category header ("Decision-making") for matching. */
function normaliseCategoryKey(name: string): string {
return name.toLowerCase().replace(/[^a-z]+/g, "");
}
/** Preset core-value labels with the chip id (1-based preset index as string) the select screen expects. */
type CorePresetRow = { id: string; label: string };
const CORE_VALUE_PRESETS: readonly CorePresetRow[] = (() => {
const raw = (coreValuesMessages as { values: unknown }).values;
if (!Array.isArray(raw)) return [];
return raw.map((v, i) => ({
id: String(i + 1),
label: typeof v === "string" ? v : (v as { label: string }).label,
}));
})();
function buildCoreValuePrefill(
titles: readonly string[],
): Pick<CreateFlowState, "selectedCoreValueIds" | "coreValuesChipsSnapshot"> {
const wantedByLower = new Map<string, string>();
for (const t of titles) wantedByLower.set(t.toLowerCase(), t);
const selected: string[] = [];
const snapshot: CommunityStructureChipSnapshotRow[] = [];
for (const preset of CORE_VALUE_PRESETS) {
const isSelected = wantedByLower.delete(preset.label.toLowerCase());
snapshot.push({
id: preset.id,
label: preset.label,
state: isSelected ? "selected" : "unselected",
});
if (isSelected) selected.push(preset.id);
}
// Any template labels not matching a preset ride along as custom chip rows
// so templates authored with bespoke values still pre-select on the screen.
for (const original of wantedByLower.values()) {
const id = `template-cv-${methodSlugFromTitle(original) || snapshot.length}`;
snapshot.push({ id, label: original, state: "selected" });
selected.push(id);
}
return {
selectedCoreValueIds: selected,
coreValuesChipsSnapshot: snapshot,
};
}
/**
* Map a curated template `body` (DB shape — `sections[]` with `categoryName`
* + `entries[].title`) to the `CreateFlowState` keys the Create Custom Rule
* screens read for pre-selection. Used by the "Customize" handler on
* `/create/review-template/[slug]` so clicking Customize drops the user into
* the custom-rule flow with the template's chips already highlighted.
*
* Produces:
* - `selectedCoreValueIds` + `coreValuesChipsSnapshot` — preset match by
* label; non-matching titles become custom chip rows so bespoke template
* values still appear selected.
* - `selectedCommunicationMethodIds`, `selectedMembershipMethodIds`,
* `selectedDecisionApproachIds`, `selectedConflictManagementIds` — chip
* ids derived via {@link methodSlugFromTitle}, matching the `methods[].id`
* produced by the one-time messages ingest.
*
* Returns an empty object for malformed bodies (no sections array).
*/
export function buildTemplateCustomizePrefill(
body: unknown,
): Partial<CreateFlowState> {
if (!body || typeof body !== "object") return {};
const sections = (body as { sections?: unknown }).sections;
if (!Array.isArray(sections)) return {};
const prefill: Partial<CreateFlowState> = {};
for (const raw of sections) {
if (!isTemplateSection(raw)) continue;
const key = normaliseCategoryKey(raw.categoryName as string);
const titles = entryTitles(raw.entries);
if (titles.length === 0) continue;
if (key === "values" || key === "corevalues") {
Object.assign(prefill, buildCoreValuePrefill(titles));
continue;
}
const slugs = titles.map(methodSlugFromTitle).filter((s) => s.length > 0);
if (slugs.length === 0) continue;
switch (key) {
case "communication":
case "communications":
prefill.selectedCommunicationMethodIds = slugs;
break;
case "membership":
case "memberships":
prefill.selectedMembershipMethodIds = slugs;
break;
case "decisionmaking":
case "decisionapproaches":
case "decisions":
prefill.selectedDecisionApproachIds = slugs;
break;
case "conflictmanagement":
case "conflict":
case "conflictresolution":
prefill.selectedConflictManagementIds = slugs;
break;
default:
break;
}
}
return prefill;
}
+152
View File
@@ -0,0 +1,152 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import communicationMessages from "../../messages/en/create/customRule/communication.json";
import conflictManagementMessages from "../../messages/en/create/customRule/conflictManagement.json";
import decisionApproachesMessages from "../../messages/en/create/customRule/decisionApproaches.json";
import membershipMessages from "../../messages/en/create/customRule/membership.json";
import {
buildCoreValuesForDocument,
parseSectionsFromCreateFlowState,
} from "./buildPublishPayload";
/**
* Chip row shape shared with `messages/en/create/reviewAndComplete/finalReview.json`
* so the final-review screen can keep its existing category → chip label rendering
* contract regardless of whether chips came from state or from fallback content.
*/
export type FinalReviewCategoryRow = { name: string; chips: string[] };
/** Category labels supplied by the caller (pulled from localized messages). */
export type FinalReviewCategoryNames = {
values: string;
communication: string;
membership: string;
decisions: string;
conflict: string;
};
type MethodPreset = { id: string; label: string };
function readMethodsArray(source: unknown): MethodPreset[] {
if (!source || typeof source !== "object") return [];
const methods = (source as { methods?: unknown }).methods;
if (!Array.isArray(methods)) return [];
const out: MethodPreset[] = [];
for (const raw of methods) {
if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>;
if (typeof o.id === "string" && typeof o.label === "string") {
out.push({ id: o.id, label: o.label });
}
}
return out;
}
function labelsFromIds(
ids: readonly string[] | undefined,
methods: readonly MethodPreset[],
): string[] {
if (!ids || ids.length === 0) return [];
const byId = new Map(methods.map((m) => [m.id, m.label] as const));
const seen = new Set<string>();
const out: string[] = [];
for (const id of ids) {
const label = byId.get(id);
if (typeof label !== "string" || label.length === 0) continue;
if (seen.has(label)) continue;
seen.add(label);
out.push(label);
}
return out;
}
/**
* Derive the final-review RuleCard category rows from the current
* {@link CreateFlowState}.
*
* Two-mode contract, mirroring the two template entry points:
* 1. **Use without changes** — `state.sections` carries the applied template
* body; we render it verbatim (`categoryName` + entry `title`s). Core
* values still come from `buildCoreValuesForDocument` when they were
* captured separately.
* 2. **Customize / plain custom-rule flow** — each Create Custom screen writes
* its selection ids into a dedicated state field. We resolve those ids
* against the curated message `methods[]` list to get the display labels,
* matching what the user saw as chips in-flow.
*
* Empty categories are filtered out so the review card doesn't render headings
* with no chips. If nothing in state resolves to any chip, the caller should
* fall back to the demo categories shipped in `finalReview.json`.
*/
export function buildFinalReviewCategoriesFromState(
state: CreateFlowState,
names: FinalReviewCategoryNames,
): FinalReviewCategoryRow[] {
const sections = parseSectionsFromCreateFlowState(state);
const coreValueLabels = buildCoreValuesForDocument(state).map((r) => r.label);
// Use-without-changes / pre-rendered template body: the sections array is
// the source of truth. Collapse each section's entries to its titles; the
// RuleCard category UI shows only labels, not per-entry body copy.
if (sections.length > 0) {
const rows: FinalReviewCategoryRow[] = [];
// If core values were also captured (e.g., the template surfaced both),
// keep them up top for visual parity with the custom-rule flow. Otherwise
// any `Values` section already inside `sections` covers the same ground.
if (coreValueLabels.length > 0) {
const hasValuesSection = sections.some(
(s) => s.categoryName.toLowerCase() === names.values.toLowerCase(),
);
if (!hasValuesSection) {
rows.push({ name: names.values, chips: coreValueLabels });
}
}
for (const s of sections) {
const chips = s.entries
.map((e) => e.title.trim())
.filter((t) => t.length > 0);
if (chips.length === 0) continue;
rows.push({ name: s.categoryName, chips });
}
return rows;
}
const communicationMethods = readMethodsArray(communicationMessages);
const membershipMethods = readMethodsArray(membershipMessages);
const decisionApproachMethods = readMethodsArray(decisionApproachesMessages);
const conflictManagementMethods = readMethodsArray(conflictManagementMessages);
const rows: FinalReviewCategoryRow[] = [
{ name: names.values, chips: coreValueLabels },
{
name: names.communication,
chips: labelsFromIds(
state.selectedCommunicationMethodIds,
communicationMethods,
),
},
{
name: names.membership,
chips: labelsFromIds(
state.selectedMembershipMethodIds,
membershipMethods,
),
},
{
name: names.decisions,
chips: labelsFromIds(
state.selectedDecisionApproachIds,
decisionApproachMethods,
),
},
{
name: names.conflict,
chips: labelsFromIds(
state.selectedConflictManagementIds,
conflictManagementMethods,
),
},
];
return rows.filter((r) => r.chips.length > 0);
}
+44
View File
@@ -0,0 +1,44 @@
import messages from "../../messages/en/index";
import {
fetchTemplateBySlug,
type RuleTemplateDto,
} from "./fetchTemplates";
export type LoadTemplateReviewResult =
| { ok: true; template: RuleTemplateDto }
| { ok: false; message: string };
/**
* Shared prelude for the two template-review actions (Customize and
* "Use without changes") in `CreateFlowLayoutClient`. Wraps the slug →
* `RuleTemplateDto` fetch and normalizes its three possible failures
* (network / server / not-found) into a single localized error message
* suitable for the template-review banner.
*
* Keeping the localized copy here (rather than in the fetch layer) means
* callers only forward `result.message` to `setTemplateReviewApplyError`,
* and both handlers resolve identical error text from a single source.
*
* Malformed template bodies (`body` not an object, missing `sections`,
* etc.) remain the caller's responsibility because the expected shape
* differs between Customize (prefill lookup) and Use-without-changes
* (full section extraction). Those checks stay in the handlers that need
* them so errors surface at the step where the shape matters.
*/
export async function loadTemplateReviewBySlug(
slug: string,
): Promise<LoadTemplateReviewResult> {
const errors = messages.create.templateReview.errors;
const result = await fetchTemplateBySlug(slug);
if (result === null) {
return { ok: false, message: errors.notFound };
}
if ("error" in result) {
const trimmed = typeof result.error === "string" ? result.error.trim() : "";
return {
ok: false,
message: trimmed.length > 0 ? trimmed : errors.applyFailed,
};
}
return { ok: true, template: result };
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Client-safe slugifier that mirrors the one-time ingest that produced
* `data/create/customRule/<section>.json` `methods[].id`. Lives in
* `lib/create/` (not `lib/server/`) so client code — specifically the
* template "Customize" prefill — can map template entry titles to the chip
* ids the customize screens read out of `CreateFlowState`.
*
* Rules: NFKD-normalize, strip diacritics, drop apostrophes/brackets,
* collapse non-alphanumerics to single hyphens, trim leading/trailing
* hyphens. Server-side `lib/server/templateMethods.ts` re-exports this.
*/
export function methodSlugFromTitle(title: string): string {
const folded = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
const stripped = folded
.toLowerCase()
.replace(/['`()\[\]]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return stripped;
}