QA pass: layout, create-flow, and About books #68
@@ -25,6 +25,8 @@ import {
|
|||||||
MAX_SELECTED_CORE_VALUES,
|
MAX_SELECTED_CORE_VALUES,
|
||||||
removeCoreValueChipFromDraft,
|
removeCoreValueChipFromDraft,
|
||||||
} from "../../../../../lib/create/coreValueChipFacet";
|
} from "../../../../../lib/create/coreValueChipFacet";
|
||||||
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||||
import {
|
import {
|
||||||
buildMethodCardWizardInitialValues,
|
buildMethodCardWizardInitialValues,
|
||||||
coreValueDetailsFromWizardFieldBlocks,
|
coreValueDetailsFromWizardFieldBlocks,
|
||||||
@@ -41,11 +43,13 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
|
|||||||
*
|
*
|
||||||
* - `pending` — preset chip just selected; modal opened to capture
|
* - `pending` — preset chip just selected; modal opened to capture
|
||||||
* meaning/signals. Close (X) confirms, then unselects the chip.
|
* meaning/signals. Close (X) confirms, then unselects the chip.
|
||||||
* - `customPending` — brand-new custom chip just created via the Add
|
* - `customPending` — leftover inline custom-chip drafts (older sessions).
|
||||||
* value flow; modal opened with empty fields. Dismiss = drop the
|
* Dismiss = drop the chip entirely.
|
||||||
* chip entirely (it was never confirmed via the Add Value button).
|
|
||||||
* - `editing` — chip already exists & is selected; modal reopened to
|
* - `editing` — chip already exists & is selected; modal reopened to
|
||||||
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
|
* 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";
|
type ModalSession = "pending" | "customPending" | "editing";
|
||||||
|
|
||||||
@@ -489,12 +493,57 @@ export function CoreValuesSelectScreen() {
|
|||||||
description: string;
|
description: string;
|
||||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
}) => {
|
}) => {
|
||||||
const chipId = wizardCustomizeChipId ?? activeModalChipId;
|
|
||||||
if (!chipId) return;
|
|
||||||
markCreateFlowInteraction();
|
|
||||||
pendingEphemeralCoreDuplicateRef.current = null;
|
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
const trimmedDescription = description.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 = {
|
const nextDetails = {
|
||||||
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
|
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
|
||||||
...(trimmedDescription.length > 0
|
...(trimmedDescription.length > 0
|
||||||
@@ -503,8 +552,8 @@ export function CoreValuesSelectScreen() {
|
|||||||
};
|
};
|
||||||
replaceState((prev) => {
|
replaceState((prev) => {
|
||||||
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
|
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
|
||||||
const i = snap.findIndex((r) => r.id === chipId);
|
const i = snap.findIndex((r) => r.id === existingId);
|
||||||
if (i >= 0 && trimmedTitle.length > 0) {
|
if (i >= 0) {
|
||||||
snap[i] = { ...snap[i], label: trimmedTitle };
|
snap[i] = { ...snap[i], label: trimmedTitle };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -512,11 +561,11 @@ export function CoreValuesSelectScreen() {
|
|||||||
coreValuesChipsSnapshot: snap,
|
coreValuesChipsSnapshot: snap,
|
||||||
coreValueDetailsByChipId: {
|
coreValueDetailsByChipId: {
|
||||||
...(prev.coreValueDetailsByChipId ?? {}),
|
...(prev.coreValueDetailsByChipId ?? {}),
|
||||||
[chipId]: nextDetails,
|
[existingId]: nextDetails,
|
||||||
},
|
},
|
||||||
customMethodCardFieldBlocksById: {
|
customMethodCardFieldBlocksById: {
|
||||||
...(prev.customMethodCardFieldBlocksById ?? {}),
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
[chipId]: structuredClone(fieldBlocks),
|
[existingId]: structuredClone(fieldBlocks),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -526,13 +575,7 @@ export function CoreValuesSelectScreen() {
|
|||||||
setWizardCustomizeChipId(null);
|
setWizardCustomizeChipId(null);
|
||||||
setWizardInitialValues(null);
|
setWizardInitialValues(null);
|
||||||
},
|
},
|
||||||
[
|
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
|
||||||
activeModalChipId,
|
|
||||||
draft,
|
|
||||||
markCreateFlowInteraction,
|
|
||||||
replaceState,
|
|
||||||
wizardCustomizeChipId,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const kebabMenuItems = useMemo(() => {
|
const kebabMenuItems = useMemo(() => {
|
||||||
@@ -587,15 +630,14 @@ export function CoreValuesSelectScreen() {
|
|||||||
|
|
||||||
const addHandlers = {
|
const addHandlers = {
|
||||||
onAddClick: () => {
|
onAddClick: () => {
|
||||||
|
const selectedCount = coreValueOptions.filter(
|
||||||
|
(o) => o.state === "selected",
|
||||||
|
).length;
|
||||||
|
if (selectedCount >= MAX_CORE_VALUES) return;
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setCoreValueOptions((prev) => {
|
setWizardCustomizeChipId(null);
|
||||||
const next: ChipOption[] = [
|
setWizardInitialValues(null);
|
||||||
...prev,
|
setAddCustomWizardOpen(true);
|
||||||
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
|
||||||
];
|
|
||||||
queueMicrotask(() => syncCoreValuesToDraft(next));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
|||||||
@@ -256,25 +256,12 @@ describe("CoreValuesSelectScreen", () => {
|
|||||||
expect(labels[1]).toMatch(/What does this value mean to your group/);
|
expect(labels[1]).toMatch(/What does this value mean to your group/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The "Add value" → custom-chip → modal flow uses a `customPending`
|
// The "Add value" control opens an empty custom-policy wizard. Dismissing
|
||||||
// session: dismissing the modal must drop the brand-new chip entirely
|
// without Finalize leaves the chip list unchanged. Finalize writes a
|
||||||
// (not just unselect it), because the user never confirmed it via
|
// selected chip named from the wizard title.
|
||||||
// the modal's Add Value button. Clicking Add Value keeps the chip
|
describe("Add value wizard", () => {
|
||||||
// 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).
|
|
||||||
const CUSTOM_LABEL = "ZZTopBespokeValue";
|
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
|
* 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
|
* is open, and as the chip's "Remove" button aria-label. Scope to
|
||||||
@@ -290,33 +277,50 @@ describe("CoreValuesSelectScreen", () => {
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("removes the custom chip when its modal is dismissed without Add Value", async () => {
|
async function completeAddValueWizard(label: string) {
|
||||||
renderWithProviders(<CoreValuesSelectScreen />);
|
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
|
||||||
await addCustomChipNamed(CUSTOM_LABEL);
|
const nameInput = await screen.findByPlaceholderText("Policy name");
|
||||||
expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
|
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" });
|
it("opens the empty custom-policy wizard from Add value", async () => {
|
||||||
await waitFor(() => {
|
renderWithProviders(<CoreValuesSelectScreen />);
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
|
||||||
});
|
expect(
|
||||||
// Chip must be gone — not just unselected. If it were merely
|
await screen.findByPlaceholderText("Policy name"),
|
||||||
// unselected the chip button would still render. Wrap in waitFor
|
).toHaveValue("");
|
||||||
// because the chip removal flushes through `updateState` →
|
expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument();
|
||||||
// `useEffect` → `setCoreValueOptions` and isn't synchronous with
|
|
||||||
// the dialog close.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(countCustomChips(CUSTOM_LABEL)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(<CoreValuesSelectScreen />);
|
renderWithProviders(<CoreValuesSelectScreen />);
|
||||||
const dialog = await addCustomChipNamed(CUSTOM_LABEL);
|
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
|
||||||
fireEvent.click(
|
await screen.findByPlaceholderText("Policy name");
|
||||||
within(dialog).getByRole("button", { name: "Add Value" }),
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
);
|
|
||||||
await waitFor(() => {
|
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(<CoreValuesSelectScreen />);
|
||||||
|
await completeAddValueWizard(CUSTOM_LABEL);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByPlaceholderText("Policy name"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
|
expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user