Route kebab Customize through the prefilled policy wizard so existing methods and values can be renamed, reordered, and kept in that field order on the card after Finalize.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-08-21 13:51:37 -06:00
co-authored by Cursor
parent e9951f260b
commit 4006fe5c6c
31 changed files with 3062 additions and 1530 deletions
@@ -69,7 +69,6 @@ export function applyFinalReviewChipEditPatch(
}
: {};
if (
patch.groupKey !== "coreValues" &&
"customMethodCardFieldBlocks" in patch &&
patch.customMethodCardFieldBlocks !== undefined
) {
+21
View File
@@ -24,6 +24,15 @@ export function removeCoreValueChipFromDraft(
const nextDetails = hadDetail
? omitIdFromStringRecord(state.coreValueDetailsByChipId, chipId)
: undefined;
const hadBlocks =
Boolean(state.customMethodCardFieldBlocksById) &&
Object.prototype.hasOwnProperty.call(
state.customMethodCardFieldBlocksById,
chipId,
);
const nextBlocks = hadBlocks
? omitIdFromStringRecord(state.customMethodCardFieldBlocksById, chipId)
: undefined;
const out: Partial<CreateFlowState> = {
coreValuesChipsSnapshot: nextSnap,
@@ -33,6 +42,9 @@ export function removeCoreValueChipFromDraft(
if (hadDetail) {
out.coreValueDetailsByChipId = nextDetails;
}
if (hadBlocks) {
out.customMethodCardFieldBlocksById = nextBlocks;
}
return out;
}
@@ -77,6 +89,7 @@ export function duplicateCoreValueChipInDraft(
[newId]: structuredClone(inherited),
}
: { ...(state.coreValueDetailsByChipId ?? {}) };
const inheritedBlocks = state.customMethodCardFieldBlocksById?.[chipId];
return {
newId,
@@ -87,6 +100,14 @@ export function duplicateCoreValueChipInDraft(
...(Object.keys(nextDetails).length > 0
? { coreValueDetailsByChipId: nextDetails }
: {}),
...(inheritedBlocks !== undefined
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[newId]: structuredClone(inheritedBlocks),
},
}
: {}),
},
};
}
@@ -1,2 +1,8 @@
/** Max length for title and description fields in the add-custom-method-card wizard (Figma 0/48). */
/** Max length for the policy title in the add-custom-method-card wizard (Figma 0/48). */
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
/**
* Max length for the policy description. Wider than the title so Customize can
* seed existing card support text without blocking Next.
*/
export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200;
+391
View File
@@ -0,0 +1,391 @@
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
CoreValueDetailEntry,
CreateFlowState,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../../app/(app)/create/types";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import { formatConflictApplicableScopeForTextarea } from "./ruleSectionsFromMethodSelections";
/** Seed for {@link CustomMethodCardWizard} when Customize opens on an existing card. */
export type MethodCardWizardInitialValues = {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
};
export type MethodCardWizardFacetPrefill =
| {
group: "communication";
draft: CommunicationMethodDetailEntry;
headings: {
corePrinciple: string;
logisticsAdmin: string;
codeOfConduct: string;
};
}
| {
group: "membership";
draft: MembershipMethodDetailEntry;
headings: {
eligibility: string;
joiningProcess: string;
expectations: string;
};
}
| {
group: "decisionApproaches";
draft: DecisionApproachDetailEntry;
headings: {
corePrinciple: string;
applicableScope: string;
stepByStepInstructions: string;
consensusLevel: string;
objectionsDeadlocks: string;
};
}
| {
group: "conflictManagement";
draft: ConflictManagementDetailEntry;
headings: {
corePrinciple: string;
applicableScope: string;
processProtocol: string;
restorationFallbacks: string;
};
}
| {
group: "coreValues";
draft: CoreValueDetailEntry;
headings: {
meaning: string;
signals: string;
};
};
const TEXT_BODY_MAX = 8000;
function textBlock(
id: string,
blockTitle: string,
placeholderText: string,
): CustomMethodCardFieldBlock {
return {
kind: "text",
id,
blockTitle,
placeholderText: placeholderText.slice(0, TEXT_BODY_MAX),
};
}
function hasTrimmedText(value: string): boolean {
return value.trim().length > 0;
}
function clampPercent(n: number): number {
if (!Number.isFinite(n)) return 1;
return Math.min(100, Math.max(1, Math.round(n)));
}
function facetPrefillHasContent(prefill: MethodCardWizardFacetPrefill): boolean {
switch (prefill.group) {
case "communication":
return (
hasTrimmedText(prefill.draft.corePrinciple) ||
hasTrimmedText(prefill.draft.logisticsAdmin) ||
hasTrimmedText(prefill.draft.codeOfConduct)
);
case "membership":
return (
hasTrimmedText(prefill.draft.eligibility) ||
hasTrimmedText(prefill.draft.joiningProcess) ||
hasTrimmedText(prefill.draft.expectations)
);
case "decisionApproaches":
return (
hasTrimmedText(prefill.draft.corePrinciple) ||
hasTrimmedText(prefill.draft.stepByStepInstructions) ||
hasTrimmedText(prefill.draft.objectionsDeadlocks) ||
prefill.draft.applicableScope.length > 0 ||
prefill.draft.selectedApplicableScope.length > 0
);
case "conflictManagement":
return (
hasTrimmedText(prefill.draft.corePrinciple) ||
hasTrimmedText(prefill.draft.processProtocol) ||
hasTrimmedText(prefill.draft.restorationFallbacks) ||
formatConflictApplicableScopeForTextarea(
prefill.draft.selectedApplicableScope,
prefill.draft.applicableScope,
).trim().length > 0
);
case "coreValues":
return (
hasTrimmedText(prefill.draft.meaning) ||
hasTrimmedText(prefill.draft.signals)
);
}
}
function mapFacetPrefillToWizardFieldBlocks(
prefill: MethodCardWizardFacetPrefill,
): CustomMethodCardFieldBlock[] {
switch (prefill.group) {
case "communication":
return [
textBlock(
"facet-corePrinciple",
prefill.headings.corePrinciple,
prefill.draft.corePrinciple,
),
textBlock(
"facet-logisticsAdmin",
prefill.headings.logisticsAdmin,
prefill.draft.logisticsAdmin,
),
textBlock(
"facet-codeOfConduct",
prefill.headings.codeOfConduct,
prefill.draft.codeOfConduct,
),
];
case "membership":
return [
textBlock(
"facet-eligibility",
prefill.headings.eligibility,
prefill.draft.eligibility,
),
textBlock(
"facet-joiningProcess",
prefill.headings.joiningProcess,
prefill.draft.joiningProcess,
),
textBlock(
"facet-expectations",
prefill.headings.expectations,
prefill.draft.expectations,
),
];
case "decisionApproaches": {
const scopeOptions =
prefill.draft.applicableScope.length > 0
? [...prefill.draft.applicableScope]
: [...prefill.draft.selectedApplicableScope];
const blocks: CustomMethodCardFieldBlock[] = [
textBlock(
"facet-corePrinciple",
prefill.headings.corePrinciple,
prefill.draft.corePrinciple,
),
];
if (scopeOptions.length > 0) {
blocks.push({
kind: "badges",
id: "facet-applicableScope",
blockTitle: prefill.headings.applicableScope,
options: scopeOptions,
});
}
blocks.push(
textBlock(
"facet-stepByStepInstructions",
prefill.headings.stepByStepInstructions,
prefill.draft.stepByStepInstructions,
),
{
kind: "proportion",
id: "facet-consensusLevel",
blockTitle: prefill.headings.consensusLevel,
defaultPercent: clampPercent(prefill.draft.consensusLevel),
},
textBlock(
"facet-objectionsDeadlocks",
prefill.headings.objectionsDeadlocks,
prefill.draft.objectionsDeadlocks,
),
);
return blocks;
}
case "conflictManagement":
return [
textBlock(
"facet-corePrinciple",
prefill.headings.corePrinciple,
prefill.draft.corePrinciple,
),
textBlock(
"facet-applicableScope",
prefill.headings.applicableScope,
formatConflictApplicableScopeForTextarea(
prefill.draft.selectedApplicableScope,
prefill.draft.applicableScope,
),
),
textBlock(
"facet-processProtocol",
prefill.headings.processProtocol,
prefill.draft.processProtocol,
),
textBlock(
"facet-restorationFallbacks",
prefill.headings.restorationFallbacks,
prefill.draft.restorationFallbacks,
),
];
case "coreValues":
return [
textBlock(
"facet-meaning",
prefill.headings.meaning,
prefill.draft.meaning,
),
textBlock(
"facet-signals",
prefill.headings.signals,
prefill.draft.signals,
),
];
}
}
/**
* Map a method card's facet editors onto wizard field blocks so Customize
* can show and edit the same sections on step 3.
*/
export function facetDetailsToWizardFieldBlocks(
prefill: MethodCardWizardFacetPrefill,
): CustomMethodCardFieldBlock[] {
if (!facetPrefillHasContent(prefill)) {
return [];
}
return mapFacetPrefillToWizardFieldBlocks(prefill);
}
function facetBlockHasContent(block: CustomMethodCardFieldBlock): boolean {
switch (block.kind) {
case "text":
return hasTrimmedText(block.placeholderText);
case "badges":
return block.options.length > 0;
case "proportion":
return true;
case "upload":
return Boolean(block.fileName?.trim() || block.assetUrl?.trim());
}
}
function overlayBlockValue(
existing: CustomMethodCardFieldBlock,
incoming: CustomMethodCardFieldBlock,
): CustomMethodCardFieldBlock {
if (existing.kind === "text" && incoming.kind === "text") {
return { ...existing, placeholderText: incoming.placeholderText };
}
if (existing.kind === "badges" && incoming.kind === "badges") {
return { ...existing, options: [...incoming.options] };
}
if (existing.kind === "proportion" && incoming.kind === "proportion") {
return { ...existing, defaultPercent: incoming.defaultPercent };
}
if (existing.kind === "upload" && incoming.kind === "upload") {
return {
...existing,
fileName: incoming.fileName,
...(incoming.assetUrl !== undefined
? { assetUrl: incoming.assetUrl }
: {}),
};
}
return incoming;
}
/**
* Keep extra wizard-only blocks, but refresh facet-* field values from the
* current modal editors so Customize does not show a stale copy.
*/
export function overlayFacetPrefillValues(
blocks: CustomMethodCardFieldBlock[],
prefill: MethodCardWizardFacetPrefill,
): CustomMethodCardFieldBlock[] {
const incoming = mapFacetPrefillToWizardFieldBlocks(prefill);
const incomingById = new Map(incoming.map((block) => [block.id, block]));
const seen = new Set<string>();
const next = blocks.map((block) => {
const overlay = incomingById.get(block.id);
if (!overlay) {
return block;
}
seen.add(block.id);
return overlayBlockValue(block, overlay);
});
for (const block of incoming) {
if (seen.has(block.id) || !facetBlockHasContent(block)) {
continue;
}
next.push(block);
}
return next;
}
function textPlaceholderForBlockId(
blocks: CustomMethodCardFieldBlock[],
id: string,
): string | undefined {
const block = blocks.find((b) => b.id === id);
if (!block || block.kind !== "text") {
return undefined;
}
return block.placeholderText;
}
/** Map Customize wizard field blocks back onto meaning/signals for a value chip. */
export function coreValueDetailsFromWizardFieldBlocks(
blocks: CustomMethodCardFieldBlock[],
fallback: CoreValueDetailEntry,
): CoreValueDetailEntry {
return {
meaning: textPlaceholderForBlockId(blocks, "facet-meaning") ?? fallback.meaning,
signals:
textPlaceholderForBlockId(blocks, "facet-signals") ?? fallback.signals,
...(fallback.supportText !== undefined
? { supportText: fallback.supportText }
: {}),
};
}
/**
* Title, support text, and field blocks for the custom-policy wizard, preferring
* in-modal drafts over persisted meta/blocks. When the card has no wizard
* blocks yet, facet body fields are mapped onto step-3 blocks.
*/
export function buildMethodCardWizardInitialValues(args: {
cardId: string;
fallbackTitle: string;
fallbackDescription: string;
meta: CreateFlowState["customMethodCardMetaById"];
persistedBlocks: CreateFlowState["customMethodCardFieldBlocksById"];
draftFieldBlocks: CustomMethodCardFieldBlock[] | null;
facetPrefill?: MethodCardWizardFacetPrefill;
}): MethodCardWizardInitialValues {
const metaRow = args.meta?.[args.cardId];
const persisted = args.persistedBlocks?.[args.cardId];
let fieldBlocks =
args.draftFieldBlocks !== null
? structuredClone(args.draftFieldBlocks)
: Array.isArray(persisted) && persisted.length > 0
? structuredClone(persisted)
: [];
if (fieldBlocks.length === 0 && args.facetPrefill) {
fieldBlocks = facetDetailsToWizardFieldBlocks(args.facetPrefill);
} else if (fieldBlocks.length > 0 && args.facetPrefill) {
fieldBlocks = overlayFacetPrefillValues(fieldBlocks, args.facetPrefill);
}
return {
title: metaRow?.label ?? args.fallbackTitle,
description: metaRow?.supportText ?? args.fallbackDescription,
fieldBlocks,
};
}
+6 -4
View File
@@ -14,6 +14,9 @@ import { isCustomMethodCardId } from "./isCustomMethodCardId";
* stubs keep the facet's structured edit fields until the user adds blocks (then this
* returns true once persisted blocks are non-empty).
*
* Non-empty **draft** blocks also win, including catalog ids after Customize
* Finalize (facet editors ignore array order).
*
* **View mode** (`modalEditUnlocked` false): when the custom card still has facet copy
* that matches preset seeds only (see `./methodCardFacetMatchesPresetForId`), route to
* {@link CustomMethodCardModalBody} so meta-only wizard cards show policy copy instead
@@ -33,16 +36,15 @@ export function usesWizardFieldBlocksModalBody(args: {
if (Array.isArray(persisted) && persisted.length > 0) {
return true;
}
if (!isCustomMethodCardId(args.methodId, args.meta)) {
return false;
}
if (
args.modalEditUnlocked &&
args.draftFieldBlocks !== null &&
args.draftFieldBlocks.length > 0
) {
return true;
}
if (!isCustomMethodCardId(args.methodId, args.meta)) {
return false;
}
return (
!args.modalEditUnlocked && args.customFacetDetailsMatchPreset === true
);