From a22d53e860a183c5cb2681b983c1af59d08391fe Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:57:17 -0600 Subject: [PATCH] Final review edit modals created --- app/(app)/create/CreateFlowLayoutClient.tsx | 24 +- .../components/FinalReviewChipEditModal.tsx | 335 ++++++++++++++++++ .../CommunicationMethodEditFields.tsx | 61 ++++ .../ConflictManagementEditFields.tsx | 78 ++++ .../methodEditFields/CoreValueEditFields.tsx | 57 +++ .../DecisionApproachEditFields.tsx | 95 +++++ .../MembershipMethodEditFields.tsx | 61 ++++ .../components/methodEditFields/index.ts | 14 + .../card/CommunicationMethodsScreen.tsx | 163 ++++----- .../screens/card/ConflictManagementScreen.tsx | 203 ++++------- .../screens/card/MembershipMethodsScreen.tsx | 168 ++++----- .../screens/review/FinalReviewScreen.tsx | 196 ++++++++-- .../right-rail/DecisionApproachesScreen.tsx | 216 ++++------- .../screens/select/CoreValuesSelectScreen.tsx | 61 ++-- app/(app)/create/types.ts | 51 +++ docs/guides/backend-linear-tickets.md | 55 ++- lib/create/applyTemplatePrefill.ts | 30 ++ lib/create/buildFinalReviewCategories.ts | 251 +++++++++---- lib/create/buildPublishPayload.ts | 137 ++++++- lib/create/finalReviewChipPresets.ts | 190 ++++++++++ lib/server/validation/createFlowSchemas.ts | 45 +++ .../create/reviewAndComplete/finalReview.json | 5 + ...unicationMethodsScreenPersistence.test.tsx | 150 ++++++++ tests/components/FinalReviewPage.test.tsx | 245 ++++++++++++- tests/unit/applyTemplatePrefill.test.ts | 48 ++- tests/unit/buildFinalReviewCategories.test.ts | 8 +- tests/unit/buildPublishPayload.test.ts | 83 +++++ 27 files changed, 2410 insertions(+), 620 deletions(-) create mode 100644 app/(app)/create/components/FinalReviewChipEditModal.tsx create mode 100644 app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx create mode 100644 app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx create mode 100644 app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx create mode 100644 app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx create mode 100644 app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx create mode 100644 app/(app)/create/components/methodEditFields/index.ts create mode 100644 lib/create/finalReviewChipPresets.ts create mode 100644 tests/components/CommunicationMethodsScreenPersistence.test.tsx diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 057617d..2f5812b 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -34,7 +34,10 @@ import { } from "./utils/anonymousDraftStorage"; import { deleteServerDraft } from "../../../lib/create/api"; import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; -import { buildTemplateCustomizePrefill } from "../../../lib/create/applyTemplatePrefill"; +import { + buildCoreValuesPrefillFromTemplateBody, + buildTemplateCustomizePrefill, +} from "../../../lib/create/applyTemplatePrefill"; import { loadTemplateReviewBySlug } from "../../../lib/create/loadTemplateReviewBySlug"; import messages from "../../../messages/en/index"; import { @@ -298,6 +301,22 @@ function CreateFlowLayoutContent({ // don't bleed into `document.coreValues` at publish time. resetCustomRuleSelections(); + // Seed the core-values snapshot from the Values section so the + // final-review chip modal can edit them (it keys edits by chip id). + // The Values entries themselves are then dropped from `sections` to + // avoid publishing `document.coreValues` and `document.sections.Values` + // for the same data — matches the "Customize" path's data shape. + const coreValuesPrefill = buildCoreValuesPrefillFromTemplateBody(doc); + const sectionsWithoutValues = + Object.keys(coreValuesPrefill).length > 0 + ? sections.filter((s) => { + const name = (s as { categoryName?: unknown }).categoryName; + if (typeof name !== "string") return true; + const key = name.toLowerCase().replace(/[^a-z]+/g, ""); + return key !== "values" && key !== "corevalues"; + }) + : sections; + const summaryRaw = typeof template.description === "string" ? template.description.trim() @@ -305,7 +324,8 @@ function CreateFlowLayoutContent({ const hasCommunityName = typeof state.title === "string" && state.title.trim().length > 0; updateState({ - sections, + ...coreValuesPrefill, + sections: sectionsWithoutValues, ...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}), ...(hasCommunityName ? { pendingTemplateAction: undefined } diff --git a/app/(app)/create/components/FinalReviewChipEditModal.tsx b/app/(app)/create/components/FinalReviewChipEditModal.tsx new file mode 100644 index 0000000..d53d0cc --- /dev/null +++ b/app/(app)/create/components/FinalReviewChipEditModal.tsx @@ -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(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(""); + const seededTargetRef = useRef(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 ( + + + + } + showBackButton={false} + showNextButton + nextButtonText={tModal("saveButton")} + nextButtonDisabled={!isDirty} + onNext={handleSave} + ariaLabel={target?.chipLabel || "Edit chip details"} + > +
+ {draft?.groupKey === "coreValues" && ( + setDraft({ groupKey: "coreValues", value })} + /> + )} + {draft?.groupKey === "communication" && ( + + setDraft({ groupKey: "communication", value }) + } + /> + )} + {draft?.groupKey === "membership" && ( + setDraft({ groupKey: "membership", value })} + /> + )} + {draft?.groupKey === "decisionApproaches" && ( + + setDraft({ groupKey: "decisionApproaches", value }) + } + /> + )} + {draft?.groupKey === "conflictManagement" && ( + + setDraft({ groupKey: "conflictManagement", value }) + } + /> + )} +
+
+ ); +} + +// ---------- 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["create"]["customRule"]["coreValues"]; + tComm: ReturnType["create"]["customRule"]["communication"]; + tMem: ReturnType["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 ""; +} diff --git a/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx b/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx new file mode 100644 index 0000000..221baa7 --- /dev/null +++ b/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx @@ -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 = [ + "corePrinciple", + "logisticsAdmin", + "codeOfConduct", +]; + +function CommunicationMethodEditFieldsComponent({ + value, + onChange, +}: CommunicationMethodEditFieldsProps) { + const m = useMessages(); + const t = m.create.customRule.communication; + + const patch = useCallback( + ( + key: K, + next: CommunicationMethodDetailEntry[K], + ) => { + onChange({ ...value, [key]: next }); + }, + [value, onChange], + ); + + return ( +
+ {FIELDS.map((field) => ( + patch(field, v)} + /> + ))} +
+ ); +} + +CommunicationMethodEditFieldsComponent.displayName = + "CommunicationMethodEditFields"; + +export default memo(CommunicationMethodEditFieldsComponent); diff --git a/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx b/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx new file mode 100644 index 0000000..3c0da4f --- /dev/null +++ b/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx @@ -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( + ( + key: K, + next: ConflictManagementDetailEntry[K], + ) => { + onChange({ ...value, [key]: next }); + }, + [value, onChange], + ); + + return ( +
+ patch("corePrinciple", v)} + /> + + patch( + "selectedApplicableScope", + value.selectedApplicableScope.includes(scope) + ? value.selectedApplicableScope.filter((s) => s !== scope) + : [...value.selectedApplicableScope, scope], + ) + } + onAddScope={(scope) => + patch("applicableScope", [...value.applicableScope, scope]) + } + /> + patch("processProtocol", v)} + /> + patch("restorationFallbacks", v)} + /> +
+ ); +} + +ConflictManagementEditFieldsComponent.displayName = + "ConflictManagementEditFields"; + +export default memo(ConflictManagementEditFieldsComponent); diff --git a/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx b/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx new file mode 100644 index 0000000..e612028 --- /dev/null +++ b/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx @@ -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( + ( + key: K, + next: CoreValueDetailEntry[K], + ) => { + onChange({ ...value, [key]: next }); + }, + [value, onChange], + ); + + return ( +
+ patch("meaning", v)} + rows={4} + /> + patch("signals", v)} + rows={4} + /> +
+ ); +} + +CoreValueEditFieldsComponent.displayName = "CoreValueEditFields"; + +export default memo(CoreValueEditFieldsComponent); diff --git a/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx b/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx new file mode 100644 index 0000000..08c3132 --- /dev/null +++ b/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx @@ -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( + ( + key: K, + next: DecisionApproachDetailEntry[K], + ) => { + onChange({ ...value, [key]: next }); + }, + [value, onChange], + ); + + return ( +
+ patch("corePrinciple", v)} + /> + + patch( + "selectedApplicableScope", + value.selectedApplicableScope.includes(scope) + ? value.selectedApplicableScope.filter((s) => s !== scope) + : [...value.selectedApplicableScope, scope], + ) + } + onAddScope={(scope) => + patch("applicableScope", [...value.applicableScope, scope]) + } + /> + patch("stepByStepInstructions", v)} + /> + patch("consensusLevel", next)} + formatValue={(v) => `${v}%`} + decrementAriaLabel="Decrease consensus level" + incrementAriaLabel="Increase consensus level" + /> + patch("objectionsDeadlocks", v)} + /> +
+ ); +} + +DecisionApproachEditFieldsComponent.displayName = + "DecisionApproachEditFields"; + +export default memo(DecisionApproachEditFieldsComponent); diff --git a/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx b/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx new file mode 100644 index 0000000..8372756 --- /dev/null +++ b/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx @@ -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 = [ + "eligibility", + "joiningProcess", + "expectations", +]; + +function MembershipMethodEditFieldsComponent({ + value, + onChange, +}: MembershipMethodEditFieldsProps) { + const m = useMessages(); + const t = m.create.customRule.membership; + + const patch = useCallback( + ( + key: K, + next: MembershipMethodDetailEntry[K], + ) => { + onChange({ ...value, [key]: next }); + }, + [value, onChange], + ); + + return ( +
+ {FIELDS.map((field) => ( + patch(field, v)} + /> + ))} +
+ ); +} + +MembershipMethodEditFieldsComponent.displayName = + "MembershipMethodEditFields"; + +export default memo(MembershipMethodEditFieldsComponent); diff --git a/app/(app)/create/components/methodEditFields/index.ts b/app/(app)/create/components/methodEditFields/index.ts new file mode 100644 index 0000000..4ca359f --- /dev/null +++ b/app/(app)/create/components/methodEditFields/index.ts @@ -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"; diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index 6784e4b..c386e43 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -7,6 +7,11 @@ * Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from * two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and * reuse `CardStack` / `CreateFlowStepShell` as needed. + * + * Card click opens the Figma "Add Platform" create modal (node `20246-15829`) with three + * editable sections rendered by {@link CommunicationMethodEditFields}. The same field set is + * reused on `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both + * the chip selection and any user edits as a `communicationMethodDetailsById[id]` override. */ import { useState, useCallback, useMemo } from "react"; @@ -27,61 +32,9 @@ import { CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS, CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS, } from "../../components/createFlowLayoutTokens"; -import ModalTextAreaField from "../../components/ModalTextAreaField"; - -const SECTION_FIELDS = [ - "corePrinciple", - "logisticsAdmin", - "codeOfConduct", -] as const; -type SectionField = (typeof SECTION_FIELDS)[number]; - -function AddPlatformModalContent({ - platformCardId, -}: { - platformCardId: string; -}) { - const { markCreateFlowInteraction } = useCreateFlow(); - const m = useMessages(); - const comm = m.create.customRule.communication; - const method = comm.methods.find((entry) => entry.id === platformCardId); - const sections = method?.sections; - const defaults: Record = { - corePrinciple: sections?.corePrinciple ?? "", - logisticsAdmin: sections?.logisticsAdmin ?? "", - codeOfConduct: sections?.codeOfConduct ?? "", - }; - - const [sectionValues, setSectionValues] = useState< - Record - >(() => ({ - corePrinciple: defaults.corePrinciple, - logisticsAdmin: defaults.logisticsAdmin, - codeOfConduct: defaults.codeOfConduct, - })); - - const updateSection = useCallback( - (key: SectionField, value: string) => { - markCreateFlowInteraction(); - setSectionValues((prev) => ({ ...prev, [key]: value })); - }, - [markCreateFlowInteraction], - ); - - return ( -
- {SECTION_FIELDS.map((field) => ( - updateSection(field, v)} - /> - ))} -
- ); -} +import { CommunicationMethodEditFields } from "../../components/methodEditFields"; +import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; +import type { CommunicationMethodDetailEntry } from "../../types"; export function CommunicationMethodsScreen() { const m = useMessages(); @@ -91,16 +44,11 @@ export function CommunicationMethodsScreen() { const [expanded, setExpanded] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(null); + const [pendingDraft, setPendingDraft] = + useState(null); const selectedIds = state.selectedCommunicationMethodIds ?? []; - const setSelectedIds = useCallback( - (next: string[]) => { - updateState({ selectedCommunicationMethodIds: next }); - }, - [updateState], - ); - const { scoresBySlug, hasAnyFacets } = useFacetRecommendations("communication"); const rankedMethods = useMemo( @@ -148,55 +96,81 @@ export function CommunicationMethodsScreen() { ); - const modalConfig = (() => { - if (!pendingCardId) { - return { + const modalConfig = pendingCardId + ? (() => { + const method = methodById.get(pendingCardId); + return { + title: method?.label ?? comm.confirmModal.title, + description: method?.supportText ?? comm.confirmModal.description, + nextButtonText: comm.addPlatform.nextButtonText, + }; + })() + : { title: comm.confirmModal.title, description: comm.confirmModal.description, nextButtonText: comm.confirmModal.nextButtonText, - showBackButton: false as const, - currentStep: undefined, - totalSteps: undefined, }; - } - const method = methodById.get(pendingCardId); - return { - title: method?.label ?? comm.confirmModal.title, - description: method?.supportText ?? comm.confirmModal.description, - nextButtonText: comm.addPlatform.nextButtonText, - showBackButton: false as const, - currentStep: undefined, - totalSteps: undefined, - }; - })(); + const seedDraft = useCallback( + (id: string): CommunicationMethodDetailEntry => { + const saved = state.communicationMethodDetailsById?.[id]; + if (saved) { + return { ...saved }; + } + return communicationPresetFor(id); + }, + [state.communicationMethodDetailsById], + ); const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); setPendingCardId(id); + setPendingDraft(seedDraft(id)); setCreateModalOpen(true); }, + [markCreateFlowInteraction, seedDraft], + ); + + const handleDraftChange = useCallback( + (next: CommunicationMethodDetailEntry) => { + markCreateFlowInteraction(); + setPendingDraft(next); + }, [markCreateFlowInteraction], ); const handleCreateModalClose = useCallback(() => { setCreateModalOpen(false); setPendingCardId(null); + setPendingDraft(null); }, []); const handleCreateModalConfirm = useCallback(() => { - markCreateFlowInteraction(); - if (pendingCardId) { - setSelectedIds( - selectedIds.includes(pendingCardId) - ? selectedIds - : [...selectedIds, pendingCardId], - ); + if (!pendingCardId || !pendingDraft) { + handleCreateModalClose(); + return; } - setCreateModalOpen(false); - setPendingCardId(null); - }, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]); + markCreateFlowInteraction(); + updateState({ + selectedCommunicationMethodIds: selectedIds.includes(pendingCardId) + ? selectedIds + : [...selectedIds, pendingCardId], + communicationMethodDetailsById: { + ...(state.communicationMethodDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + handleCreateModalClose(); + }, [ + handleCreateModalClose, + markCreateFlowInteraction, + pendingCardId, + pendingDraft, + selectedIds, + state.communicationMethodDetailsById, + updateState, + ]); return ( - {pendingCardId ? ( - ) : null} diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index 3b804ff..4948988 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -4,11 +4,12 @@ * `conflict-management` step — Figma compact card stack (node `20879-15979`). * Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`. * - * Card click opens the Figma "Add Approach" create modal (node `20874-172292`) with four - * controls: Core Principle, Applicable Scope (capsules), Process Protocol, and Restoration - * & Fallbacks. Section defaults are sourced from - * `messages/en/create/customRule/conflictManagement.json` and will be replaced with DB-driven - * content; labels are hard-coded per the Figma design. + * Card click opens the Figma "Add Approach" create modal (node `20874-172292`) + * with four controls rendered by {@link ConflictManagementEditFields}: Core + * Principle, Applicable Scope (capsules), Process Protocol, and Restoration + * & Fallbacks. The same field set is reused on `/create/final-review` — see + * `FinalReviewChipEditModal`. Confirm persists both the chip selection and + * any user edits as a `conflictManagementDetailsById[id]` override. */ import { useState, useCallback, useMemo } from "react"; @@ -29,91 +30,9 @@ import { CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS, CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS, } from "../../components/createFlowLayoutTokens"; -import ModalTextAreaField from "../../components/ModalTextAreaField"; -import ApplicableScopeField from "../../components/ApplicableScopeField"; - -type ConflictModalSections = { - corePrinciple: string; - applicableScope: string[]; - selectedApplicableScope: string[]; - processProtocol: string; - restorationFallbacks: string; -}; - -function AddConflictApproachModalContent({ - approachCardId, -}: { - approachCardId: string; -}) { - const { markCreateFlowInteraction } = useCreateFlow(); - const m = useMessages(); - const cm = m.create.customRule.conflictManagement; - const method = cm.methods.find((entry) => entry.id === approachCardId); - const modalSections = method?.sections; - const defaults: ConflictModalSections = { - corePrinciple: modalSections?.corePrinciple ?? "", - applicableScope: modalSections?.applicableScope ?? [], - selectedApplicableScope: [], - processProtocol: modalSections?.processProtocol ?? "", - restorationFallbacks: modalSections?.restorationFallbacks ?? "", - }; - - const [sections, setSections] = useState(() => ({ - corePrinciple: defaults.corePrinciple, - applicableScope: [...defaults.applicableScope], - selectedApplicableScope: [...defaults.selectedApplicableScope], - processProtocol: defaults.processProtocol, - restorationFallbacks: defaults.restorationFallbacks, - })); - - const patch = useCallback( - ( - key: K, - value: ConflictModalSections[K], - ) => { - markCreateFlowInteraction(); - setSections((prev) => ({ ...prev, [key]: value })); - }, - [markCreateFlowInteraction], - ); - - return ( -
- patch("corePrinciple", v)} - /> - - patch( - "selectedApplicableScope", - sections.selectedApplicableScope.includes(scope) - ? sections.selectedApplicableScope.filter((s) => s !== scope) - : [...sections.selectedApplicableScope, scope], - ) - } - onAddScope={(scope) => - patch("applicableScope", [...sections.applicableScope, scope]) - } - /> - patch("processProtocol", v)} - /> - patch("restorationFallbacks", v)} - /> -
- ); -} +import { ConflictManagementEditFields } from "../../components/methodEditFields"; +import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; +import type { ConflictManagementDetailEntry } from "../../types"; export function ConflictManagementScreen() { const m = useMessages(); @@ -123,16 +42,11 @@ export function ConflictManagementScreen() { const [expanded, setExpanded] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(null); + const [pendingDraft, setPendingDraft] = + useState(null); const selectedIds = state.selectedConflictManagementIds ?? []; - const setSelectedIds = useCallback( - (next: string[]) => { - updateState({ selectedConflictManagementIds: next }); - }, - [updateState], - ); - const { scoresBySlug, hasAnyFacets } = useFacetRecommendations("conflictManagement"); const rankedMethods = useMemo( @@ -180,55 +94,85 @@ export function ConflictManagementScreen() { ); - const modalConfig = (() => { - if (!pendingCardId) { - return { + const modalConfig = pendingCardId + ? (() => { + const method = methodById.get(pendingCardId); + return { + title: method?.label ?? cm.confirmModal.title, + description: method?.supportText ?? cm.confirmModal.description, + nextButtonText: cm.addApproach.nextButtonText, + }; + })() + : { title: cm.confirmModal.title, description: cm.confirmModal.description, nextButtonText: cm.confirmModal.nextButtonText, - showBackButton: false as const, - currentStep: undefined, - totalSteps: undefined, }; - } - const method = methodById.get(pendingCardId); - return { - title: method?.label ?? cm.confirmModal.title, - description: method?.supportText ?? cm.confirmModal.description, - nextButtonText: cm.addApproach.nextButtonText, - showBackButton: false as const, - currentStep: undefined, - totalSteps: undefined, - }; - })(); + const seedDraft = useCallback( + (id: string): ConflictManagementDetailEntry => { + const saved = state.conflictManagementDetailsById?.[id]; + if (saved) { + return { + ...saved, + applicableScope: [...saved.applicableScope], + selectedApplicableScope: [...saved.selectedApplicableScope], + }; + } + return conflictManagementPresetFor(id); + }, + [state.conflictManagementDetailsById], + ); const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); setPendingCardId(id); + setPendingDraft(seedDraft(id)); setCreateModalOpen(true); }, + [markCreateFlowInteraction, seedDraft], + ); + + const handleDraftChange = useCallback( + (next: ConflictManagementDetailEntry) => { + markCreateFlowInteraction(); + setPendingDraft(next); + }, [markCreateFlowInteraction], ); const handleCreateModalClose = useCallback(() => { setCreateModalOpen(false); setPendingCardId(null); + setPendingDraft(null); }, []); const handleCreateModalConfirm = useCallback(() => { - markCreateFlowInteraction(); - if (pendingCardId) { - setSelectedIds( - selectedIds.includes(pendingCardId) - ? selectedIds - : [...selectedIds, pendingCardId], - ); + if (!pendingCardId || !pendingDraft) { + handleCreateModalClose(); + return; } - setCreateModalOpen(false); - setPendingCardId(null); - }, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]); + markCreateFlowInteraction(); + updateState({ + selectedConflictManagementIds: selectedIds.includes(pendingCardId) + ? selectedIds + : [...selectedIds, pendingCardId], + conflictManagementDetailsById: { + ...(state.conflictManagementDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + handleCreateModalClose(); + }, [ + handleCreateModalClose, + markCreateFlowInteraction, + pendingCardId, + pendingDraft, + selectedIds, + state.conflictManagementDetailsById, + updateState, + ]); return ( - {pendingCardId ? ( - ) : null} diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index bd20b52..902c989 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -5,10 +5,12 @@ * Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`. * * Card click opens the Figma create modal (node `20858-13948`) with three - * editable sections — Eligibility & Philosophy, Joining Process, and - * Expectations & Removal. Section defaults come from - * `messages/en/create/customRule/membership.json` and will be replaced with DB-driven - * content. + * editable sections rendered by {@link MembershipMethodEditFields}. The same + * field set is reused on `/create/final-review` — see `FinalReviewChipEditModal`. + * Confirm persists both the chip selection and any user edits as a + * `membershipMethodDetailsById[id]` override; section defaults come from + * `messages/en/create/customRule/membership.json` and will be replaced with + * DB-driven content. */ import { useState, useCallback, useMemo } from "react"; @@ -29,61 +31,9 @@ import { CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS, CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS, } from "../../components/createFlowLayoutTokens"; -import ModalTextAreaField from "../../components/ModalTextAreaField"; - -const SECTION_FIELDS = [ - "eligibility", - "joiningProcess", - "expectations", -] as const; -type SectionField = (typeof SECTION_FIELDS)[number]; - -function AddMembershipModalContent({ - membershipCardId, -}: { - membershipCardId: string; -}) { - const { markCreateFlowInteraction } = useCreateFlow(); - const m = useMessages(); - const mem = m.create.customRule.membership; - const method = mem.methods.find((entry) => entry.id === membershipCardId); - const sections = method?.sections; - const defaults: Record = { - eligibility: sections?.eligibility ?? "", - joiningProcess: sections?.joiningProcess ?? "", - expectations: sections?.expectations ?? "", - }; - - const [sectionValues, setSectionValues] = useState< - Record - >(() => ({ - eligibility: defaults.eligibility, - joiningProcess: defaults.joiningProcess, - expectations: defaults.expectations, - })); - - const updateSection = useCallback( - (key: SectionField, value: string) => { - markCreateFlowInteraction(); - setSectionValues((prev) => ({ ...prev, [key]: value })); - }, - [markCreateFlowInteraction], - ); - - return ( -
- {SECTION_FIELDS.map((field) => ( - updateSection(field, v)} - /> - ))} -
- ); -} +import { MembershipMethodEditFields } from "../../components/methodEditFields"; +import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; +import type { MembershipMethodDetailEntry } from "../../types"; export function MembershipMethodsScreen() { const m = useMessages(); @@ -93,16 +43,11 @@ export function MembershipMethodsScreen() { const [expanded, setExpanded] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(null); + const [pendingDraft, setPendingDraft] = + useState(null); const selectedIds = state.selectedMembershipMethodIds ?? []; - const setSelectedIds = useCallback( - (next: string[]) => { - updateState({ selectedMembershipMethodIds: next }); - }, - [updateState], - ); - const { scoresBySlug, hasAnyFacets } = useFacetRecommendations("membership"); const rankedMethods = useMemo( @@ -150,55 +95,81 @@ export function MembershipMethodsScreen() { ); - const modalConfig = (() => { - if (!pendingCardId) { - return { + const modalConfig = pendingCardId + ? (() => { + const method = methodById.get(pendingCardId); + return { + title: method?.label ?? mem.confirmModal.title, + description: method?.supportText ?? mem.confirmModal.description, + nextButtonText: mem.addPlatform.nextButtonText, + }; + })() + : { title: mem.confirmModal.title, description: mem.confirmModal.description, nextButtonText: mem.confirmModal.nextButtonText, - showBackButton: false as const, - currentStep: undefined, - totalSteps: undefined, }; - } - const method = methodById.get(pendingCardId); - return { - title: method?.label ?? mem.confirmModal.title, - description: method?.supportText ?? mem.confirmModal.description, - nextButtonText: mem.addPlatform.nextButtonText, - showBackButton: false as const, - currentStep: undefined, - totalSteps: undefined, - }; - })(); + const seedDraft = useCallback( + (id: string): MembershipMethodDetailEntry => { + const saved = state.membershipMethodDetailsById?.[id]; + if (saved) { + return { ...saved }; + } + return membershipPresetFor(id); + }, + [state.membershipMethodDetailsById], + ); const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); setPendingCardId(id); + setPendingDraft(seedDraft(id)); setCreateModalOpen(true); }, + [markCreateFlowInteraction, seedDraft], + ); + + const handleDraftChange = useCallback( + (next: MembershipMethodDetailEntry) => { + markCreateFlowInteraction(); + setPendingDraft(next); + }, [markCreateFlowInteraction], ); const handleCreateModalClose = useCallback(() => { setCreateModalOpen(false); setPendingCardId(null); + setPendingDraft(null); }, []); const handleCreateModalConfirm = useCallback(() => { - markCreateFlowInteraction(); - if (pendingCardId) { - setSelectedIds( - selectedIds.includes(pendingCardId) - ? selectedIds - : [...selectedIds, pendingCardId], - ); + if (!pendingCardId || !pendingDraft) { + handleCreateModalClose(); + return; } - setCreateModalOpen(false); - setPendingCardId(null); - }, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]); + markCreateFlowInteraction(); + updateState({ + selectedMembershipMethodIds: selectedIds.includes(pendingCardId) + ? selectedIds + : [...selectedIds, pendingCardId], + membershipMethodDetailsById: { + ...(state.membershipMethodDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + handleCreateModalClose(); + }, [ + handleCreateModalClose, + markCreateFlowInteraction, + pendingCardId, + pendingDraft, + selectedIds, + state.membershipMethodDetailsById, + updateState, + ]); return ( - {pendingCardId ? ( - ) : null} diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index 82003e8..5fea46e 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -1,8 +1,9 @@ "use client"; -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import RuleCard from "../../../../components/cards/RuleCard"; import type { Category } from "../../../../components/cards/RuleCard/RuleCard.types"; +import { TemplateChipDetailModal } from "../../../../components/cards/TemplateReviewCard/TemplateChipDetailModal"; import { useMessages, useTranslation } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; @@ -11,22 +12,15 @@ import { CreateFlowLockupCardStepShell, } from "../../components/CreateFlowLockupCardStepShell"; import { - buildFinalReviewCategoriesFromState, - type FinalReviewCategoryRow, + buildFinalReviewCategoryRowsDetailed, + type FinalReviewCategoryRowDetailed, } from "../../../../../lib/create/buildFinalReviewCategories"; - -function buildFinalReviewCategories( - rows: readonly FinalReviewCategoryRow[], -): Category[] { - return rows.map((cat) => ({ - name: cat.name, - chipOptions: cat.chips.map((label, idx) => ({ - id: `${cat.name}-${idx}`, - label, - state: "unselected" as const, - })), - })); -} +import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping"; +import { + FinalReviewChipEditModal, + type FinalReviewChipEditPatch, + type FinalReviewChipEditTarget, +} from "../../components/FinalReviewChipEditModal"; /** * `finalReview.json.categories` ships a demo ordering + localized names @@ -36,7 +30,7 @@ function buildFinalReviewCategories( * plain-custom flows, and fall back to the demo chips when state resolves * to nothing selected. */ -function readFallbackCategoryNames( +function readFallbackCategoryRows( categories: readonly { name: string; chips: readonly string[] }[], ): { names: { @@ -46,7 +40,7 @@ function readFallbackCategoryNames( decisions: string; conflict: string; }; - rows: FinalReviewCategoryRow[]; + rows: FinalReviewCategoryRowDetailed[]; } { const get = (i: number): string => typeof categories[i]?.name === "string" ? categories[i].name : ""; @@ -58,28 +52,158 @@ function readFallbackCategoryNames( decisions: get(3), conflict: get(4), }, - rows: categories.map((c) => ({ name: c.name, chips: [...c.chips] })), + rows: categories.map((c) => ({ + name: c.name, + groupKey: null, + entries: [...c.chips].map((label) => ({ + label, + groupKey: null, + overrideKey: null, + })), + })), }; } export function FinalReviewScreen() { - const { state } = useCreateFlow(); + const { state, updateState, markCreateFlowInteraction } = useCreateFlow(); const mdUp = useCreateFlowMdUp(); const t = useTranslation("create.reviewAndComplete.finalReview"); const m = useMessages(); - const finalReviewCategories = useMemo(() => { - const { names, rows: fallbackRows } = readFallbackCategoryNames( + /** + * Two modals coexist on this screen: + * + * - {@link FinalReviewChipEditModal} — editable Save-button version used + * whenever the chip resolves to a stable `overrideKey` (core-value + * chip id, or a method preset id). Writes through to + * `{group}DetailsById` state fields on Save; close-without-save is a + * no-op so any typed edits are discarded. + * - {@link TemplateChipDetailModal} — read-only fallback for chips we + * can't map to an override key (e.g. template body entries on the + * "Use without changes" path where no preset matches the title). + * + * `activeEditTarget` drives the editable modal; `activeReadOnlyDetail` + * drives the read-only modal; only one is ever non-null at a time. + */ + const [activeEditTarget, setActiveEditTarget] = + useState(null); + const [activeReadOnlyDetail, setActiveReadOnlyDetail] = + useState(null); + + const handleSave = useCallback( + (patch: FinalReviewChipEditPatch) => { + markCreateFlowInteraction(); + switch (patch.groupKey) { + case "coreValues": { + updateState({ + coreValueDetailsByChipId: { + ...(state.coreValueDetailsByChipId ?? {}), + [patch.overrideKey]: patch.value, + }, + }); + return; + } + case "communication": { + updateState({ + communicationMethodDetailsById: { + ...(state.communicationMethodDetailsById ?? {}), + [patch.overrideKey]: patch.value, + }, + }); + return; + } + case "membership": { + updateState({ + membershipMethodDetailsById: { + ...(state.membershipMethodDetailsById ?? {}), + [patch.overrideKey]: patch.value, + }, + }); + return; + } + case "decisionApproaches": { + updateState({ + decisionApproachDetailsById: { + ...(state.decisionApproachDetailsById ?? {}), + [patch.overrideKey]: patch.value, + }, + }); + return; + } + case "conflictManagement": { + updateState({ + conflictManagementDetailsById: { + ...(state.conflictManagementDetailsById ?? {}), + [patch.overrideKey]: patch.value, + }, + }); + return; + } + } + }, + [markCreateFlowInteraction, updateState, state], + ); + + const { categories: finalReviewCategories, chipLookup } = useMemo(() => { + const { names, rows: fallbackRows } = readFallbackCategoryRows( m.create.reviewAndComplete.finalReview.categories, ); - const derived = buildFinalReviewCategoriesFromState(state, names); - // When a user lands on final review with nothing actually selected (e.g. - // direct-nav during dev), keep the shipped demo chips rather than render - // an empty card — matches prior behavior for that edge case. - return buildFinalReviewCategories( - derived.length > 0 ? derived : fallbackRows, - ); - }, [m.create.reviewAndComplete.finalReview.categories, state]); + const derived = buildFinalReviewCategoryRowsDetailed(state, names); + const rowsToRender: readonly FinalReviewCategoryRowDetailed[] = + derived.length > 0 ? derived : fallbackRows; + + const lookup = new Map< + string, + { target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail } + >(); + + const cats: Category[] = rowsToRender.map((row) => { + const chipOptions = row.entries.map((entry, idx) => { + const chipId = `${row.name}-${idx}`; + const readOnly: TemplateChipDetail = { + chipId, + chipLabel: entry.label, + categoryName: row.name, + groupKey: entry.groupKey, + body: "", + }; + const target: FinalReviewChipEditTarget | null = + entry.groupKey && entry.overrideKey + ? { + overrideKey: entry.overrideKey, + groupKey: entry.groupKey, + chipLabel: entry.label, + } + : null; + lookup.set(chipId, { target, readOnly }); + return { + id: chipId, + label: entry.label, + state: "unselected" as const, + }; + }); + return { + name: row.name, + chipOptions, + onChipClick: (_categoryName: string, chipId: string) => { + const hit = lookup.get(chipId); + if (!hit) return; + markCreateFlowInteraction(); + if (hit.target) { + setActiveEditTarget(hit.target); + } else { + setActiveReadOnlyDetail(hit.readOnly); + } + }, + }; + }); + return { categories: cats, chipLookup: lookup }; + }, [ + m.create.reviewAndComplete.finalReview.categories, + state, + markCreateFlowInteraction, + ]); + void chipLookup; const ruleCardTitle = useMemo(() => { const raw = typeof state.title === "string" ? state.title.trim() : ""; @@ -109,6 +233,18 @@ export function FinalReviewScreen() { className={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS} onClick={() => {}} /> + setActiveEditTarget(null)} + target={activeEditTarget} + state={state} + onSave={handleSave} + /> + setActiveReadOnlyDetail(null)} + detail={activeReadOnlyDetail} + /> ); } diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx index 3e5193a..dfdd285 100644 --- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx +++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx @@ -7,18 +7,19 @@ * Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns * at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content. * - * Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls: - * Core Principle, Applicable Scope, Step-by-Step Instructions, Consensus Level, and Objections & - * Deadlocks. Section defaults are sourced from `messages/en/create/customRule/decisionApproaches.json` (read - * via `m.create.customRule.decisionApproaches`) and will be replaced with DB-driven content; labels are - * hard-coded per the Figma design. + * Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls + * rendered by {@link DecisionApproachEditFields}: Core Principle, Applicable Scope, Step-by-Step + * Instructions, Consensus Level, and Objections & Deadlocks. The same field set is reused on + * `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both the chip + * selection and any user edits as a `decisionApproachDetailsById[id]` override; section + * defaults come from `messages/en/create/customRule/decisionApproaches.json` and will be + * replaced with DB-driven content. */ import { useState, useCallback, useMemo } from "react"; import DecisionMakingSidebar from "../../../../components/utility/DecisionMakingSidebar"; import CardStack from "../../../../components/utility/CardStack"; import Create from "../../../../components/modals/Create"; -import IncrementerBlock from "../../../../components/controls/IncrementerBlock"; import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import type { InfoMessageBoxItem } from "../../../../components/utility/InfoMessageBox/InfoMessageBox.types"; import type { CardStackItem } from "../../../../components/utility/CardStack/CardStack.types"; @@ -31,110 +32,9 @@ import { useFacetRecommendations, } from "../../hooks/useFacetRecommendations"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; -import ModalTextAreaField from "../../components/ModalTextAreaField"; -import ApplicableScopeField from "../../components/ApplicableScopeField"; - -const CONSENSUS_LEVEL_MIN = 0; -const CONSENSUS_LEVEL_MAX = 100; -const CONSENSUS_LEVEL_STEP = 5; -const CONSENSUS_LEVEL_DEFAULT = 75; - -type RightRailModalSections = { - corePrinciple: string; - applicableScope: string[]; - selectedApplicableScope: string[]; - stepByStepInstructions: string; - consensusLevel: number; - objectionsDeadlocks: string; -}; - -function AddDecisionApproachModalContent({ - approachCardId, -}: { - approachCardId: string; -}) { - const { markCreateFlowInteraction } = useCreateFlow(); - const m = useMessages(); - const da = m.create.customRule.decisionApproaches; - const method = da.methods.find((entry) => entry.id === approachCardId); - const modalSections = method?.sections; - const defaults: RightRailModalSections = { - corePrinciple: modalSections?.corePrinciple ?? "", - applicableScope: modalSections?.applicableScope ?? [], - selectedApplicableScope: [], - stepByStepInstructions: modalSections?.stepByStepInstructions ?? "", - consensusLevel: modalSections?.consensusLevel ?? CONSENSUS_LEVEL_DEFAULT, - objectionsDeadlocks: modalSections?.objectionsDeadlocks ?? "", - }; - - const [sections, setSections] = useState(() => ({ - corePrinciple: defaults.corePrinciple, - applicableScope: [...defaults.applicableScope], - selectedApplicableScope: [...defaults.selectedApplicableScope], - stepByStepInstructions: defaults.stepByStepInstructions, - consensusLevel: defaults.consensusLevel, - objectionsDeadlocks: defaults.objectionsDeadlocks, - })); - - const patch = useCallback( - ( - key: K, - value: RightRailModalSections[K], - ) => { - markCreateFlowInteraction(); - setSections((prev) => ({ ...prev, [key]: value })); - }, - [markCreateFlowInteraction], - ); - - return ( -
- patch("corePrinciple", v)} - /> - - patch( - "selectedApplicableScope", - sections.selectedApplicableScope.includes(scope) - ? sections.selectedApplicableScope.filter((s) => s !== scope) - : [...sections.selectedApplicableScope, scope], - ) - } - onAddScope={(scope) => - patch("applicableScope", [...sections.applicableScope, scope]) - } - /> - patch("stepByStepInstructions", v)} - /> - patch("consensusLevel", next)} - formatValue={(v) => `${v}%`} - decrementAriaLabel="Decrease consensus level" - incrementAriaLabel="Increase consensus level" - /> - patch("objectionsDeadlocks", v)} - /> -
- ); -} +import { DecisionApproachEditFields } from "../../components/methodEditFields"; +import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; +import type { DecisionApproachDetailEntry } from "../../types"; export function DecisionApproachesScreen() { const m = useMessages(); @@ -147,16 +47,11 @@ export function DecisionApproachesScreen() { const [expanded, setExpanded] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(null); + const [pendingDraft, setPendingDraft] = + useState(null); const selectedIds = state.selectedDecisionApproachIds ?? []; - const setSelectedIds = useCallback( - (next: string[]) => { - updateState({ selectedDecisionApproachIds: next }); - }, - [updateState], - ); - const messageBoxItems: InfoMessageBoxItem[] = useMemo( () => da.messageBox.items.map((item) => ({ @@ -219,12 +114,36 @@ export function DecisionApproachesScreen() { [markCreateFlowInteraction], ); + const seedDraft = useCallback( + (id: string): DecisionApproachDetailEntry => { + const saved = state.decisionApproachDetailsById?.[id]; + if (saved) { + return { + ...saved, + applicableScope: [...saved.applicableScope], + selectedApplicableScope: [...saved.selectedApplicableScope], + }; + } + return decisionApproachPresetFor(id); + }, + [state.decisionApproachDetailsById], + ); + const handleCardSelect = useCallback( (id: string) => { markCreateFlowInteraction(); setPendingCardId(id); + setPendingDraft(seedDraft(id)); setCreateModalOpen(true); }, + [markCreateFlowInteraction, seedDraft], + ); + + const handleDraftChange = useCallback( + (next: DecisionApproachDetailEntry) => { + markCreateFlowInteraction(); + setPendingDraft(next); + }, [markCreateFlowInteraction], ); @@ -236,37 +155,49 @@ export function DecisionApproachesScreen() { const handleCreateModalClose = useCallback(() => { setCreateModalOpen(false); setPendingCardId(null); + setPendingDraft(null); }, []); const handleCreateModalConfirm = useCallback(() => { - markCreateFlowInteraction(); - if (pendingCardId) { - setSelectedIds( - selectedIds.includes(pendingCardId) - ? selectedIds - : [...selectedIds, pendingCardId], - ); + if (!pendingCardId || !pendingDraft) { + handleCreateModalClose(); + return; } - setCreateModalOpen(false); - setPendingCardId(null); - }, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]); + markCreateFlowInteraction(); + updateState({ + selectedDecisionApproachIds: selectedIds.includes(pendingCardId) + ? selectedIds + : [...selectedIds, pendingCardId], + decisionApproachDetailsById: { + ...(state.decisionApproachDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + handleCreateModalClose(); + }, [ + handleCreateModalClose, + markCreateFlowInteraction, + pendingCardId, + pendingDraft, + selectedIds, + state.decisionApproachDetailsById, + updateState, + ]); - const modalConfig = (() => { - if (!pendingCardId) { - return { + const modalConfig = pendingCardId + ? (() => { + const method = methodById.get(pendingCardId); + return { + title: method?.label ?? da.confirmModal.title, + description: method?.supportText ?? da.confirmModal.description, + nextButtonText: da.addApproach.nextButtonText, + }; + })() + : { title: da.confirmModal.title, description: da.confirmModal.description, nextButtonText: da.confirmModal.nextButtonText, }; - } - - const method = methodById.get(pendingCardId); - return { - title: method?.label ?? da.confirmModal.title, - description: method?.supportText ?? da.confirmModal.description, - nextButtonText: da.addApproach.nextButtonText, - }; - })(); return ( - {pendingCardId ? ( - ) : null} diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx index 64a30e3..7bd37fa 100644 --- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx +++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx @@ -3,14 +3,17 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import MultiSelect from "../../../../components/controls/MultiSelect"; import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types"; -import TextArea from "../../../../components/controls/TextArea"; import Create from "../../../../components/modals/Create"; import ContentLockup from "../../../../components/type/ContentLockup"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; -import type { CommunityStructureChipSnapshotRow } from "../../types"; +import type { + CommunityStructureChipSnapshotRow, + CoreValueDetailEntry, +} from "../../types"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; +import { CoreValueEditFields } from "../../components/methodEditFields"; const MAX_CORE_VALUES = 5; @@ -96,6 +99,8 @@ function snapshotRowsToChipOptions( })); } +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(); @@ -122,8 +127,7 @@ export function CoreValuesSelectScreen() { null, ); const [modalSession, setModalSession] = useState(null); - const [draftMeaning, setDraftMeaning] = useState(""); - const [draftSignals, setDraftSignals] = useState(""); + const [draft, setDraft] = useState(EMPTY_DETAIL); useEffect(() => { const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot); @@ -158,16 +162,16 @@ export function CoreValuesSelectScreen() { /** Default meaning/signals from `coreValues.json` `values` for each preset label. */ const getPresetTexts = useCallback( - (valueLabel: string): { meaning: string; signals: string } => { + (valueLabel: string): CoreValueDetailEntry => { const row = presets.find((p) => p.label === valueLabel); - if (!row) return { meaning: "", signals: "" }; + if (!row) return EMPTY_DETAIL; return { meaning: row.meaning, signals: row.signals }; }, [presets], ); const getInitialTexts = useCallback( - (chipId: string, valueLabel: string) => { + (chipId: string, valueLabel: string): CoreValueDetailEntry => { const saved = state.coreValueDetailsByChipId?.[chipId]; const preset = getPresetTexts(valueLabel); return { @@ -180,9 +184,7 @@ export function CoreValuesSelectScreen() { const openModal = useCallback( (chipId: string, session: ModalSession, valueLabel: string) => { - const initial = getInitialTexts(chipId, valueLabel); - setDraftMeaning(initial.meaning); - setDraftSignals(initial.signals); + setDraft(getInitialTexts(chipId, valueLabel)); setActiveModalChipId(chipId); setModalSession(session); markCreateFlowInteraction(); @@ -190,6 +192,14 @@ export function CoreValuesSelectScreen() { [getInitialTexts, markCreateFlowInteraction], ); + const handleDraftChange = useCallback( + (next: CoreValueDetailEntry) => { + markCreateFlowInteraction(); + setDraft(next); + }, + [markCreateFlowInteraction], + ); + const handleModalDismiss = useCallback(() => { if (activeModalChipId && modalSession === "pending") { const next = coreValueOptions.map((opt) => @@ -208,19 +218,17 @@ export function CoreValuesSelectScreen() { markCreateFlowInteraction(); updateState({ coreValueDetailsByChipId: { - [activeModalChipId]: { - meaning: draftMeaning, - signals: draftSignals, - }, + ...(state.coreValueDetailsByChipId ?? {}), + [activeModalChipId]: draft, }, }); setActiveModalChipId(null); setModalSession(null); }, [ activeModalChipId, - draftMeaning, - draftSignals, + draft, markCreateFlowInteraction, + state.coreValueDetailsByChipId, updateState, ]); @@ -374,26 +382,7 @@ export function CoreValuesSelectScreen() { nextButtonText={detailModal.addValueButton} ariaLabel={modalChipLabel || "Core value details"} > -
-