392 lines
11 KiB
TypeScript
392 lines
11 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 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,
|
|
};
|
|
}
|