Add custom intervention modals
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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() ?? "";
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 facet’s 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(),
|
||||
|
||||
Reference in New Issue
Block a user