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
+27
View File
@@ -14,6 +14,8 @@ import {
GOVERNANCE_TEMPLATE_HOME_SLUGS,
getGovernanceTemplatesForHome,
} from "../../lib/templates/governanceTemplateCatalog";
import { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage";
import { CORE_VALUE_DETAILS_STORAGE_KEY } from "../../app/(app)/create/utils/coreValueDetailsLocalStorage";
const homeFeatured = getGovernanceTemplatesForHome();
@@ -212,6 +214,31 @@ describe("RuleStack Component", () => {
debugSpy.mockRestore();
});
test("template click from home wipes any stale anonymous draft", async () => {
window.localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({ title: "Stale Community" }),
);
window.localStorage.setItem(
CORE_VALUE_DETAILS_STORAGE_KEY,
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
);
const user = userEvent.setup();
render(<RuleStack />);
await waitForRuleStackCards();
const consensusCard = screen.getByText("Consensus").closest("div");
await user.click(consensusCard);
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
).toBeNull();
window.localStorage.clear();
});
test("renders with proper semantic structure", async () => {
render(<RuleStack />);
await waitForRuleStackCards();
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { buildTemplateCustomizePrefill } from "../../lib/create/applyTemplatePrefill";
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
function coreValuePresetId(label: string): string {
const values = coreValuesMessages.values as Array<
string | { label: string }
>;
const idx = values.findIndex((v) => {
const l = typeof v === "string" ? v : v.label;
return l.toLowerCase() === label.toLowerCase();
});
return String(idx + 1);
}
describe("buildTemplateCustomizePrefill", () => {
it("returns an empty object for malformed bodies", () => {
expect(buildTemplateCustomizePrefill(null)).toEqual({});
expect(buildTemplateCustomizePrefill({})).toEqual({});
expect(buildTemplateCustomizePrefill({ sections: "nope" })).toEqual({});
});
it("maps communication / membership / decisions / conflict titles to method-id slugs", () => {
const body = {
sections: [
{
categoryName: "Communication",
entries: [
{ title: "In-Person Meetings", body: "x" },
{ title: "Loomio", body: "y" },
],
},
{
categoryName: "Membership",
entries: [{ title: "Peer Sponsorship", body: "m" }],
},
{
categoryName: "Decision-making",
entries: [{ title: "Consensus Decision-Making", body: "d" }],
},
{
categoryName: "Conflict management",
entries: [{ title: "Restorative Justice", body: "c" }],
},
],
};
expect(buildTemplateCustomizePrefill(body)).toEqual({
selectedCommunicationMethodIds: ["in-person-meetings", "loomio"],
selectedMembershipMethodIds: ["peer-sponsorship"],
selectedDecisionApproachIds: ["consensus-decision-making"],
selectedConflictManagementIds: ["restorative-justice"],
});
});
it("matches template Values against the preset list and marks them selected", () => {
const body = {
sections: [
{
categoryName: "Values",
entries: [
{ title: "Consensus", body: "" },
{ title: "Community Care", body: "" },
],
},
],
};
const prefill = buildTemplateCustomizePrefill(body);
const selected = prefill.selectedCoreValueIds ?? [];
expect(selected).toContain(coreValuePresetId("Consensus"));
expect(selected).toContain(coreValuePresetId("Community Care"));
const snapshot = prefill.coreValuesChipsSnapshot ?? [];
const selectedRows = snapshot.filter((r) => r.state === "selected");
expect(selectedRows.map((r) => r.label).sort()).toEqual([
"Community Care",
"Consensus",
]);
// Unmatched presets should still appear, as unselected, so the screen
// renders the full chip list (the select screen reads the snapshot as-is).
expect(snapshot.length).toBeGreaterThan(selectedRows.length);
});
it("preserves bespoke template values as custom chip rows", () => {
const body = {
sections: [
{
categoryName: "Values",
entries: [{ title: "Very Bespoke Thing", body: "" }],
},
],
};
const prefill = buildTemplateCustomizePrefill(body);
const custom = (prefill.coreValuesChipsSnapshot ?? []).find(
(r) => r.label === "Very Bespoke Thing",
);
expect(custom).toBeDefined();
expect(custom?.state).toBe("selected");
expect(prefill.selectedCoreValueIds).toContain(custom?.id);
});
it("ignores unknown category names", () => {
const prefill = buildTemplateCustomizePrefill({
sections: [
{ categoryName: "Mystery", entries: [{ title: "What", body: "" }] },
],
});
expect(prefill).toEqual({});
});
});
@@ -0,0 +1,135 @@
import { describe, it, expect } from "vitest";
import { buildFinalReviewCategoriesFromState } from "../../lib/create/buildFinalReviewCategories";
import type { CreateFlowState } from "../../app/(app)/create/types";
const NAMES = {
values: "Values",
communication: "Communication",
membership: "Membership",
decisions: "Decision-making",
conflict: "Conflict management",
};
describe("buildFinalReviewCategoriesFromState", () => {
it("returns [] when state has no selections and no sections", () => {
expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual([]);
});
it("resolves method ids to labels from message presets", () => {
// IDs here match `messages/en/create/customRule/*.json` `methods[].id`.
// Same shape `buildTemplateCustomizePrefill` emits via methodSlugFromTitle.
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", "in-person-meetings"],
selectedMembershipMethodIds: ["open-access"],
selectedDecisionApproachIds: ["lazy-consensus"],
selectedConflictManagementIds: ["peer-mediation"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
const byName = new Map(rows.map((r) => [r.name, r.chips]));
expect(byName.get("Communication")).toEqual([
"Signal",
"In-Person Meetings",
]);
expect(byName.get("Membership")).toEqual(["Open Access"]);
expect(byName.get("Decision-making")).toEqual(["Lazy Consensus"]);
expect(byName.get("Conflict management")).toEqual(["Peer Mediation"]);
expect(byName.has("Values")).toBe(false);
});
it("derives core values via buildCoreValuesForDocument (snapshot + selected ids)", () => {
const state: CreateFlowState = {
selectedCoreValueIds: ["1", "custom-one"],
coreValuesChipsSnapshot: [
{ id: "1", label: "Accessibility", state: "selected" },
{ id: "2", label: "Accountability", state: "unselected" },
{ id: "custom-one", label: "Resilience", state: "selected" },
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Accessibility", "Resilience"] },
]);
});
it("drops unknown ids silently instead of inserting empty labels", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", "bogus-id"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
});
it("dedupes repeated labels from duplicate ids", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", "signal"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
});
it("prefers state.sections when populated (use-without-changes path)", () => {
const state: CreateFlowState = {
sections: [
{
categoryName: "Values",
entries: [
{ title: "Consciousness", body: "…" },
{ title: "Ecology", body: "…" },
],
},
{
categoryName: "Communication",
entries: [{ title: "Signal", body: "…" }],
},
],
// Selection ids must be ignored when sections is present — the
// "Use without changes" handler resets them for exactly that reason,
// but we double-check the helper honors the sections branch first.
selectedCommunicationMethodIds: ["in-person-meetings"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Consciousness", "Ecology"] },
{ name: "Communication", chips: ["Signal"] },
]);
});
it("prepends a Values row from coreValuesChipsSnapshot when sections lack one", () => {
const state: CreateFlowState = {
sections: [
{
categoryName: "Communication",
entries: [{ title: "Signal", body: "…" }],
},
],
selectedCoreValueIds: ["1"],
coreValuesChipsSnapshot: [
{ id: "1", label: "Accessibility", state: "selected" },
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Accessibility"] },
{ name: "Communication", chips: ["Signal"] },
]);
});
it("does not duplicate Values when sections already includes one", () => {
const state: CreateFlowState = {
sections: [
{
categoryName: "Values",
entries: [{ title: "Consciousness", body: "…" }],
},
],
selectedCoreValueIds: ["1"],
coreValuesChipsSnapshot: [
{ id: "1", label: "Accessibility", state: "selected" },
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Consciousness"] },
]);
});
});
-38
View File
@@ -1,38 +0,0 @@
import { describe, it, expect } from "vitest";
import { hasCreateFlowUserInput } from "../../app/(app)/create/utils/hasCreateFlowUserInput";
describe("hasCreateFlowUserInput", () => {
it("returns false for empty state", () => {
expect(hasCreateFlowUserInput({})).toBe(false);
});
it("ignores currentStep alone", () => {
expect(hasCreateFlowUserInput({ currentStep: "informational" })).toBe(
false,
);
});
it("returns true for non-empty title", () => {
expect(hasCreateFlowUserInput({ title: "My rule" })).toBe(true);
});
it("returns false for whitespace-only title", () => {
expect(hasCreateFlowUserInput({ title: " " })).toBe(false);
});
it("returns true for non-empty sections array", () => {
expect(hasCreateFlowUserInput({ sections: [{}] })).toBe(true);
});
it("returns false for empty sections array", () => {
expect(hasCreateFlowUserInput({ sections: [] })).toBe(false);
});
it("returns true for extra step-specific keys with content", () => {
expect(hasCreateFlowUserInput({ cards: ["a"] })).toBe(true);
});
it("returns false for extra keys with empty object", () => {
expect(hasCreateFlowUserInput({ foo: {} })).toBe(false);
});
});