Files
community-rule/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx
T

757 lines
24 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import Create from "../../../../components/modals/Create";
import ContentLockup from "../../../../components/type/ContentLockup";
import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import type {
CommunityStructureChipSnapshotRow,
CoreValueDetailEntry,
} from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { CoreValueEditFields } from "../../components/methodEditFields";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import {
duplicateCoreValueChipInDraft,
MAX_SELECTED_CORE_VALUES,
removeCoreValueChipFromDraft,
} from "../../../../../lib/create/coreValueChipFacet";
import {
buildMethodCardWizardInitialValues,
coreValueDetailsFromWizardFieldBlocks,
overlayFacetPrefillValues,
type MethodCardWizardInitialValues,
} from "../../../../../lib/create/methodCardWizardPrefill";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
/**
* Why three sessions, not two:
*
* - `pending` — preset chip just selected; modal opened to capture
* meaning/signals. Close (X) confirms, then unselects the chip.
* - `customPending` — brand-new custom chip just created via the Add
* value flow; modal opened with empty fields. Dismiss = drop the
* chip entirely (it was never confirmed via the Add Value button).
* - `editing` — chip already exists & is selected; modal reopened to
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
*/
type ModalSession = "pending" | "customPending" | "editing";
/** Row in `coreValues.json` `values` — string (legacy) or `{ label, meaning, signals }`. */
type CoreValuePresetJson =
| string
| { label: string; meaning?: string; signals?: string };
type CoreValuePreset = {
label: string;
meaning: string;
signals: string;
};
function normalizeCoreValuePresets(
values: readonly CoreValuePresetJson[],
): CoreValuePreset[] {
return values.map((v) => {
if (typeof v === "string") {
return { label: v, meaning: "", signals: "" };
}
return {
label: v.label,
meaning: typeof v.meaning === "string" ? v.meaning : "",
signals: typeof v.signals === "string" ? v.signals : "",
};
});
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "selected")
.map((o) => o.id);
}
function chipOptionsToSnapshotRows(
options: ChipOption[],
): CommunityStructureChipSnapshotRow[] {
return options.map((o) => ({
id: o.id,
label: o.label,
...(o.state !== undefined ? { state: o.state } : {}),
}));
}
const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
export function CoreValuesSelectScreen() {
const m = useMessages();
const cv = m.create.customRule.coreValues;
const modalKebabMenu = m.create.customRule.modalKebabMenu;
const presets = useMemo(
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
[cv.values],
);
const { requestConfirm, confirmDialog } = useAsyncConfirm();
const { markCreateFlowInteraction, updateState, replaceState, state } =
useCreateFlow();
const initialDraftRef = useRef<CoreValueDetailEntry | null>(null);
const pendingEphemeralCoreDuplicateRef = useRef<string | null>(null);
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() =>
buildCoreValueChipOptionsFromDraft(
presets,
state.coreValuesChipsSnapshot,
state.selectedCoreValueIds,
),
);
const [activeModalChipId, setActiveModalChipId] = useState<string | null>(
null,
);
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [wizardCustomizeChipId, setWizardCustomizeChipId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null
>(null);
useEffect(() => {
setCoreValueOptions(
buildCoreValueChipOptionsFromDraft(
presets,
state.coreValuesChipsSnapshot,
state.selectedCoreValueIds,
),
);
}, [
presets,
state.coreValuesChipsSnapshot,
state.selectedCoreValueIds,
]);
/** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */
const syncCoreValuesToDraft = useCallback(
(next: ChipOption[]) => {
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
},
[updateState],
);
const persistCoreValues = useCallback(
(next: ChipOption[]) => {
markCreateFlowInteraction();
setCoreValueOptions(next);
syncCoreValuesToDraft(next);
},
[markCreateFlowInteraction, syncCoreValuesToDraft],
);
/** Default meaning/signals from `coreValues.json` `values` for each preset label. */
const getPresetTexts = useCallback(
(valueLabel: string): CoreValueDetailEntry => {
const row = presets.find((p) => p.label === valueLabel);
if (!row) return EMPTY_DETAIL;
return { meaning: row.meaning, signals: row.signals };
},
[presets],
);
const getInitialTexts = useCallback(
(chipId: string, valueLabel: string): CoreValueDetailEntry => {
const saved = state.coreValueDetailsByChipId?.[chipId];
const preset = getPresetTexts(valueLabel);
return {
meaning: saved?.meaning ?? preset.meaning,
signals: saved?.signals ?? preset.signals,
...(saved?.supportText ? { supportText: saved.supportText } : {}),
};
},
[state.coreValueDetailsByChipId, getPresetTexts],
);
const openModal = useCallback(
(
chipId: string,
session: ModalSession,
valueLabel: string,
seedDetail?: CoreValueDetailEntry,
) => {
const initial = seedDetail ?? getInitialTexts(chipId, valueLabel);
initialDraftRef.current = { ...initial };
const persisted = state.customMethodCardFieldBlocksById?.[chipId];
setDraftFieldBlocks(
Array.isArray(persisted) && persisted.length > 0
? structuredClone(persisted)
: null,
);
setDraft(initial);
setActiveModalChipId(chipId);
setModalSession(session);
markCreateFlowInteraction();
},
[
getInitialTexts,
markCreateFlowInteraction,
state.customMethodCardFieldBlocksById,
],
);
const handleDraftChange = useCallback(
(next: CoreValueDetailEntry) => {
markCreateFlowInteraction();
setDraft(next);
},
[markCreateFlowInteraction],
);
const finalizeModalDismiss = useCallback(() => {
pendingEphemeralCoreDuplicateRef.current = null;
initialDraftRef.current = null;
setActiveModalChipId(null);
setModalSession(null);
setDraftFieldBlocks(null);
}, []);
const confirmLeaveWithoutSaving = useCallback(async () => {
const initial = initialDraftRef.current;
const fieldsDirty =
initial != null &&
(draft.meaning !== initial.meaning || draft.signals !== initial.signals);
if (!fieldsDirty) {
return true;
}
const isPendingAdd =
modalSession === "pending" || modalSession === "customPending";
return requestConfirm({
title: cv.detailModal.discardTitle,
description: isPendingAdd
? cv.detailModal.discardPendingDescription
: cv.detailModal.discardEditsDescription,
proceedText: cv.detailModal.discardProceed,
cancelText: cv.detailModal.discardKeepEditing,
});
}, [cv.detailModal, draft, modalSession, requestConfirm]);
const handleDuplicateCoreChip = useCallback(() => {
if (!activeModalChipId || !modalSession) return;
markCreateFlowInteraction();
const priorEphemeral = pendingEphemeralCoreDuplicateRef.current;
let outcome: ReturnType<typeof duplicateCoreValueChipInDraft> | null = null;
replaceState((prev) => {
const base =
priorEphemeral != null
? { ...prev, ...removeCoreValueChipFromDraft(prev, priorEphemeral) }
: prev;
const res = duplicateCoreValueChipInDraft(
base,
activeModalChipId,
modalKebabMenu.duplicateTitleSuffix,
);
if (!res) {
return base;
}
outcome = res;
return { ...base, ...res.patch };
});
if (!outcome) {
return;
}
pendingEphemeralCoreDuplicateRef.current = outcome.newId;
openModal(
outcome.newId,
"editing",
outcome.newLabel,
structuredClone(draft),
);
}, [
activeModalChipId,
draft,
markCreateFlowInteraction,
modalKebabMenu.duplicateTitleSuffix,
modalSession,
openModal,
replaceState,
]);
const handleRemoveFromKebab = useCallback(() => {
markCreateFlowInteraction();
const ep = pendingEphemeralCoreDuplicateRef.current;
if (ep && activeModalChipId === ep) {
replaceState((prev) => ({
...prev,
...removeCoreValueChipFromDraft(prev, ep),
}));
finalizeModalDismiss();
return;
}
if (modalSession === "pending") {
const next = coreValueOptions.map((opt) =>
opt.id === activeModalChipId
? { ...opt, state: "unselected" as const }
: opt,
);
persistCoreValues(next);
} else if (modalSession === "customPending") {
const next = coreValueOptions.filter((opt) => opt.id !== activeModalChipId);
persistCoreValues(next);
} else if (modalSession === "editing" && activeModalChipId) {
const nextFiltered = coreValueOptions.filter(
(opt) => opt.id !== activeModalChipId,
);
markCreateFlowInteraction();
replaceState((prev) => ({
...prev,
...removeCoreValueChipFromDraft(prev, activeModalChipId),
}));
setCoreValueOptions(nextFiltered);
}
finalizeModalDismiss();
}, [
activeModalChipId,
coreValueOptions,
finalizeModalDismiss,
markCreateFlowInteraction,
modalSession,
persistCoreValues,
replaceState,
]);
const handleModalDismiss = useCallback(async () => {
if (!(await confirmLeaveWithoutSaving())) {
return;
}
const ep = pendingEphemeralCoreDuplicateRef.current;
if (ep) {
replaceState((prev) => ({
...prev,
...removeCoreValueChipFromDraft(prev, ep),
}));
}
if (modalSession === "pending" && activeModalChipId) {
const next = coreValueOptions.map((opt) =>
opt.id === activeModalChipId
? { ...opt, state: "unselected" as const }
: opt,
);
persistCoreValues(next);
} else if (modalSession === "customPending" && activeModalChipId) {
const next = coreValueOptions.filter(
(opt) => opt.id !== activeModalChipId,
);
persistCoreValues(next);
}
finalizeModalDismiss();
}, [
activeModalChipId,
confirmLeaveWithoutSaving,
coreValueOptions,
finalizeModalDismiss,
modalSession,
persistCoreValues,
replaceState,
]);
const handleModalConfirm = useCallback(() => {
if (!activeModalChipId || !modalSession) return;
markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null;
const existingBlocks =
draftFieldBlocks && draftFieldBlocks.length > 0
? draftFieldBlocks
: state.customMethodCardFieldBlocksById?.[activeModalChipId];
updateState({
coreValueDetailsByChipId: {
...(state.coreValueDetailsByChipId ?? {}),
[activeModalChipId]: draft,
},
...(existingBlocks && existingBlocks.length > 0
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[activeModalChipId]: overlayFacetPrefillValues(existingBlocks, {
group: "coreValues",
draft,
headings: {
meaning: cv.detailModal.meaningLabel,
signals: cv.detailModal.signalsLabel,
},
}),
},
}
: {}),
});
finalizeModalDismiss();
}, [
activeModalChipId,
cv.detailModal.meaningLabel,
cv.detailModal.signalsLabel,
draft,
draftFieldBlocks,
finalizeModalDismiss,
markCreateFlowInteraction,
modalSession,
state.coreValueDetailsByChipId,
state.customMethodCardFieldBlocksById,
updateState,
]);
const modalChipLabel =
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
const modalUsesWizardFieldBlocksBody = Boolean(
activeModalChipId &&
usesWizardFieldBlocksModalBody({
methodId: activeModalChipId,
meta: {},
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: true,
draftFieldBlocks,
}),
);
const showFooterPrimary = Boolean(modalSession);
const handleCustomize = useCallback(() => {
if (!activeModalChipId) return;
markCreateFlowInteraction();
setWizardInitialValues(
buildMethodCardWizardInitialValues({
cardId: activeModalChipId,
fallbackTitle: modalChipLabel,
fallbackDescription:
draft.supportText?.trim() || cv.detailModal.subtitle,
meta: {},
persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks,
facetPrefill: {
group: "coreValues",
draft,
headings: {
meaning: cv.detailModal.meaningLabel,
signals: cv.detailModal.signalsLabel,
},
},
}),
);
setWizardCustomizeChipId(activeModalChipId);
setAddCustomWizardOpen(true);
}, [
activeModalChipId,
cv.detailModal.meaningLabel,
cv.detailModal.signalsLabel,
cv.detailModal.subtitle,
draft,
draftFieldBlocks,
markCreateFlowInteraction,
modalChipLabel,
state.customMethodCardFieldBlocksById,
]);
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
setWizardCustomizeChipId(null);
setWizardInitialValues(null);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
const chipId = wizardCustomizeChipId ?? activeModalChipId;
if (!chipId) return;
markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null;
const trimmedTitle = title.trim();
const trimmedDescription = description.trim();
const nextDetails = {
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
...(trimmedDescription.length > 0
? { supportText: trimmedDescription }
: {}),
};
replaceState((prev) => {
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
const i = snap.findIndex((r) => r.id === chipId);
if (i >= 0 && trimmedTitle.length > 0) {
snap[i] = { ...snap[i], label: trimmedTitle };
}
return {
...prev,
coreValuesChipsSnapshot: snap,
coreValueDetailsByChipId: {
...(prev.coreValueDetailsByChipId ?? {}),
[chipId]: nextDetails,
},
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
[chipId]: structuredClone(fieldBlocks),
},
};
});
setDraft(nextDetails);
setDraftFieldBlocks(structuredClone(fieldBlocks));
setAddCustomWizardOpen(false);
setWizardCustomizeChipId(null);
setWizardInitialValues(null);
},
[
activeModalChipId,
draft,
markCreateFlowInteraction,
replaceState,
wizardCustomizeChipId,
],
);
const kebabMenuItems = useMemo(() => {
if (!modalSession || !activeModalChipId) return [];
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" ||
selectedCount >= MAX_CORE_VALUES
? undefined
: handleDuplicateCoreChip,
showRemove: modalSession === "editing",
onRemove: handleRemoveFromKebab,
});
}, [
activeModalChipId,
coreValueOptions,
handleCustomize,
handleDuplicateCoreChip,
handleRemoveFromKebab,
modalKebabMenu,
modalSession,
state.editingPublishedRuleId,
]);
const handleChipClick = (chipId: string) => {
const target = coreValueOptions.find((o) => o.id === chipId);
if (!target || target.state === "custom") return;
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
if (target.state === "selected") {
openModal(chipId, "editing", target.label);
return;
}
if (selectedCount >= MAX_CORE_VALUES) return;
const next: ChipOption[] = coreValueOptions.map((opt) =>
opt.id === chipId
? { ...opt, state: "selected" as const }
: opt,
);
persistCoreValues(next);
openModal(chipId, "pending", target.label);
};
const addHandlers = {
onAddClick: () => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next: ChipOption[] = [
...prev,
{ id: crypto.randomUUID(), label: "", state: "custom" },
];
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
},
onCustomChipConfirm: (chipId: string, value: string) => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const withLabel = prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "unselected" as const }
: opt,
);
const selectedCount = withLabel.filter(
(o) => o.state === "selected",
).length;
const canSelect = selectedCount < MAX_CORE_VALUES;
const next = canSelect
? withLabel.map((opt) =>
opt.id === chipId
? { ...opt, state: "selected" as const }
: opt,
)
: withLabel;
queueMicrotask(() => {
syncCoreValuesToDraft(next);
// Both branches treat the chip as a brand-new draft until the
// user confirms via Add Value — dismissal removes it.
openModal(chipId, "customPending", value);
});
return next;
});
},
onCustomChipClose: (chipId: string) => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next = prev.filter((o) => o.id !== chipId);
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
},
};
const description = (
<>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{cv.header.descriptionLead}{" "}
</span>
<button
type="button"
onClick={addHandlers.onAddClick}
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90"
>
{cv.header.addLink}
</button>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{" "}
{cv.header.descriptionTrail}
</span>
</>
);
const detailModal = cv.detailModal;
return (
<>
<CreateFlowTwoColumnSelectShell
lgVerticalAlign="start"
header={
<CreateFlowHeaderLockup
title={cv.header.title}
description={description}
justification="left"
/>
}
>
<MultiSelect
formHeader={false}
size="m"
options={coreValueOptions}
onChipClick={handleChipClick}
onAddClick={addHandlers.onAddClick}
onCustomChipConfirm={addHandlers.onCustomChipConfirm}
onCustomChipClose={addHandlers.onCustomChipClose}
addButton
addButtonText={cv.multiSelect.addButtonText}
/>
{detailModal && (
<Create
isOpen={activeModalChipId !== null && !addCustomWizardOpen}
onClose={handleModalDismiss}
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={modalChipLabel}
description={
draft.supportText?.trim() || detailModal.subtitle
}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton={showFooterPrimary}
onNext={handleModalConfirm}
nextButtonText={
modalSession === "editing"
? modalKebabMenu.saveEdits
: detailModal.addValueButton
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={
kebabMenuItems.length > 0 ? kebabMenuItems : undefined
}
ariaLabel={modalChipLabel || "Core value details"}
>
{modalUsesWizardFieldBlocksBody && activeModalChipId ? (
<CustomMethodCardModalBody
cardId={activeModalChipId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
showPolicyContentLockupWhenNoBlocks={false}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => {
setDraftFieldBlocks(next);
setDraft(
coreValueDetailsFromWizardFieldBlocks(next, draft),
);
}
}
/>
) : (
<CoreValueEditFields
value={draft}
onChange={handleDraftChange}
/>
)}
</Create>
)}
</CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
{confirmDialog}
</>
);
}