Files
ihasmail-inbuxa/web/src/lib/__tests__/unsavedChanges.test.tsx
T
jcoffey-dev 4a99b77bc3 Highlight saving, not discarding, on the unsaved-changes guard
The guard shipped with "Discard changes" as the only choice carrying a
colour -- a filled red button, against a plain outlined "Save changes" --
which made losing the work the loudest thing in a dialog whose entire
purpose is to stop that. The emphasis belongs on the safe answer.

A dialog choice can now be marked `primary`, and Save is. Discard keeps its
`danger` flag, but a danger choice is drawn the way `.menu-item.danger`
already is: a red label on the ordinary surface. In a list of answers a
filled red button is not "this one is destructive", it is "this one is the
default", which is the opposite of what it meant here.

That rendering change reaches the other choice dialog too -- the calendar's
"this occurrence or the whole series", where both answers are marked danger
because both delete something. Two filled red buttons become two red labels
and nothing is highlighted, which is right: neither answer there is the safe
one, so neither should look like it.

Checked in the browser against the mock, in both themes. Light: #dc2626 on
white, 4.8:1. Dark: the theme's own --danger, which every palette already
tunes for contrast on this surface.

Reported on #175 by the reporter's colleague, who is right that the
non-destructive action is the one that normally gets the highlight.
2026-09-02 07:05:27 -07:00

111 lines
3.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
/*
* The guard's answers, and which of them the dialog leans on.
*
* It shipped with "Discard changes" as the only choice carrying a colour, which
* made losing the work the easy thing to click on a dialog whose entire purpose
* is to stop that (#175). The emphasis belongs on the safe answer; the
* destructive one stays legible as destructive without being the loudest thing
* in the box.
*/
const choiceDialog = vi.fn();
vi.mock("@/ui/dialog", () => ({ choiceDialog: (...args: unknown[]) => choiceDialog(...args) }));
const { confirmLeaveUnsaved, useUnsavedChanges } = await import("@/lib/unsavedChanges");
interface Choice { value: string; label: string; hint?: string; danger?: boolean; primary?: boolean }
const save = vi.fn(async () => true);
const discard = vi.fn();
function Editor() {
useUnsavedChanges({ dirty: true, save, discard, message: "Your filters have unsaved changes." });
return null;
}
let root: Root | null = null;
let host: HTMLDivElement | null = null;
/** One dirty editor on screen, which is what registers anything at all. */
function mountDirtyEditor() {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root!.render(<Editor />));
}
const asked = () => choiceDialog.mock.calls[0]![0] as { choices: Choice[]; cancelLabel: string; title: string; message: string };
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
choiceDialog.mockReset().mockResolvedValue(null);
save.mockClear().mockResolvedValue(true);
discard.mockClear();
mountDirtyEditor();
});
afterEach(() => {
act(() => root!.unmount());
host!.remove();
root = null;
host = null;
});
describe("the unsaved-changes guard", () => {
it("highlights saving, not discarding", async () => {
await confirmLeaveUnsaved();
const [saveChoice, discardChoice] = asked().choices;
expect(saveChoice!.primary).toBe(true);
expect(discardChoice!.primary).toBeFalsy();
});
it("still says which answer is the destructive one", async () => {
await confirmLeaveUnsaved();
const [saveChoice, discardChoice] = asked().choices;
expect(discardChoice!.danger).toBe(true);
expect(discardChoice!.hint).toBeTruthy();
expect(saveChoice!.danger).toBeFalsy();
});
it("offers saving first, and staying as the way out", async () => {
await confirmLeaveUnsaved();
expect(asked().choices.map((c) => c.value)).toEqual(["save", "discard"]);
expect(asked().cancelLabel).toBeTruthy();
});
it("says what is about to be lost, in the editor's own words", async () => {
await confirmLeaveUnsaved();
expect(asked().message).toBe("Your filters have unsaved changes.");
});
it("stays put when the dialog is dismissed, and loses nothing", async () => {
choiceDialog.mockResolvedValue(null);
await expect(confirmLeaveUnsaved()).resolves.toBe(false);
expect(save).not.toHaveBeenCalled();
expect(discard).not.toHaveBeenCalled();
});
it("saves and goes when saving is chosen", async () => {
choiceDialog.mockResolvedValue("save");
await expect(confirmLeaveUnsaved()).resolves.toBe(true);
expect(save).toHaveBeenCalledOnce();
});
it("holds you on the page when the save fails", async () => {
choiceDialog.mockResolvedValue("save");
save.mockResolvedValue(false);
await expect(confirmLeaveUnsaved()).resolves.toBe(false);
});
it("discards and goes when discarding is chosen", async () => {
choiceDialog.mockResolvedValue("discard");
await expect(confirmLeaveUnsaved()).resolves.toBe(true);
expect(discard).toHaveBeenCalledOnce();
expect(save).not.toHaveBeenCalled();
});
});