Files
community-rule/lib/create/methodCardWizardPrefill.ts
T
adilalloandCursor 20ea4a2f30 Give MultiSelect chips accessible names and start creation fields empty.
Catalog chips now announce Select or Deselect so they are named for assistive tech. Method and value modals open blank; Generate draft inserts the catalog copy that used to be prefilled, and Customize still keeps the section headings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 15:59:00 -06:00

361 lines
10 KiB
TypeScript

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 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,
),
);
if (
typeof prefill.draft.consensusLevel === "number" &&
(hasTrimmedText(prefill.draft.corePrinciple) ||
hasTrimmedText(prefill.draft.stepByStepInstructions) ||
hasTrimmedText(prefill.draft.objectionsDeadlocks) ||
prefill.draft.applicableScope.length > 0 ||
prefill.draft.selectedApplicableScope.length > 0)
) {
blocks.push({
kind: "proportion",
id: "facet-consensusLevel",
blockTitle: prefill.headings.consensusLevel,
defaultPercent: clampPercent(prefill.draft.consensusLevel),
});
}
blocks.push(
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. Empty copy still yields
* the catalog headings so authors can fill them in the wizard.
*/
export function facetDetailsToWizardFieldBlocks(
prefill: MethodCardWizardFacetPrefill,
): CustomMethodCardFieldBlock[] {
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,
};
}