Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions gui/src/components/codex-set/CustomLayerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export default function CustomLayerDialog({
* mid-edit, which is the whole point of moving between them.
*/
const draftsRef = useRef(new Map<string, { title: string; body: string }>());
const [parkedDirty, setParkedDirty] = useState(false);
const othersRef = useRef(others);
useEffect(() => { othersRef.current = others; }, [others]);
const editingId = layer?.id ?? null;
const lastIdRef = useRef(editingId);

Expand All @@ -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
Expand All @@ -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;
Expand All @@ -114,16 +124,20 @@ 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();
requestClose();
}, [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;
Expand Down Expand Up @@ -215,7 +229,7 @@ export default function CustomLayerDialog({
</ul>
)}

{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.
Expand All @@ -225,10 +239,14 @@ export default function CustomLayerDialog({
aria-labelledby={titleId + "-discard"}
>
<span id={titleId + "-discard"} className="muted small">{t("codexSet.custom.discardPrompt")}</span>
<button type="button" className="btn btn-sm" onClick={() => setConfirmingDiscard(false)}>
<button type="button" className="btn btn-sm" onClick={() => setDiscardAction(null)}>
{t("codexSet.custom.keepEditing")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={onClose}>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={() => discardAction === "save" ? onSave({ ...draft, body: normalized }) : onClose()}
>
{t("common.discard")}
</button>
</div>
Expand All @@ -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")}
</button>
Expand Down
30 changes: 30 additions & 0 deletions gui/tests/codex-set-stack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading