Final review edit modals created

This commit is contained in:
adilallo
2026-04-20 17:57:17 -06:00
parent c08cd62872
commit a22d53e860
27 changed files with 2410 additions and 620 deletions
@@ -0,0 +1,335 @@
"use client";
/**
* Editable mirror of {@link TemplateChipDetailModal} for the final-review
* screen. Each chip on `/create/final-review` opens this modal — same field
* set as the matching custom-rule add-method modals, but with a **Save**
* button instead of **Add**:
*
* - Initial field values come from the matching `{group}DetailsById` state
* override when present; otherwise from the preset defaults shipped in
* `messages/en/create/customRule/*.json` (see {@link finalReviewChipPresets}).
* - Save is disabled until the user edits any field (cheap structural
* compare against the seeded snapshot). Saving writes the draft into
* `CreateFlowState` via the caller's `onSave` handler and closes; the
* state then rides along through the existing localStorage mirror,
* signed-in server draft PUT (Save & Exit), and `buildPublishPayload`
* (Finalize).
* - Closing the modal without saving discards any edits — the parent never
* hears about them.
*
* The actual field rendering lives in `components/methodEditFields/*` and
* is shared with the custom-rule add-method modals so the two surfaces stay
* in lockstep automatically.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import Create from "../../../components/modals/Create";
import ContentLockup from "../../../components/type/ContentLockup";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import {
CommunicationMethodEditFields,
ConflictManagementEditFields,
CoreValueEditFields,
DecisionApproachEditFields,
MembershipMethodEditFields,
} from "./methodEditFields";
import {
communicationPresetFor,
conflictManagementPresetFor,
coreValuePresetFor,
decisionApproachPresetFor,
membershipPresetFor,
} from "../../../../lib/create/finalReviewChipPresets";
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
CoreValueDetailEntry,
CreateFlowState,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../types";
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
export type FinalReviewChipEditTarget = {
/** Stable key for override lookup: preset id (methods) or chip id (core values). */
overrideKey: string;
/** Category group that decides which field set to render. */
groupKey: TemplateFacetGroupKey;
/** Display label shown at the top of the modal (localized chip label). */
chipLabel: string;
};
export type FinalReviewChipEditPatch =
| { groupKey: "coreValues"; overrideKey: string; value: CoreValueDetailEntry }
| {
groupKey: "communication";
overrideKey: string;
value: CommunicationMethodDetailEntry;
}
| {
groupKey: "membership";
overrideKey: string;
value: MembershipMethodDetailEntry;
}
| {
groupKey: "decisionApproaches";
overrideKey: string;
value: DecisionApproachDetailEntry;
}
| {
groupKey: "conflictManagement";
overrideKey: string;
value: ConflictManagementDetailEntry;
};
export interface FinalReviewChipEditModalProps {
isOpen: boolean;
onClose: () => void;
/**
* Chip being edited. Passed `null` while the modal is closing so the
* component can cleanly reset its internal draft state.
*/
target: FinalReviewChipEditTarget | null;
/** Current flow state — used to seed the modal from saved overrides. */
state: CreateFlowState;
/** Called with the typed patch when the user clicks Save. */
onSave: (_patch: FinalReviewChipEditPatch) => void;
}
/**
* Discriminated union of every group's draft + value. Storing both the
* `groupKey` and the `value` together keeps render-time switches exhaustive
* and prevents the four method group states from drifting apart (which is
* the bug that motivated extracting `methodEditFields/*` in the first place).
*/
type Draft =
| { groupKey: "coreValues"; value: CoreValueDetailEntry }
| { groupKey: "communication"; value: CommunicationMethodDetailEntry }
| { groupKey: "membership"; value: MembershipMethodDetailEntry }
| { groupKey: "decisionApproaches"; value: DecisionApproachDetailEntry }
| { groupKey: "conflictManagement"; value: ConflictManagementDetailEntry };
export function FinalReviewChipEditModal({
isOpen,
onClose,
target,
state,
onSave,
}: FinalReviewChipEditModalProps) {
const m = useMessages();
const tCv = m.create.customRule.coreValues;
const tComm = m.create.customRule.communication;
const tMem = m.create.customRule.membership;
const tDa = m.create.customRule.decisionApproaches;
const tCm = m.create.customRule.conflictManagement;
const tModal = useTranslation(
"create.reviewAndComplete.finalReview.chipEditModal",
);
const [draft, setDraft] = useState<Draft | null>(null);
/**
* JSON-stringified seed used for the cheap dirty check. Re-captured on
* every (re)open so reopening a chip after a save shows Save-disabled
* again until the user makes a fresh edit.
*/
const initialSnapshotRef = useRef<string>("");
const seededTargetRef = useRef<string | null>(null);
useEffect(() => {
if (!isOpen || !target) return;
const targetKey = `${target.groupKey}:${target.overrideKey}`;
if (seededTargetRef.current === targetKey) return;
const seed = seedDraftForTarget(target, state);
setDraft(seed);
initialSnapshotRef.current = JSON.stringify(seed.value);
seededTargetRef.current = targetKey;
}, [isOpen, target, state]);
useEffect(() => {
if (!isOpen) seededTargetRef.current = null;
}, [isOpen]);
const isDirty = useMemo(() => {
if (!draft) return false;
return JSON.stringify(draft.value) !== initialSnapshotRef.current;
}, [draft]);
const handleSave = () => {
if (!target || !draft || !isDirty) return;
onSave({
groupKey: draft.groupKey,
overrideKey: target.overrideKey,
value: draft.value,
} as FinalReviewChipEditPatch);
onClose();
};
const subtitle = useMemo(() => {
if (!target) return "";
return subtitleForTarget(target, { tCv, tComm, tMem, tDa, tCm });
}, [target, tCv, tComm, tMem, tDa, tCm]);
return (
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="loginYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={target?.chipLabel ?? ""}
description={subtitle}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton
nextButtonText={tModal("saveButton")}
nextButtonDisabled={!isDirty}
onNext={handleSave}
ariaLabel={target?.chipLabel || "Edit chip details"}
>
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
{draft?.groupKey === "coreValues" && (
<CoreValueEditFields
value={draft.value}
onChange={(value) => setDraft({ groupKey: "coreValues", value })}
/>
)}
{draft?.groupKey === "communication" && (
<CommunicationMethodEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "communication", value })
}
/>
)}
{draft?.groupKey === "membership" && (
<MembershipMethodEditFields
value={draft.value}
onChange={(value) => setDraft({ groupKey: "membership", value })}
/>
)}
{draft?.groupKey === "decisionApproaches" && (
<DecisionApproachEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "decisionApproaches", value })
}
/>
)}
{draft?.groupKey === "conflictManagement" && (
<ConflictManagementEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "conflictManagement", value })
}
/>
)}
</div>
</Create>
);
}
// ---------- helpers ------------------------------------------------------
function seedDraftForTarget(
target: FinalReviewChipEditTarget,
state: CreateFlowState,
): Draft {
switch (target.groupKey) {
case "coreValues": {
const saved = state.coreValueDetailsByChipId?.[target.overrideKey];
const preset = coreValuePresetFor(target.overrideKey);
return {
groupKey: "coreValues",
value: {
meaning: saved?.meaning ?? preset.meaning,
signals: saved?.signals ?? preset.signals,
},
};
}
case "communication": {
const saved =
state.communicationMethodDetailsById?.[target.overrideKey] ??
communicationPresetFor(target.overrideKey);
return { groupKey: "communication", value: { ...saved } };
}
case "membership": {
const saved =
state.membershipMethodDetailsById?.[target.overrideKey] ??
membershipPresetFor(target.overrideKey);
return { groupKey: "membership", value: { ...saved } };
}
case "decisionApproaches": {
const saved =
state.decisionApproachDetailsById?.[target.overrideKey] ??
decisionApproachPresetFor(target.overrideKey);
return {
groupKey: "decisionApproaches",
value: {
...saved,
applicableScope: [...saved.applicableScope],
selectedApplicableScope: [...saved.selectedApplicableScope],
},
};
}
case "conflictManagement": {
const saved =
state.conflictManagementDetailsById?.[target.overrideKey] ??
conflictManagementPresetFor(target.overrideKey);
return {
groupKey: "conflictManagement",
value: {
...saved,
applicableScope: [...saved.applicableScope],
selectedApplicableScope: [...saved.selectedApplicableScope],
},
};
}
}
}
type SubtitleMessages = {
tCv: ReturnType<typeof useMessages>["create"]["customRule"]["coreValues"];
tComm: ReturnType<typeof useMessages>["create"]["customRule"]["communication"];
tMem: ReturnType<typeof useMessages>["create"]["customRule"]["membership"];
tDa: ReturnType<
typeof useMessages
>["create"]["customRule"]["decisionApproaches"];
tCm: ReturnType<
typeof useMessages
>["create"]["customRule"]["conflictManagement"];
};
function subtitleForTarget(
target: FinalReviewChipEditTarget,
msgs: SubtitleMessages,
): string {
switch (target.groupKey) {
case "coreValues":
return msgs.tCv.detailModal.subtitle;
case "communication":
return findMethodSupportText(msgs.tComm.methods, target.overrideKey);
case "membership":
return findMethodSupportText(msgs.tMem.methods, target.overrideKey);
case "decisionApproaches":
return findMethodSupportText(msgs.tDa.methods, target.overrideKey);
case "conflictManagement":
return findMethodSupportText(msgs.tCm.methods, target.overrideKey);
}
}
function findMethodSupportText(
methods: readonly { id: string; supportText: string }[],
id: string,
): string {
for (const method of methods) {
if (method.id === id) return method.supportText;
}
return "";
}
@@ -0,0 +1,61 @@
"use client";
/**
* Controlled section editor for a communication-method chip. Used by both
* the custom-rule `communication-methods` add-method modal and the
* `final-review` chip edit modal — caller owns draft state and decides when
* to persist or discard.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import type { CommunicationMethodDetailEntry } from "../../types";
export interface CommunicationMethodEditFieldsProps {
value: CommunicationMethodDetailEntry;
onChange: (_next: CommunicationMethodDetailEntry) => void;
}
const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
"corePrinciple",
"logisticsAdmin",
"codeOfConduct",
];
function CommunicationMethodEditFieldsComponent({
value,
onChange,
}: CommunicationMethodEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.communication;
const patch = useCallback(
<K extends keyof CommunicationMethodDetailEntry>(
key: K,
next: CommunicationMethodDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-6">
{FIELDS.map((field) => (
<ModalTextAreaField
key={field}
label={t.sectionHeadings[field]}
rows={6}
value={value[field]}
onChange={(v) => patch(field, v)}
/>
))}
</div>
);
}
CommunicationMethodEditFieldsComponent.displayName =
"CommunicationMethodEditFields";
export default memo(CommunicationMethodEditFieldsComponent);
@@ -0,0 +1,78 @@
"use client";
/**
* Controlled section editor for a conflict-management chip. Used by both the
* custom-rule `conflict-management` add-method modal and the `final-review`
* chip edit modal. Caller owns draft state and persistence.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import ApplicableScopeField from "../ApplicableScopeField";
import type { ConflictManagementDetailEntry } from "../../types";
export interface ConflictManagementEditFieldsProps {
value: ConflictManagementDetailEntry;
onChange: (_next: ConflictManagementDetailEntry) => void;
}
function ConflictManagementEditFieldsComponent({
value,
onChange,
}: ConflictManagementEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.conflictManagement;
const patch = useCallback(
<K extends keyof ConflictManagementDetailEntry>(
key: K,
next: ConflictManagementDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={t.sectionHeadings.corePrinciple}
value={value.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel}
scopes={value.applicableScope}
selectedScopes={value.selectedApplicableScope}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
value.selectedApplicableScope.includes(scope)
? value.selectedApplicableScope.filter((s) => s !== scope)
: [...value.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...value.applicableScope, scope])
}
/>
<ModalTextAreaField
label={t.sectionHeadings.processProtocol}
value={value.processProtocol}
onChange={(v) => patch("processProtocol", v)}
/>
<ModalTextAreaField
label={t.sectionHeadings.restorationFallbacks}
value={value.restorationFallbacks}
onChange={(v) => patch("restorationFallbacks", v)}
/>
</div>
);
}
ConflictManagementEditFieldsComponent.displayName =
"ConflictManagementEditFields";
export default memo(ConflictManagementEditFieldsComponent);
@@ -0,0 +1,57 @@
"use client";
/**
* Controlled meaning/signals field set for a core-value chip. Rendered both
* by `core-values` (custom-rule selection step) and `final-review` (chip
* edit modal). Holds no state — the parent owns the draft and decides when
* to persist (`updateState`) or discard.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import type { CoreValueDetailEntry } from "../../types";
export interface CoreValueEditFieldsProps {
value: CoreValueDetailEntry;
onChange: (_next: CoreValueDetailEntry) => void;
}
function CoreValueEditFieldsComponent({
value,
onChange,
}: CoreValueEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.coreValues.detailModal;
const patch = useCallback(
<K extends keyof CoreValueDetailEntry>(
key: K,
next: CoreValueDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
<ModalTextAreaField
label={t.meaningLabel}
value={value.meaning}
onChange={(v) => patch("meaning", v)}
rows={4}
/>
<ModalTextAreaField
label={t.signalsLabel}
value={value.signals}
onChange={(v) => patch("signals", v)}
rows={4}
/>
</div>
);
}
CoreValueEditFieldsComponent.displayName = "CoreValueEditFields";
export default memo(CoreValueEditFieldsComponent);
@@ -0,0 +1,95 @@
"use client";
/**
* Controlled section editor for a decision-approach chip. Used by both the
* custom-rule `decision-approaches` add-method modal and the `final-review`
* chip edit modal. Caller owns draft state — Confirm/Save persistence and
* `markCreateFlowInteraction` live in the parent.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import ApplicableScopeField from "../ApplicableScopeField";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import type { DecisionApproachDetailEntry } from "../../types";
export interface DecisionApproachEditFieldsProps {
value: DecisionApproachDetailEntry;
onChange: (_next: DecisionApproachDetailEntry) => void;
}
const CONSENSUS_LEVEL_MIN = 0;
const CONSENSUS_LEVEL_MAX = 100;
const CONSENSUS_LEVEL_STEP = 5;
function DecisionApproachEditFieldsComponent({
value,
onChange,
}: DecisionApproachEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.decisionApproaches;
const patch = useCallback(
<K extends keyof DecisionApproachDetailEntry>(
key: K,
next: DecisionApproachDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={t.sectionHeadings.corePrinciple}
value={value.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel}
scopes={value.applicableScope}
selectedScopes={value.selectedApplicableScope}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
value.selectedApplicableScope.includes(scope)
? value.selectedApplicableScope.filter((s) => s !== scope)
: [...value.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...value.applicableScope, scope])
}
/>
<ModalTextAreaField
label={t.sectionHeadings.stepByStepInstructions}
value={value.stepByStepInstructions}
onChange={(v) => patch("stepByStepInstructions", v)}
/>
<IncrementerBlock
label={t.sectionHeadings.consensusLevel}
value={value.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => patch("consensusLevel", next)}
formatValue={(v) => `${v}%`}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
/>
<ModalTextAreaField
label={t.sectionHeadings.objectionsDeadlocks}
value={value.objectionsDeadlocks}
onChange={(v) => patch("objectionsDeadlocks", v)}
/>
</div>
);
}
DecisionApproachEditFieldsComponent.displayName =
"DecisionApproachEditFields";
export default memo(DecisionApproachEditFieldsComponent);
@@ -0,0 +1,61 @@
"use client";
/**
* Controlled section editor for a membership-method chip. Used by both the
* custom-rule `membership-methods` add-method modal and the `final-review`
* chip edit modal — caller owns draft state and decides when to persist or
* discard.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import type { MembershipMethodDetailEntry } from "../../types";
export interface MembershipMethodEditFieldsProps {
value: MembershipMethodDetailEntry;
onChange: (_next: MembershipMethodDetailEntry) => void;
}
const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
"eligibility",
"joiningProcess",
"expectations",
];
function MembershipMethodEditFieldsComponent({
value,
onChange,
}: MembershipMethodEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.membership;
const patch = useCallback(
<K extends keyof MembershipMethodDetailEntry>(
key: K,
next: MembershipMethodDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-6">
{FIELDS.map((field) => (
<ModalTextAreaField
key={field}
label={t.sectionHeadings[field]}
rows={6}
value={value[field]}
onChange={(v) => patch(field, v)}
/>
))}
</div>
);
}
MembershipMethodEditFieldsComponent.displayName =
"MembershipMethodEditFields";
export default memo(MembershipMethodEditFieldsComponent);
@@ -0,0 +1,14 @@
export { default as CoreValueEditFields } from "./CoreValueEditFields";
export type { CoreValueEditFieldsProps } from "./CoreValueEditFields";
export { default as CommunicationMethodEditFields } from "./CommunicationMethodEditFields";
export type { CommunicationMethodEditFieldsProps } from "./CommunicationMethodEditFields";
export { default as MembershipMethodEditFields } from "./MembershipMethodEditFields";
export type { MembershipMethodEditFieldsProps } from "./MembershipMethodEditFields";
export { default as DecisionApproachEditFields } from "./DecisionApproachEditFields";
export type { DecisionApproachEditFieldsProps } from "./DecisionApproachEditFields";
export { default as ConflictManagementEditFields } from "./ConflictManagementEditFields";
export type { ConflictManagementEditFieldsProps } from "./ConflictManagementEditFields";