Unsaved changes: ask in Postulo's own dialog when the person leaves through a link or a button on the page #657

Closed
opened 2026-10-03 01:58:38 +00:00 by tiagoagueda · 1 comment
Owner

From the maintainer's list: “Make a save guard that prevents losing unsaved data, for instance prompting a dialog if abandoning a page without saving it.” #258 made the guard, and it asks with the browser's own prompt, whose words a page cannot set or translate. A dialog can be Postulo's, translated and styled. This issue adds one for the leaving a page can see, and keeps the browser's prompt for the rest. It does not reopen what #258 delivered, and it comes after #518.

What #258 delivered, and stays

  • A form is dirty from its first keystroke until it is submitted or put back; dirtiness is per form, and a submit clears only the form submitted (src/postulo/static/js/app.js:810-877).
  • A form that is a control, not work, is never dirty: a GET form, one marked data-no-guard, a data-autosubmit control (app.js:833-843).
  • beforeunload asks when anything is dirty (app.js:879-887); pageshow clears the marks (:889-894). The capture review's d and j keys respect it.
  • Tests: tests/e2e/test_unsaved_work.py:61-143 (a letter, an untouched page, a submit, a filter form, d, j).
  • Drafts were left out deliberately (app.js:805-808), and stay out.

What exists today for a dialog

<c-dialog> (templates/cotton/dialog.html, #303) is a <dialog popover>: with scripts it is opened modally by app.js (:1798-1862) with focus returned to its opener; without, the button that names it opens it as a popover. Cancel, the answer that changes nothing, is drawn first and takes focus. Without an action its body is a plain <div>. tests/e2e/test_dialog.py covers both modes.

What a page can and cannot intercept

  • Can: a click on a same-origin link, the sidebar, the search, a form submitted that is not the dirty one (Sign out, templates/base.html:234; the capture review's discard), and the keys that click them.
  • Cannot: closing the tab, reload, the address bar, the browser's Back and Forward across documents. For these beforeunload remains, as it is.
  • With scripts off nothing is guarded, today or after this.

Checked against main at f09b6010b.

Proposal

  1. One dialog drawn once on every page that has a form, with a title and a sentence of Postulo's: Leave without saving? and “What you typed on this page has not been saved. If you leave, it is lost.” The safe answer, Cancel, first; Leave this page beside it.
  2. A delegated click listener in the capture phase on same-origin a[href] links (no modifier key, no target, no download, not a fragment on this page), and a capture-phase submit listener for forms other than a dirty one. When anything is dirty, it cancels the action, remembers where it was going, and opens the dialog. Capturing runs before the double-submit guard marks the form, which is how the dead form of #518 does not arise on this route.
  3. Leave clears every data-dirty mark, so beforeunload is silent and the person is asked once, then goes to the link or submits the form they had pressed. Cancel and Escape change nothing; focus returns to the link or button that was used.
  4. beforeunload stays, unchanged, as the answer for everything above the line.
  5. A browser without showModal keeps beforeunload alone.

Decisions

  • A third answer, Save and leave. It needs the form's save to succeed before the page goes, and a path for a refusal. Recommended: not here. #656 is the place where saving as you go is assessed; two answers keep this small.
  • Order with #518. The dead form after Stay is reachable by a submit and by nothing else. Recommended: fix #518 first, as its own issue says, then land this; the dialog removes the in-app route to it, and the browser's prompt still needs the fix.
  • Links that open elsewhere. target="_blank" and a download do not leave the page. Recommended: no question for them.
  • The wording. Recommended: the title above and the sentence, with Leave this page as the confirming button, in the project's own words rather than the browser's, translated per language.

Builds on

Issues #258, #518, #303 (the dialog) and #656.

Done when

  • tests/e2e/test_unsaved_work.py gains: typing into a letter and clicking a sidebar link opens the dialog and puts up no beforeunload; Cancel keeps the typed text and returns focus to the link; Leave this page goes and the browser puts up no second prompt; Sign out with a dirty form asks, and after Cancel still works (the #518 case); d and j on the capture review open the dialog; an untouched page and a filter form do not; the tab-close case still raises beforeunload. The six tests there pass unchanged.
  • tests/e2e/test_accessibility.py covers the page with the dialog open in both schemes; tests/e2e/test_reflow.py at 320 pixels; the dialog is operable by keyboard alone, announced as an alert dialog, and closes on Escape.
  • No record, preference or archive change. Strings in en-GB, fr-FR and pt-PT (drafts) and Weblate (#349).

From the list of improvements of 2026-10-02.

From the maintainer's list: “Make a save guard that prevents losing unsaved data, for instance prompting a dialog if abandoning a page without saving it.” #258 made the guard, and it asks with the browser's own prompt, whose words a page cannot set or translate. A dialog can be Postulo's, translated and styled. This issue adds one for the leaving a page can see, and keeps the browser's prompt for the rest. It does not reopen what #258 delivered, and it comes after #518. ## What #258 delivered, and stays - A form is dirty from its first keystroke until it is submitted or put back; dirtiness is per form, and a submit clears only the form submitted (`src/postulo/static/js/app.js:810-877`). - A form that is a control, not work, is never dirty: a `GET` form, one marked `data-no-guard`, a `data-autosubmit` control (`app.js:833-843`). - `beforeunload` asks when anything is dirty (`app.js:879-887`); `pageshow` clears the marks (`:889-894`). The capture review's `d` and `j` keys respect it. - Tests: `tests/e2e/test_unsaved_work.py:61-143` (a letter, an untouched page, a submit, a filter form, `d`, `j`). - Drafts were left out deliberately (`app.js:805-808`), and stay out. ## What exists today for a dialog `<c-dialog>` (`templates/cotton/dialog.html`, #303) is a `<dialog popover>`: with scripts it is opened modally by `app.js` (`:1798-1862`) with focus returned to its opener; without, the button that names it opens it as a popover. *Cancel*, the answer that changes nothing, is drawn first and takes focus. Without an `action` its body is a plain `<div>`. `tests/e2e/test_dialog.py` covers both modes. ## What a page can and cannot intercept - **Can:** a click on a same-origin link, the sidebar, the search, a form submitted that is not the dirty one (*Sign out*, `templates/base.html:234`; the capture review's discard), and the keys that click them. - **Cannot:** closing the tab, reload, the address bar, the browser's Back and Forward across documents. For these `beforeunload` remains, as it is. - With scripts off nothing is guarded, today or after this. Checked against `main` at `f09b6010b`. ## Proposal 1. **One dialog** drawn once on every page that has a form, with a title and a sentence of Postulo's: *Leave without saving?* and “What you typed on this page has not been saved. If you leave, it is lost.” The safe answer, *Cancel*, first; *Leave this page* beside it. 2. **A delegated click listener** in the capture phase on same-origin `a[href]` links (no modifier key, no `target`, no `download`, not a fragment on this page), and a capture-phase `submit` listener for forms other than a dirty one. When anything is dirty, it cancels the action, remembers where it was going, and opens the dialog. Capturing runs before the double-submit guard marks the form, which is how the dead form of #518 does not arise on this route. 3. **Leave** clears every `data-dirty` mark, so `beforeunload` is silent and the person is asked once, then goes to the link or submits the form they had pressed. **Cancel** and Escape change nothing; focus returns to the link or button that was used. 4. **`beforeunload` stays,** unchanged, as the answer for everything above the line. 5. **A browser without `showModal`** keeps `beforeunload` alone. ## Decisions - **A third answer, *Save and leave*.** It needs the form's save to succeed before the page goes, and a path for a refusal. **Recommended:** not here. #656 is the place where saving as you go is assessed; two answers keep this small. - **Order with #518.** The dead form after *Stay* is reachable by a submit and by nothing else. **Recommended:** fix #518 first, as its own issue says, then land this; the dialog removes the in-app route to it, and the browser's prompt still needs the fix. - **Links that open elsewhere.** `target="_blank"` and a download do not leave the page. **Recommended:** no question for them. - **The wording.** **Recommended:** the title above and the sentence, with *Leave this page* as the confirming button, in the project's own words rather than the browser's, translated per language. ## Builds on Issues #258, #518, #303 (the dialog) and #656. ## Done when - `tests/e2e/test_unsaved_work.py` gains: typing into a letter and clicking a sidebar link opens the dialog and puts up no `beforeunload`; *Cancel* keeps the typed text and returns focus to the link; *Leave this page* goes and the browser puts up no second prompt; *Sign out* with a dirty form asks, and after *Cancel* still works (the #518 case); `d` and `j` on the capture review open the dialog; an untouched page and a filter form do not; the tab-close case still raises `beforeunload`. The six tests there pass unchanged. - `tests/e2e/test_accessibility.py` covers the page with the dialog open in both schemes; `tests/e2e/test_reflow.py` at 320 pixels; the dialog is operable by keyboard alone, announced as an alert dialog, and closes on Escape. - No record, preference or archive change. Strings in en-GB, fr-FR and pt-PT (drafts) and Weblate (#349). From the list of improvements of 2026-10-02.
tiagoagueda added this to the 0.6.0 milestone 2026-10-03 01:58:38 +00:00
Author
Owner

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#657).

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#657).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#657
No description provided.