diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx
index 5732c74..743c044 100644
--- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx
+++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx
@@ -25,6 +25,8 @@ import {
MAX_SELECTED_CORE_VALUES,
removeCoreValueChipFromDraft,
} from "../../../../../lib/create/coreValueChipFacet";
+import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
+import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import {
buildMethodCardWizardInitialValues,
coreValueDetailsFromWizardFieldBlocks,
@@ -41,11 +43,13 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
*
* - `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).
+ * - `customPending` — leftover inline custom-chip drafts (older sessions).
+ * Dismiss = drop the chip entirely.
* - `editing` — chip already exists & is selected; modal reopened to
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
+ *
+ * **Add value** (and the header "add" link) opens an empty custom-policy
+ * wizard. Finalize writes a selected chip named from the wizard title.
*/
type ModalSession = "pending" | "customPending" | "editing";
@@ -489,12 +493,57 @@ export function CoreValuesSelectScreen() {
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
- const chipId = wizardCustomizeChipId ?? activeModalChipId;
- if (!chipId) return;
- markCreateFlowInteraction();
- pendingEphemeralCoreDuplicateRef.current = null;
const trimmedTitle = title.trim();
const trimmedDescription = description.trim();
+ if (!trimmedTitle) return;
+ markCreateFlowInteraction();
+ pendingEphemeralCoreDuplicateRef.current = null;
+
+ const existingId = wizardCustomizeChipId;
+ if (!existingId) {
+ const id = crypto.randomUUID();
+ const nextDetails = {
+ ...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, EMPTY_DETAIL),
+ ...(trimmedDescription.length > 0
+ ? { supportText: trimmedDescription }
+ : {}),
+ };
+ replaceState((prev) => {
+ const sel = prev.selectedCoreValueIds ?? [];
+ if (sel.length >= MAX_CORE_VALUES) {
+ return prev;
+ }
+ const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
+ snap.push({
+ id,
+ label: trimmedTitle,
+ state: "selected",
+ });
+ return {
+ ...prev,
+ selectedCoreValueIds: moveFacetSelectionIdToFront(sel, id),
+ coreValuesChipsSnapshot: snap,
+ coreValueDetailsByChipId: {
+ ...(prev.coreValueDetailsByChipId ?? {}),
+ [id]: nextDetails,
+ },
+ customMethodCardFieldBlocksById: {
+ ...(prev.customMethodCardFieldBlocksById ?? {}),
+ [id]: structuredClone(fieldBlocks),
+ },
+ customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
+ prev.customMethodCardMetaById,
+ id,
+ { title: trimmedTitle, description: trimmedDescription },
+ ),
+ };
+ });
+ setAddCustomWizardOpen(false);
+ setWizardCustomizeChipId(null);
+ setWizardInitialValues(null);
+ return;
+ }
+
const nextDetails = {
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
...(trimmedDescription.length > 0
@@ -503,8 +552,8 @@ export function CoreValuesSelectScreen() {
};
replaceState((prev) => {
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
- const i = snap.findIndex((r) => r.id === chipId);
- if (i >= 0 && trimmedTitle.length > 0) {
+ const i = snap.findIndex((r) => r.id === existingId);
+ if (i >= 0) {
snap[i] = { ...snap[i], label: trimmedTitle };
}
return {
@@ -512,11 +561,11 @@ export function CoreValuesSelectScreen() {
coreValuesChipsSnapshot: snap,
coreValueDetailsByChipId: {
...(prev.coreValueDetailsByChipId ?? {}),
- [chipId]: nextDetails,
+ [existingId]: nextDetails,
},
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
- [chipId]: structuredClone(fieldBlocks),
+ [existingId]: structuredClone(fieldBlocks),
},
};
});
@@ -526,13 +575,7 @@ export function CoreValuesSelectScreen() {
setWizardCustomizeChipId(null);
setWizardInitialValues(null);
},
- [
- activeModalChipId,
- draft,
- markCreateFlowInteraction,
- replaceState,
- wizardCustomizeChipId,
- ],
+ [draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
);
const kebabMenuItems = useMemo(() => {
@@ -587,15 +630,14 @@ export function CoreValuesSelectScreen() {
const addHandlers = {
onAddClick: () => {
+ const selectedCount = coreValueOptions.filter(
+ (o) => o.state === "selected",
+ ).length;
+ if (selectedCount >= MAX_CORE_VALUES) return;
markCreateFlowInteraction();
- setCoreValueOptions((prev) => {
- const next: ChipOption[] = [
- ...prev,
- { id: crypto.randomUUID(), label: "", state: "custom" },
- ];
- queueMicrotask(() => syncCoreValuesToDraft(next));
- return next;
- });
+ setWizardCustomizeChipId(null);
+ setWizardInitialValues(null);
+ setAddCustomWizardOpen(true);
},
onCustomChipConfirm: (chipId: string, value: string) => {
markCreateFlowInteraction();
diff --git a/tests/components/CoreValuesSelectScreen.test.tsx b/tests/components/CoreValuesSelectScreen.test.tsx
index fa7e044..02dce53 100644
--- a/tests/components/CoreValuesSelectScreen.test.tsx
+++ b/tests/components/CoreValuesSelectScreen.test.tsx
@@ -256,25 +256,12 @@ describe("CoreValuesSelectScreen", () => {
expect(labels[1]).toMatch(/What does this value mean to your group/);
});
- // The "Add value" → custom-chip → modal flow uses a `customPending`
- // session: dismissing the modal must drop the brand-new chip entirely
- // (not just unselect it), because the user never confirmed it via
- // the modal's Add Value button. Clicking Add Value keeps the chip
- // as a selected entry. These two tests pin both halves of the
- // contract so the screens stay in sync with the create-flow draft.
- describe("custom chip — confirmed vs dismissed", () => {
- // Use a label guaranteed to NOT collide with any preset value
- // (we'd otherwise get two matching chips and false positives).
+ // The "Add value" control opens an empty custom-policy wizard. Dismissing
+ // without Finalize leaves the chip list unchanged. Finalize writes a
+ // selected chip named from the wizard title.
+ describe("Add value wizard", () => {
const CUSTOM_LABEL = "ZZTopBespokeValue";
- async function addCustomChipNamed(label: string) {
- fireEvent.click(screen.getByRole("button", { name: "Add value" }));
- const input = await screen.findByPlaceholderText("Type to add");
- fireEvent.change(input, { target: { value: label } });
- fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
- return screen.findByRole("dialog");
- }
-
/**
* The label can also appear in the modal header while the modal
* is open, and as the chip's "Remove" button aria-label. Scope to
@@ -290,33 +277,50 @@ describe("CoreValuesSelectScreen", () => {
).length;
}
- it("removes the custom chip when its modal is dismissed without Add Value", async () => {
- renderWithProviders();
- await addCustomChipNamed(CUSTOM_LABEL);
- expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
+ async function completeAddValueWizard(label: string) {
+ fireEvent.click(screen.getByRole("button", { name: "Add value" }));
+ const nameInput = await screen.findByPlaceholderText("Policy name");
+ fireEvent.change(nameInput, { target: { value: label } });
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ const descriptionInput =
+ await screen.findByPlaceholderText("Policy description");
+ fireEvent.change(descriptionInput, {
+ target: { value: "A community-authored value." },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Finalize" }));
+ }
- fireEvent.keyDown(document, { key: "Escape" });
- await waitFor(() => {
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
- });
- // Chip must be gone — not just unselected. If it were merely
- // unselected the chip button would still render. Wrap in waitFor
- // because the chip removal flushes through `updateState` →
- // `useEffect` → `setCoreValueOptions` and isn't synchronous with
- // the dialog close.
- await waitFor(() => {
- expect(countCustomChips(CUSTOM_LABEL)).toBe(0);
- });
+ it("opens the empty custom-policy wizard from Add value", async () => {
+ renderWithProviders();
+ fireEvent.click(screen.getByRole("button", { name: "Add value" }));
+ expect(
+ await screen.findByPlaceholderText("Policy name"),
+ ).toHaveValue("");
+ expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument();
});
- it("keeps the custom chip selected when Add Value is clicked", async () => {
+ it("leaves no chip when the Add value wizard is dismissed", async () => {
renderWithProviders();
- const dialog = await addCustomChipNamed(CUSTOM_LABEL);
- fireEvent.click(
- within(dialog).getByRole("button", { name: "Add Value" }),
- );
+ fireEvent.click(screen.getByRole("button", { name: "Add value" }));
+ await screen.findByPlaceholderText("Policy name");
+ fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(
+ screen.queryByPlaceholderText("Policy name"),
+ ).not.toBeInTheDocument();
+ });
+ expect(countCustomChips(CUSTOM_LABEL)).toBe(0);
+ expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument();
+ });
+
+ it("adds a selected chip named from the wizard title after Finalize", async () => {
+ renderWithProviders();
+ await completeAddValueWizard(CUSTOM_LABEL);
+ await waitFor(() => {
+ expect(
+ screen.queryByPlaceholderText("Policy name"),
+ ).not.toBeInTheDocument();
});
expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
});