diff --git a/gui/src/components/codex-set/CustomLayerDialog.tsx b/gui/src/components/codex-set/CustomLayerDialog.tsx index 29efc6bacc..2221ab0a92 100644 --- a/gui/src/components/codex-set/CustomLayerDialog.tsx +++ b/gui/src/components/codex-set/CustomLayerDialog.tsx @@ -68,6 +68,9 @@ export default function CustomLayerDialog({ * mid-edit, which is the whole point of moving between them. */ const draftsRef = useRef(new Map()); + const [parkedDirty, setParkedDirty] = useState(false); + const othersRef = useRef(others); + useEffect(() => { othersRef.current = others; }, [others]); const editingId = layer?.id ?? null; const lastIdRef = useRef(editingId); @@ -92,8 +95,13 @@ export default function CustomLayerDialog({ const parked = editingId === null ? undefined : draftsRef.current.get(editingId); setTitle(parked?.title ?? layer?.title ?? ""); setBody(parked?.body ?? layer?.body ?? ""); + setParkedDirty([...draftsRef.current].some(([id, draft]) => { + if (id === editingId) return false; + const saved = othersRef.current.find(candidate => candidate.id === id); + return saved !== undefined && (draft.title !== saved.title || draft.body !== saved.body); + })); }, [editingId, layer]); - const [confirmingDiscard, setConfirmingDiscard] = useState(false); + const [discardAction, setDiscardAction] = useState<"close" | "save" | null>(null); const titleId = "codex-set-custom-dialog"; // Compare against what the editor OPENED with, seed included. Comparing against @@ -102,6 +110,8 @@ export default function CustomLayerDialog({ const initialTitle = layer?.title ?? seed?.title ?? ""; const initialBody = layer?.body ?? seed?.body ?? ""; const dirty = title !== initialTitle || body !== initialBody; + // A parked draft is still live user work. Exclude the displayed layer because + // its inputs supersede the older parked copy when someone navigates back. useEffect(() => { const dialog = dialogRef.current; @@ -114,9 +124,9 @@ export default function CustomLayerDialog({ }, []); const requestClose = useCallback(() => { - if (dirty) { setConfirmingDiscard(true); return; } + if (dirty || parkedDirty) { setDiscardAction("close"); return; } onClose(); - }, [dirty, onClose]); + }, [dirty, parkedDirty, onClose]); const handleCancel = useCallback((event: React.SyntheticEvent) => { event.preventDefault(); @@ -124,6 +134,10 @@ export default function CustomLayerDialog({ }, [requestClose]); const draft: Draft = { id: layer?.id ?? null, title, body, enabled: layer?.enabled ?? true }; + const requestSave = () => { + if (parkedDirty) { setDiscardAction("save"); return; } + onSave({ ...draft, body: normalizeBody(body) }); + }; const problem = validateDraft(draft, others); const normalized = normalizeBody(body); const normalizationApplied = normalized !== body; @@ -215,7 +229,7 @@ export default function CustomLayerDialog({ )} - {confirmingDiscard ? ( + {discardAction ? ( // The prompt text IS the accessible name. role="alertdialog" without one // announces an unnamed dialog, so a screen-reader user is asked to confirm // something the announcement never states. @@ -225,10 +239,14 @@ export default function CustomLayerDialog({ aria-labelledby={titleId + "-discard"} > {t("codexSet.custom.discardPrompt")} - - @@ -238,7 +256,7 @@ export default function CustomLayerDialog({ type="button" className="btn btn-primary btn-sm" disabled={problem !== null || busy} - onClick={() => onSave({ ...draft, body: normalized })} + onClick={requestSave} > {t("common.save")} diff --git a/gui/tests/codex-set-stack.test.tsx b/gui/tests/codex-set-stack.test.tsx index d28b968585..c880b88fc4 100644 --- a/gui/tests/codex-set-stack.test.tsx +++ b/gui/tests/codex-set-stack.test.tsx @@ -249,6 +249,36 @@ test("8. an unsaved edit survives navigating away and back", async () => { await act(async () => { root.unmount(); }); }); +test("8b. closing or saving another layer warns about a parked edit", async () => { + const calls = stubRoutes(call => { + if (call.url.includes("/text")) return json({ ok: true, layers: {} }); + if (call.method === "PUT") return json({ ok: true, changed: true, snapshot: snapshot({ custom: THREE }) }); + return json(snapshot({ custom: THREE })); + }); + const { container, root } = await mount(); + await openEditor(container, "aaaaaa"); + await act(async () => { typeInto(fields().body, "Parked work in progress."); }); + await act(async () => { navButtons()[1]!.click(); }); + + await act(async () => { dialog().dispatchEvent(new testWindow.Event("cancel", { cancelable: true })); }); + expect(dialog().querySelector(".codex-set-custom-dialog__discard")).not.toBeNull(); + await act(async () => { + const keepEditing = [...dialog().querySelectorAll("button")].find(button => button.textContent?.includes("Keep editing"))!; + keepEditing.click(); + }); + + const save = [...dialog().querySelectorAll("button")].find(button => button.textContent?.includes("Save"))!; + await act(async () => { save.click(); }); + expect(calls.filter(call => call.method === "PUT")).toHaveLength(0); + expect(dialog().querySelector(".codex-set-custom-dialog__discard")).not.toBeNull(); + await act(async () => { + const discard = [...dialog().querySelectorAll("button")].find(button => button.textContent?.includes("Discard"))!; + discard.click(); + }); + expect(calls.filter(call => call.method === "PUT")).toHaveLength(1); + await act(async () => { root.unmount(); }); +}); + test("10. one layer offers no navigation at all", async () => { stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot({ custom: [layer()] })))); const { container, root } = await mount();