Custom add and create flow polish

This commit is contained in:
adilallo
2026-05-08 20:32:24 -06:00
parent 26bcd61ea3
commit 026a1e6d71
68 changed files with 6208 additions and 527 deletions
@@ -166,4 +166,52 @@ describe("applyFinalReviewChipEditPatch", () => {
"550e8400-e29b-41d4-a716-446655440000": patch.customMethodCardFieldBlocks,
});
});
it("merges customMethodCardMetaById when the patch carries methodCardMeta", () => {
const state: CreateFlowState = {
customMethodCardMetaById: {
signal: { label: "Signal", supportText: "Old" },
},
};
const patch: FinalReviewChipEditPatch = {
groupKey: "communication",
overrideKey: "signal",
value: {
corePrinciple: "p",
logisticsAdmin: "l",
codeOfConduct: "c",
},
methodCardMeta: { label: "Signal (edited)", supportText: "New sub" },
};
const result = applyFinalReviewChipEditPatch(state, patch);
expect(result.customMethodCardMetaById).toEqual({
signal: { label: "Signal (edited)", supportText: "New sub" },
});
});
it("updates coreValuesChipsSnapshot label when patch carries chipLabel", () => {
const state: CreateFlowState = {
coreValuesChipsSnapshot: [
{ id: "1", label: "Accessibility", state: "selected" },
],
coreValueDetailsByChipId: { "1": { meaning: "m", signals: "s" } },
};
const patch: FinalReviewChipEditPatch = {
groupKey: "coreValues",
overrideKey: "1",
value: { meaning: "m2", signals: "s2" },
chipLabel: "A11y renamed",
};
const result = applyFinalReviewChipEditPatch(state, patch);
expect(result.coreValuesChipsSnapshot).toEqual([
{ id: "1", label: "A11y renamed", state: "selected" },
]);
expect(result.coreValueDetailsByChipId).toEqual({
"1": { meaning: "m2", signals: "s2" },
});
});
});
+37 -5
View File
@@ -80,7 +80,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
});
it("prefers state.sections when populated (use-without-changes path)", () => {
it("uses section titles for method facets when selections were cleared (use-without-changes)", () => {
const state: CreateFlowState = {
sections: [
{
@@ -95,10 +95,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
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"],
selectedCommunicationMethodIds: [],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
@@ -107,6 +104,41 @@ describe("buildFinalReviewCategoriesFromState", () => {
]);
});
it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => {
const state: CreateFlowState = {
sections: [
{
categoryName: "Communication",
entries: [{ title: "Signal", body: "…" }],
},
],
selectedCommunicationMethodIds: ["in-person-meetings"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Communication", chips: ["In-Person Meetings"] },
]);
});
it("shows custom communication chips when sections exist and selections include a UUID", () => {
const customId = "00000000-0000-4000-8000-000000000099";
const state: CreateFlowState = {
sections: [
{
categoryName: "Communication",
entries: [{ title: "Signal", body: "…" }],
},
],
selectedCommunicationMethodIds: ["signal", customId],
customMethodCardMetaById: {
[customId]: { label: "Garden IRC", supportText: "x" },
},
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
const comm = rows.find((r) => r.name === "Communication");
expect(comm?.chips).toEqual(["Signal", "Garden IRC"]);
});
it("prepends a Values row from coreValuesChipsSnapshot when sections lack one", () => {
const state: CreateFlowState = {
sections: [
+37
View File
@@ -188,6 +188,43 @@ describe("buildPublishPayload — methodSelections", () => {
expect(ms?.communication?.[0]?.label).toBe("Custom Comm");
});
it("embeds wizard field blocks in published Communication sections for custom UUID ids", () => {
const customId = "00000000-0000-4000-8000-000000000099";
const r = buildPublishPayload({
title: "T",
selectedCommunicationMethodIds: [customId],
sections: [
{
categoryName: "Communication",
entries: [{ title: "Template row", body: "placeholder" }],
},
],
customMethodCardMetaById: {
[customId]: { label: "Wizard title", supportText: "" },
},
customMethodCardFieldBlocksById: {
[customId]: [
{
kind: "text",
id: "b1",
blockTitle: "Field A",
placeholderText: "User-authored body",
},
],
},
});
expect(r.ok).toBe(true);
if (!r.ok) return;
const secs = r.document.sections as Array<{
categoryName: string;
entries: Array<{ blocks?: Array<{ label: string; body: string }> }>;
}>;
const comm = secs.find((s) => s.categoryName === "Communication");
expect(comm?.entries[0]?.blocks).toEqual([
{ label: "Field A", body: "User-authored body" },
]);
});
it("emits preset-only sections when a method is selected without an override", () => {
const r = buildPublishPayload({
title: "T",
@@ -0,0 +1,82 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { CreateFlowState } from "../../../app/(app)/create/types";
const deleteServerDraft = vi.fn();
const saveDraftToServer = vi.fn();
const updatePublishedRule = vi.fn();
vi.mock("../../../lib/create/buildPublishPayload", () => ({
buildPublishPayload: vi.fn(() => ({
ok: true as const,
title: "T",
summary: "S",
document: {},
})),
}));
vi.mock("../../../lib/create/api", () => ({
deleteServerDraft: (...args: unknown[]) => deleteServerDraft(...args),
saveDraftToServer: (...args: unknown[]) => saveDraftToServer(...args),
updatePublishedRule: (...args: unknown[]) => updatePublishedRule(...args),
}));
vi.mock("../../../lib/create/lastPublishedRule", () => ({
writeLastPublishedRule: vi.fn(),
}));
async function loadExitHook() {
return import("../../../app/(app)/create/hooks/useCreateFlowExit");
}
describe("useCreateFlowExit", () => {
const router = { push: vi.fn() };
const clearState = vi.fn();
const user = { id: "u1", email: "a@b.c" };
beforeEach(async () => {
vi.resetModules();
vi.unstubAllEnvs();
vi.stubEnv("NEXT_PUBLIC_ENABLE_BACKEND_SYNC", "true");
deleteServerDraft.mockReset();
saveDraftToServer.mockReset();
updatePublishedRule.mockReset();
router.push.mockReset();
clearState.mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("does not delete the server draft after updating a published rule (preserves other in-progress work)", async () => {
updatePublishedRule.mockResolvedValue({ ok: true as const });
const { useCreateFlowExit } = await loadExitHook();
const state: CreateFlowState = {
editingPublishedRuleId: "rule-1",
};
const { result } = renderHook(() =>
useCreateFlowExit({
state,
currentStep: "edit-rule",
clearState,
router,
user,
}),
);
await act(async () => {
await result.current({ saveDraft: true });
});
expect(updatePublishedRule).toHaveBeenCalledWith(
"rule-1",
expect.objectContaining({ title: "T" }),
);
expect(deleteServerDraft).not.toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith("/");
});
});
@@ -22,4 +22,23 @@ describe("mergePresetMethodsWithCustom", () => {
supportText: "cx",
});
});
it("overlays meta label/supportText onto preset ids for card display", () => {
const presets = [
{ id: "signal", label: "Signal", supportText: "preset sub" },
];
const merged = mergePresetMethodsWithCustom(
presets,
["signal"],
{
signal: { label: "Renamed", supportText: "user sub" },
},
);
expect(merged).toHaveLength(1);
expect(merged[0]).toEqual({
id: "signal",
label: "Renamed",
supportText: "user sub",
});
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { communicationPresetFor } from "../../lib/create/finalReviewChipPresets";
import { communicationMethodFacetMatchesPreset } from "../../lib/create/methodCardFacetMatchesPresetForId";
const uuid = "550e8400-e29b-41d4-a716-446655440000";
describe("methodCardFacetMatchesPresetForId", () => {
it("communication: matches fresh preset seed for an unknown id", () => {
const p = communicationPresetFor(uuid);
expect(communicationMethodFacetMatchesPreset(p, uuid)).toBe(true);
});
it("communication: mismatches when any section differs from preset", () => {
const p = communicationPresetFor(uuid);
expect(
communicationMethodFacetMatchesPreset(
{ ...p, corePrinciple: "edited" },
uuid,
),
).toBe(false);
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
createFlowStateFromPublishedRule,
isPublishedRuleHydratePatchIncomplete,
isPublishedRuleSelectionMissing,
methodSectionsPinsForHydratedSelections,
methodSectionsPinsFromPublishedHydratePatch,
@@ -68,6 +69,72 @@ describe("isPublishedRuleSelectionMissing", () => {
});
});
describe("isPublishedRuleHydratePatchIncomplete", () => {
it("is true when facet ids are present but custom method meta from patch is missing in state", () => {
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
const patch = createFlowStateFromPublishedRule({
id: "r",
title: "T",
summary: "",
document: {
methodSelections: {
communication: [
{
id: customId,
label: "My custom comms method",
sections: {
corePrinciple: "x",
logisticsAdmin: "",
codeOfConduct: "",
},
},
],
},
},
});
const state = {
sections: [],
title: "T",
editingPublishedRuleId: "r",
selectedCommunicationMethodIds: [customId],
} as CreateFlowState;
expect(isPublishedRuleSelectionMissing(state, patch)).toBe(false);
expect(isPublishedRuleHydratePatchIncomplete(state, patch)).toBe(true);
});
it("is false when patch meta keys exist on state", () => {
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
const patch = createFlowStateFromPublishedRule({
id: "r",
title: "T",
summary: "",
document: {
methodSelections: {
communication: [
{
id: customId,
label: "My custom comms method",
sections: {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
},
},
],
},
},
});
const state = {
sections: [],
title: "T",
editingPublishedRuleId: "r",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: patch.customMethodCardMetaById,
} as CreateFlowState;
expect(isPublishedRuleHydratePatchIncomplete(state, patch)).toBe(false);
});
});
describe("methodSectionsPinsForHydratedSelections / methodSectionsPinsFromPublishedHydratePatch", () => {
it("alias matches hydrated-selection helper output", () => {
const partial: Partial<CreateFlowState> = {
@@ -218,6 +285,35 @@ describe("createFlowStateFromPublishedRule", () => {
expect(partial.sections).toEqual([]);
});
it("hydrates customMethodCardMetaById for user-authored method ids from methodSelections", () => {
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
const partial = createFlowStateFromPublishedRule({
id: "rule-custom",
title: "C",
summary: "",
document: {
methodSelections: {
communication: [
{
id: customId,
label: "Custom channel policy",
sections: {
corePrinciple: "cp",
logisticsAdmin: "la",
codeOfConduct: "cc",
},
},
],
},
},
});
expect(partial.selectedCommunicationMethodIds).toEqual([customId]);
expect(partial.customMethodCardMetaById?.[customId]).toEqual({
label: "Custom channel policy",
supportText: "",
});
});
it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => {
const partial = createFlowStateFromPublishedRule({
id: "rule-2",
@@ -116,4 +116,96 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
doc.sections,
);
});
it("replaces stale document.sections method category with full methodSelections (custom rules)", () => {
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
const doc = {
sections: [
{
categoryName: "Communication",
entries: [
{
title: "Slack",
body: "Only template row; custom card missing from sections.",
},
],
},
],
methodSelections: {
communication: [
{
id: "slack",
label: "Slack",
sections: {
corePrinciple: "Slack principle",
logisticsAdmin: "Slack logistics",
codeOfConduct: "Slack conduct",
},
},
{
id: customId,
label: "My custom comms",
sections: {
corePrinciple: "Custom principle",
logisticsAdmin: "",
codeOfConduct: "",
},
},
],
},
};
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
const comm = out.find((s) => s.categoryName === "Communication");
expect(comm).toBeDefined();
expect(comm?.entries.map((e) => e.title)).toEqual([
"Slack",
"My custom comms",
]);
expect(
comm?.entries.some(
(e) => e.title === "My custom comms" && e.blocks?.length,
),
).toBe(true);
});
it("includes wizard field blocks when methodSelections preset sections are empty (custom UUID)", () => {
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
const doc = {
sections: [
{
categoryName: "Communication",
entries: [{ title: "Stale template row", body: "ignored after merge" }],
},
],
methodSelections: {
communication: [
{
id: customId,
label: "Custom method title",
sections: {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
},
},
],
},
customMethodCardFieldBlocksById: {
[customId]: [
{
kind: "text" as const,
id: "f1",
blockTitle: "Expectations",
placeholderText: "Answer stored only on field blocks.",
},
],
},
};
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
const comm = out.find((s) => s.categoryName === "Communication");
expect(comm?.entries.map((e) => e.title)).toEqual(["Custom method title"]);
expect(comm?.entries[0]?.blocks).toEqual([
{ label: "Expectations", body: "Answer stored only on field blocks." },
]);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect, vi } from "vitest";
import { runCompletedStepExit } from "../../app/(app)/create/utils/runCompletedStepExit";
describe("runCompletedStepExit", () => {
it("clears client draft mirrors and navigates home without implying server DELETE", () => {
const clearState = vi.fn();
const clearAnonymousCreateFlowStorage = vi.fn();
const router = { push: vi.fn() };
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
router,
});
expect(clearState).toHaveBeenCalledTimes(1);
expect(clearAnonymousCreateFlowStorage).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith("/");
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { usesWizardFieldBlocksModalBody } from "../../lib/create/usesWizardFieldBlocksModalBody";
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
const id = "550e8400-e29b-41d4-a716-446655440000";
const meta = { [id]: { label: "L", supportText: "S" } };
const blocks: CustomMethodCardFieldBlock[] = [
{ kind: "text", id: "b1", blockTitle: "T", placeholderText: "p" },
];
describe("usesWizardFieldBlocksModalBody", () => {
it("is false without meta row", () => {
expect(
usesWizardFieldBlocksModalBody({
methodId: "signal",
meta: {},
fieldBlocksById: {},
modalEditUnlocked: false,
draftFieldBlocks: null,
}),
).toBe(false);
});
it("is true when persisted field blocks exist (wizard card)", () => {
expect(
usesWizardFieldBlocksModalBody({
methodId: id,
meta,
fieldBlocksById: { [id]: blocks },
modalEditUnlocked: false,
draftFieldBlocks: null,
}),
).toBe(true);
});
it("is true for proportion-only persisted blocks (read-only modal)", () => {
const proportionBlocks: CustomMethodCardFieldBlock[] = [
{
kind: "proportion",
id: "p1",
blockTitle: "Share of async",
defaultPercent: 40,
},
];
expect(
usesWizardFieldBlocksModalBody({
methodId: id,
meta,
fieldBlocksById: { [id]: proportionBlocks },
modalEditUnlocked: false,
draftFieldBlocks: null,
}),
).toBe(true);
});
it("is true when persisted blocks exist even if customMethodCardMetaById row is missing", () => {
expect(
usesWizardFieldBlocksModalBody({
methodId: id,
meta: {},
fieldBlocksById: { [id]: blocks },
modalEditUnlocked: false,
draftFieldBlocks: null,
}),
).toBe(true);
});
it("is false when meta exists but persisted blocks empty and not editing blocks (preset duplicate stub)", () => {
expect(
usesWizardFieldBlocksModalBody({
methodId: id,
meta,
fieldBlocksById: { [id]: [] },
modalEditUnlocked: false,
draftFieldBlocks: null,
}),
).toBe(false);
});
it("is true in read-only modal when custom, blocks empty, and facet matches preset stubs", () => {
expect(
usesWizardFieldBlocksModalBody({
methodId: id,
meta,
fieldBlocksById: { [id]: [] },
modalEditUnlocked: false,
draftFieldBlocks: null,
customFacetDetailsMatchPreset: true,
}),
).toBe(true);
});
it("is false when customizing with empty wizard draft — structured fields stay active", () => {
expect(
usesWizardFieldBlocksModalBody({
methodId: id,
meta,
fieldBlocksById: {},
modalEditUnlocked: true,
draftFieldBlocks: [],
}),
).toBe(false);
});
it("is true when customizing with non-empty block draft", () => {
expect(
usesWizardFieldBlocksModalBody({
methodId: id,
meta,
fieldBlocksById: {},
modalEditUnlocked: true,
draftFieldBlocks: blocks,
}),
).toBe(true);
});
});