Template flow cleaned up
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { methodSlugFromTitle } from "../create/methodSlugFromTitle";
|
||||
import type { SectionId } from "./validation/methodFacetsSchemas";
|
||||
|
||||
/**
|
||||
@@ -21,18 +22,7 @@ const CATEGORY_NAME_TO_SECTION: Record<string, SectionId> = {
|
||||
"Conflict management": "conflictManagement",
|
||||
};
|
||||
|
||||
export function methodSlugFromTitle(title: string): string {
|
||||
// Match the slugify rules of the one-time messages ingest: NFKD-normalize,
|
||||
// strip diacritics, drop apostrophes/brackets, collapse non-alphanumerics
|
||||
// to single hyphens, trim leading/trailing hyphens.
|
||||
const folded = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
|
||||
const stripped = folded
|
||||
.toLowerCase()
|
||||
.replace(/['’`()\[\]]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return stripped;
|
||||
}
|
||||
export { methodSlugFromTitle };
|
||||
|
||||
type RuleTemplateBodySection = {
|
||||
categoryName?: unknown;
|
||||
|
||||
@@ -67,6 +67,13 @@ export const createFlowStateSchema = z
|
||||
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
|
||||
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
|
||||
selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
|
||||
pendingTemplateAction: z
|
||||
.object({
|
||||
slug: z.string().max(200),
|
||||
mode: z.enum(["customize", "useWithoutChanges"]),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
currentStep: createFlowStepSchema.optional(),
|
||||
sections: z.array(z.unknown()).optional(),
|
||||
stakeholders: z.array(z.unknown()).optional(),
|
||||
|
||||
Reference in New Issue
Block a user