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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user