Add custom intervention modals

This commit is contained in:
adilallo
2026-05-01 22:05:05 -06:00
parent 58d0e33500
commit dee2dd800e
67 changed files with 3480 additions and 197 deletions
+15 -1
View File
@@ -30,10 +30,24 @@ export function applyFinalReviewChipEditPatch(
current && typeof current === "object"
? (current as Record<string, unknown>)
: {};
return {
const detailPatch: Partial<CreateFlowState> = {
[stateKey]: {
...record,
[patch.overrideKey]: patch.value,
},
};
if (
patch.groupKey !== "coreValues" &&
"customMethodCardFieldBlocks" in patch &&
patch.customMethodCardFieldBlocks !== undefined
) {
return {
...detailPatch,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[patch.overrideKey]: patch.customMethodCardFieldBlocks,
},
};
}
return detailPatch;
}
+14 -2
View File
@@ -62,14 +62,22 @@ function entriesFromIds(
ids: readonly string[] | undefined,
methods: readonly MethodPreset[],
groupKey: TemplateFacetGroupKey,
customMeta?: CreateFlowState["customMethodCardMetaById"],
): FinalReviewChipEntry[] {
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: FinalReviewChipEntry[] = [];
for (const id of ids) {
const label = byId.get(id);
if (typeof label !== "string" || label.length === 0) continue;
const presetLabel = byId.get(id);
const fromCustom = customMeta?.[id]?.label?.trim();
const label =
typeof presetLabel === "string" && presetLabel.length > 0
? presetLabel
: typeof fromCustom === "string" && fromCustom.length > 0
? fromCustom
: "";
if (label.length === 0) continue;
if (seen.has(label)) continue;
seen.add(label);
out.push({ label, groupKey, overrideKey: id });
@@ -181,6 +189,7 @@ export function buildFinalReviewCategoryRowsDetailed(
state.selectedCommunicationMethodIds,
methodsForGroup("communication"),
"communication",
state.customMethodCardMetaById,
),
},
{
@@ -190,6 +199,7 @@ export function buildFinalReviewCategoryRowsDetailed(
state.selectedMembershipMethodIds,
methodsForGroup("membership"),
"membership",
state.customMethodCardMetaById,
),
},
{
@@ -199,6 +209,7 @@ export function buildFinalReviewCategoryRowsDetailed(
state.selectedDecisionApproachIds,
methodsForGroup("decisionApproaches"),
"decisionApproaches",
state.customMethodCardMetaById,
),
},
{
@@ -208,6 +219,7 @@ export function buildFinalReviewCategoryRowsDetailed(
state.selectedConflictManagementIds,
methodsForGroup("conflictManagement"),
"conflictManagement",
state.customMethodCardMetaById,
),
},
];
+21 -5
View File
@@ -13,7 +13,7 @@ import {
decisionApproachPresetFor,
membershipPresetFor,
mergeCoreValueDetailWithPresets,
methodLabelFor,
publishedMethodDisplayLabel,
} from "./finalReviewChipPresets";
import { isDocumentEntry } from "./documentEntryGuards";
import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections";
@@ -254,7 +254,11 @@ export function buildMethodSelectionsForDocument(
const override = state.communicationMethodDetailsById?.[id];
return {
id,
label: methodLabelFor("communication", id),
label: publishedMethodDisplayLabel(
"communication",
id,
state.customMethodCardMetaById,
),
sections: override ? { ...preset, ...override } : preset,
};
});
@@ -270,7 +274,11 @@ export function buildMethodSelectionsForDocument(
const override = state.membershipMethodDetailsById?.[id];
return {
id,
label: methodLabelFor("membership", id),
label: publishedMethodDisplayLabel(
"membership",
id,
state.customMethodCardMetaById,
),
sections: override ? { ...preset, ...override } : preset,
};
});
@@ -286,7 +294,11 @@ export function buildMethodSelectionsForDocument(
const override = state.decisionApproachDetailsById?.[id];
return {
id,
label: methodLabelFor("decisionApproaches", id),
label: publishedMethodDisplayLabel(
"decisionApproaches",
id,
state.customMethodCardMetaById,
),
sections: override ? { ...preset, ...override } : preset,
};
});
@@ -302,7 +314,11 @@ export function buildMethodSelectionsForDocument(
const override = state.conflictManagementDetailsById?.[id];
return {
id,
label: methodLabelFor("conflictManagement", id),
label: publishedMethodDisplayLabel(
"conflictManagement",
id,
state.customMethodCardMetaById,
),
sections: override ? { ...preset, ...override } : preset,
};
});
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
/** Serializable custom field blocks for a user-authored method card (wizard step 3). */
export type CustomMethodCardFieldBlock =
| {
kind: "text";
id: string;
blockTitle: string;
placeholderText: string;
}
| {
kind: "badges";
id: string;
blockTitle: string;
options: string[];
}
| {
kind: "upload";
id: string;
blockTitle: string;
fileName?: string;
}
| {
kind: "proportion";
id: string;
blockTitle: string;
defaultPercent: number;
};
const customMethodTextBlockSchema = z
.object({
kind: z.literal("text"),
id: z.string().max(80),
blockTitle: z.string().max(200),
placeholderText: z.string().max(8000),
})
.strict();
const customMethodBadgesBlockSchema = z
.object({
kind: z.literal("badges"),
id: z.string().max(80),
blockTitle: z.string().max(200),
options: z.array(z.string().max(200)).max(50),
})
.strict();
const customMethodUploadBlockSchema = z
.object({
kind: z.literal("upload"),
id: z.string().max(80),
blockTitle: z.string().max(200),
fileName: z.string().max(500).optional(),
})
.strict();
const customMethodProportionBlockSchema = z
.object({
kind: z.literal("proportion"),
id: z.string().max(80),
blockTitle: z.string().max(200),
defaultPercent: z.number().int().min(1).max(100),
})
.strict();
export const customMethodCardFieldBlockSchema = z.discriminatedUnion("kind", [
customMethodTextBlockSchema,
customMethodBadgesBlockSchema,
customMethodUploadBlockSchema,
customMethodProportionBlockSchema,
]);
export const customMethodCardFieldBlocksByIdSchema = z
.record(z.string().max(80), z.array(customMethodCardFieldBlockSchema).max(30))
.optional();
@@ -0,0 +1,2 @@
/** Max length for title and description fields in the add-custom-method-card wizard (Figma 0/48). */
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
+12
View File
@@ -8,6 +8,7 @@ import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
CoreValueDetailEntry,
CreateFlowState,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../../app/(app)/create/types";
@@ -232,3 +233,14 @@ export function methodLabelFor(
const method = findMethod(source, id);
return method?.label ?? "";
}
/** Label for publish / review: preset JSON row, else user-authored wizard meta. */
export function publishedMethodDisplayLabel(
groupKey: TemplateFacetGroupKey,
id: string,
customMeta?: CreateFlowState["customMethodCardMetaById"],
): string {
const preset = methodLabelFor(groupKey, id);
if (preset.length > 0) return preset;
return customMeta?.[id]?.label?.trim() ?? "";
}
+13
View File
@@ -0,0 +1,13 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
/**
* User-authored method cards (UUID ids) register a meta row when finalized
* from {@link CustomMethodCardWizard}. Preset rows from `methods[]` never
* appear here — keeps edit surfaces from treating custom ids like presets.
*/
export function isCustomMethodCardId(
methodId: string,
customMeta: CreateFlowState["customMethodCardMetaById"],
): boolean {
return Boolean(customMeta?.[methodId]);
}
@@ -0,0 +1,32 @@
/**
* Merge JSON preset method rows with user-created cards (stable UUID ids + meta).
* Custom rows follow `selectedIds` order (most-recent add at index 0 in create flow)
* but appear after all presets in this merged list; CardStack display order is
* layered separately via `useMethodCardDeckOrdering`.
*/
export function mergePresetMethodsWithCustom<
T extends { id: string; label: string; supportText?: string },
>(
presets: readonly T[],
selectedIds: readonly string[],
meta: Record<string, { label: string; supportText: string }> | undefined,
): T[] {
const presetIds = new Set(presets.map((p) => p.id));
const customRows: T[] = [];
const seenCustom = new Set<string>();
for (const id of selectedIds) {
if (presetIds.has(id)) continue;
const row = meta?.[id];
if (!row || seenCustom.has(id)) continue;
seenCustom.add(id);
customRows.push({
id,
label: row.label,
supportText: row.supportText,
} as T);
}
return [...presets, ...customRows];
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Canonical ordering for method-card facet `selected*Ids` when the user adds a card:
* most recently confirmed id is index 0 so stack / compact layouts stay consistent
* with {@link orderRankedMethodsWithPinnedSelection}.
*/
export function moveFacetSelectionIdToFront(
prev: readonly string[],
id: string,
): string[] {
const without = prev.filter((x) => x !== id);
return [id, ...without];
}
@@ -32,11 +32,10 @@ export function isPublishedRuleSelectionMissing(
}
/**
* Pin flags for method-card facets: compact CardStack slots surface selections
* first only when `methodSectionsPinCommitted[facet]` is true (see
* `useMethodCardDeckOrdering`). Normal wizard flow sets that on facet **Confirm**.
* Hydration paths that seed `selected*` method ids without a confirm (edit-published,
* template customize) merge this alongside those ids so pinning matches UX after Confirm.
* Pin flags for method-card facets: persisted for hydration and footer Confirm.
* Card Stack display pulls selections to the top whenever `selected*` ids are
* non-empty (`useMethodCardDeckOrdering`); this map still merges on edit /
* template paths so drafts mirror post-Confirm expectations.
*
* Caller should spread onto existing `methodSectionsPinCommitted` so unrelated facets stay
* as-is (`{ ...prior, ...this }`).
@@ -0,0 +1,86 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import {
CUSTOM_RULE_FACET_BY_GROUP,
type TemplateFacetGroupKey,
} from "./customRuleFacets";
import { isCustomMethodCardId } from "./isCustomMethodCardId";
export type MethodFacetGroupKey = Exclude<TemplateFacetGroupKey, "coreValues">;
/**
* Removes one method card id from a facets selection and clears its detail
* override row; if the id is a custom wizard card, also drops meta + field blocks.
*/
export function removeMethodCardFromFacetSelection(
state: CreateFlowState,
facetGroupKey: MethodFacetGroupKey,
cardId: string,
): Partial<CreateFlowState> {
const row = CUSTOM_RULE_FACET_BY_GROUP.get(facetGroupKey);
if (!row || row.kind !== "method") {
throw new Error(
`removeMethodCardFromFacetSelection: not a method facet (${facetGroupKey})`,
);
}
const selectedIds = [...row.selectionIds(state)];
if (!selectedIds.includes(cardId)) {
return {};
}
const nextSelected = selectedIds.filter((id) => id !== cardId);
const detailKey = row.detailOverridesStateKey as
| "communicationMethodDetailsById"
| "membershipMethodDetailsById"
| "decisionApproachDetailsById"
| "conflictManagementDetailsById";
const prevDetails = state[detailKey] as Record<string, unknown> | undefined;
const nextDetails: Record<string, unknown> = { ...(prevDetails ?? {}) };
delete nextDetails[cardId];
const patch: Partial<CreateFlowState> = {};
(patch as Record<string, unknown>)[row.selectedIdsStateKey] = nextSelected;
const detailsPatch =
Object.keys(nextDetails).length > 0 ? nextDetails : undefined;
switch (detailKey) {
case "communicationMethodDetailsById":
patch.communicationMethodDetailsById =
detailsPatch as CreateFlowState["communicationMethodDetailsById"];
break;
case "membershipMethodDetailsById":
patch.membershipMethodDetailsById =
detailsPatch as CreateFlowState["membershipMethodDetailsById"];
break;
case "decisionApproachDetailsById":
patch.decisionApproachDetailsById =
detailsPatch as CreateFlowState["decisionApproachDetailsById"];
break;
case "conflictManagementDetailsById":
patch.conflictManagementDetailsById =
detailsPatch as CreateFlowState["conflictManagementDetailsById"];
break;
default: {
const _exhaustive: never = detailKey;
throw new Error(
`removeMethodCardFromFacetSelection: unknown detail key ${_exhaustive}`,
);
}
}
const meta = state.customMethodCardMetaById ?? {};
if (isCustomMethodCardId(cardId, meta)) {
const nextMeta = { ...meta };
delete nextMeta[cardId];
patch.customMethodCardMetaById =
Object.keys(nextMeta).length > 0 ? nextMeta : undefined;
const nextBlocks = { ...(state.customMethodCardFieldBlocksById ?? {}) };
delete nextBlocks[cardId];
patch.customMethodCardFieldBlocksById =
Object.keys(nextBlocks).length > 0 ? nextBlocks : undefined;
}
return patch;
}
@@ -15,5 +15,7 @@ export function stripCustomRuleSelectionFields(
for (const key of STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS) {
delete (out as Record<string, unknown>)[key as string];
}
delete (out as Record<string, unknown>).customMethodCardMetaById;
delete (out as Record<string, unknown>).customMethodCardFieldBlocksById;
return out;
}
@@ -1,5 +1,6 @@
import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
@@ -58,6 +59,11 @@ const conflictManagementDetailEntrySchema = z.object({
restorationFallbacks: z.string().max(8000),
});
const customMethodCardMetaEntrySchema = z.object({
label: z.string().max(48),
supportText: z.string().max(48),
});
/**
* Published rule `document` column: arbitrary JSON object with safety bounds.
*/
@@ -112,6 +118,10 @@ export const createFlowStateSchema = z
conflictManagementDetailsById: z
.record(conflictManagementDetailEntrySchema)
.optional(),
customMethodCardMetaById: z
.record(z.string().max(80), customMethodCardMetaEntrySchema)
.optional(),
customMethodCardFieldBlocksById: customMethodCardFieldBlocksByIdSchema,
methodSectionsPinCommitted: z
.object({
communication: z.boolean().optional(),