Files
ihasmail-inbuxa/web/src/lib/unsavedChanges.ts
T
jcoffey-dev d3e173c9c1 Ask before the filter editors lose your changes
Both editors on the Filters & rules page kept their edits in component
state, so every way out of the page threw them away without a word: a
settings link, the app rail, even the Rules/Scripts switch. The only sign
there had been anything to lose was a Save button that a screenful of
rules had already pushed below the fold.

Editors now register what they have pending, and every in-app navigation
asks first -- offering to save, rather than making "leave without saving"
the easy answer and saving the one you have to back out and find. Wouter
routes links, redirects and navigate() through one place, so the guard
holds for the app rail and the settings nav without either knowing an
editor exists. The Rules/Scripts switch asks for itself, since it never
reaches the router. Reload and tab close get the browser's own prompt.

The save bar is pinned to the foot of the pane, so "Unsaved changes" is
on screen whether or not the rules fit in the window.

Two things fixed on the way past, both in the raw script editor: saving
cleared only the selection, which left the editor open with the name
unlocked so a second save created a duplicate script instead of updating
the one just written; and the pair of identical nested conditions that
decided whether the editor was open at all is now the one question it was
asking twice.

Fixes #175
2026-09-01 07:05:17 -07:00

108 lines
3.8 KiB
TypeScript

import { useEffect, useRef } from "react";
import { choiceDialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
/**
* Editors that would lose work if you walked away from them.
*
* The filter editors keep their edits in component state, so every way out of
* the page -- a settings link, the app rail, the Rules/Scripts switch -- threw
* them away without a word, and the only sign there had been anything to lose
* was a Save button that a screenful of rules had already pushed past the
* bottom of the window. That is issue #175.
*
* An editor registers what it has pending here; navigation asks before it
* happens. The question is deliberately not a yes/no: "leave without saving?"
* makes losing the work the easy answer and saving it the one you have to back
* out and find, when saving is what almost everybody wants.
*/
export interface UnsavedChanges {
/** Whether anything would be lost by leaving right now. */
dirty: boolean;
/**
* Persist the edits. `false` keeps you where you are: a save that failed is
* exactly the moment not to navigate, because the edits only exist here.
*/
save: () => Promise<boolean>;
/** Throw the edits away. */
discard: () => void;
/** Already translated, and specific: it says what is about to be lost. */
message: string;
}
/**
* Held by reference rather than by value, so the callbacks the dialog runs are
* the ones from the editor's latest render and not from whenever it mounted.
*/
type Slot = { current: UnsavedChanges };
const slots = new Set<Slot>();
/** Register this editor's pending edits for as long as it is on screen. */
export function useUnsavedChanges(changes: UnsavedChanges): void {
const slot = useRef(changes);
slot.current = changes;
useEffect(() => {
slots.add(slot);
return () => {
slots.delete(slot);
};
}, []);
/*
* Reloading and closing the tab are the browser's to ask about, and all it
* will show is its own generic wording -- custom text was removed years ago.
* Generic is still better than silent, and it costs one listener, armed only
* while there is something to lose.
*/
useEffect(() => {
if (!changes.dirty) return;
const warn = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [changes.dirty]);
}
/** Whether leaving now would lose anything. Cheap enough for every navigation. */
export function hasUnsavedChanges(): boolean {
for (const slot of slots) if (slot.current.dirty) return true;
return false;
}
/**
* Ask about anything pending. Resolves true when it is safe to go.
*
* Dismissing the dialog -- Escape, the backdrop, the close button -- is not
* one of the answers, so it cannot be mistaken for "discard": it leaves you on
* the page with the edits intact.
*/
export async function confirmLeaveUnsaved(): Promise<boolean> {
/*
* A snapshot, not the live set. `save` and `discard` both make an editor
* clean, but not until React has re-rendered it, and re-reading `slots`
* between questions would find the same editor still dirty and ask twice.
*/
for (const slot of [...slots]) {
const pending = slot.current;
if (!pending.dirty) continue;
const answer = await choiceDialog({
title: t("Save your changes?"),
message: pending.message,
choices: [
{ value: "save", label: t("Save changes") },
{ value: "discard", label: t("Discard changes"), hint: t("What you changed here will be lost."), danger: true },
],
cancelLabel: t("Stay here"),
});
if (answer === null) return false;
if (answer === "discard") {
pending.discard();
continue;
}
if (!(await pending.save())) return false;
}
return true;
}