Assess saving a form as it is filled in, for the forms where that is safe, instead of at the bottom of the page #656

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

From the maintainer's list: “Access the possibility to enable on-the-fly form saving, instead on having to go to the bottom of the page to hit ‘Save’.” Read as assess: this is the decision #258 declined to take, now asked for. It is a setting that, for the forms where it is safe, saves a field when it is changed.

What exists today

  • Two controls submit when changed. data-autosubmit (src/postulo/static/js/app.js:285-363): the board's status menu (applications/partials/application_card.html:64), which saves, and the checkbox on the plugins page that shows the plugins Postulo ships (settings/plugins.html:26), which submits a filter and saves nothing. A select commits on Enter, on Tab or when focus leaves, because arrowing through a closed select fired a change at every step and wrote four status changes and four timeline entries (#227).
  • Everything else is a page with Save at its foot. 32 templates carry a Save button (34 buttons). Your details is one POST of three cards (accounts/profile.html:41-219, Save at :219), and a row can only be added by saving (“Save to add another identifier.”, :215).
  • A table cell saves where it sits (#135). core/cells.py:74 (EditableCellView) builds the page's own form narrowed to one field with modelform_factory (:115), so a cell refuses what the page refuses, in the same words; it refuses a save whose updated_at has moved (STALE, :71; :44-46, :193); the refusal sits in the cell's role="alert"; with no script the pencil is a link to the page's form.
  • The guard already knows about it. A field inside a data-autosubmit control does not mark its form dirty (app.js:840-843).
  • What saving a form can write. The application edit form routes the status through change_status, which writes a timeline entry (applications/views.py:449-474); every request is one transaction (config/settings/base.py:149). Settings pages are Profile forms (src/postulo/accounts/forms.py:664-701 for Accessibility); a new preference joins PROFILE_FIELDS (src/postulo/core/export.py:111), and #464 (open) says preferences are not all exported.
  • No draft exists. app.js:805-808 leaves “what is typed is not kept” as a decision for later; #258 listed the options (browser storage, a server row) and chose neither.

Checked against main at f09b6010b.

Proposal

  1. Settle scope first, under Decisions. The first commit takes the Settings pages only.
  2. A per-person preference, Profile.save_as_you_go, default off, on Settings → Accessibility beside the other behaviour switches, written into PROFILE_FIELDS as #464 settles for the rest.
  3. A field endpoint, built from the cell's. core/cells.py's one-field form generalised from a table column to “one field of this form's instance”, with the same ownership scoping, validation, messages and updated_at stamp. A cell's route does not become the form's.
  4. A save on change, which is blur for text and at once for a select, checkbox or radio; never on a keystroke. A select commits as data-autosubmit already decides.
  5. A visible state beside the form's heading, in a live region: Saving…, Saved with the time, or Not saved with the reason; a refusal under its field through the existing feedback partial. Save stays: for the keyboard, with scripts off, and for the person who wants the moment.
  6. The guard follows. With it on, a field that saved is not dirty; one that failed or is in flight is.

Decisions

  • Setting, or a per-form opt-in. Options: no setting, forms marked safe save as you go; a setting for every form; a form-by-form mark the developer sets and a setting the person turns on. Recommended: both: a form says whether it can (data-save-as-you-go), the person says whether it does, default off. Saving without a context change does not breach WCAG 3.2.2, but a silent write does take Save away as the moment of decision, hence the visible state and the live region.
  • What can never save on its own. Create forms (no row yet); anything that writes a timeline entry (the application's status, offers, interviews); sign-in, password, token and passkey pages; forms with rows added and removed; uploads; forms whose fields validate each other. Recommended: exactly that list, in the code and in the wiki.
  • Field or form. Recommended: field. A whole-form post on every change refuses the half-typed fields it did not need to read.
  • Drafts. Out of this issue, as #258 left them. Saving the field to its record is the only “draft” an existing record needs; a new record has no row to save to.
  • Undo. A wrong change is now saved. Recommended: no undo in the first step; blur rather than keystroke is the mitigation, and a later issue can add an Undo beside Saved.
  • Which forms next. After Settings: the company, contact and listing edit forms. Recommended: the application form stays explicit while its status goes through change_status.

Needed by / Builds on

Needed by #657, which assumes the guard's dirty rules above. Builds on #258, #135, #227 and #518 (the guard's open bug).

Done when

  • tests/e2e/test_save_as_you_go.py (new): with the setting on, changing a Settings → Appearance field saves that field without a reload and shows Saved; a refused value shows its message under the field and is not stored; a stale stamp is refused with the cell's sentence; with the setting off, nothing saves until Save; the unsaved-work guard does not ask after an autosave and does ask while a save is refused; with scripts off the form is what it is today.
  • tests/security/test_isolation_sweep.py gains the new endpoint (another account's record is a 404, a field not on the form's list cannot be named); tests/test_page_coverage.py is updated; tests/test_editable_cells.py and tests/e2e/test_editable_cells.py still pass on the generalised form.
  • A record type is not added, but a preference is: the archive format (29 today) moves to one more than whatever it is when the commit lands, because PROFILE_FIELDS gains a name (the tests compare the number with FORMAT_VERSION, not with a literal); the candidate format (4 today) does not, the API has no preference to carry, and the processing record (core/gdpr.py) has nothing new. Strings in en-GB, fr-FR and pt-PT (drafts) and Weblate (#349); the wiki's Settings page.

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

From the maintainer's list: “Access the possibility to enable on-the-fly form saving, instead on having to go to the bottom of the page to hit ‘Save’.” Read as *assess*: this is the decision #258 declined to take, now asked for. It is a setting that, for the forms where it is safe, saves a field when it is changed. ## What exists today - **Two controls submit when changed.** `data-autosubmit` (`src/postulo/static/js/app.js:285-363`): the board's status menu (`applications/partials/application_card.html:64`), which saves, and the checkbox on the plugins page that shows the plugins Postulo ships (`settings/plugins.html:26`), which submits a filter and saves nothing. A select commits on Enter, on Tab or when focus leaves, because arrowing through a closed select fired a change at every step and wrote four status changes and four timeline entries (#227). - **Everything else is a page with Save at its foot.** 32 templates carry a *Save* button (34 buttons). *Your details* is one POST of three cards (`accounts/profile.html:41-219`, Save at `:219`), and a row can only be added by saving (“Save to add another identifier.”, `:215`). - **A table cell saves where it sits** (#135). `core/cells.py:74` (`EditableCellView`) builds the page's own form narrowed to one field with `modelform_factory` (`:115`), so a cell refuses what the page refuses, in the same words; it refuses a save whose `updated_at` has moved (`STALE`, `:71`; `:44-46`, `:193`); the refusal sits in the cell's `role="alert"`; with no script the pencil is a link to the page's form. - **The guard already knows about it.** A field inside a `data-autosubmit` control does not mark its form dirty (`app.js:840-843`). - **What saving a form can write.** The application edit form routes the status through `change_status`, which writes a timeline entry (`applications/views.py:449-474`); every request is one transaction (`config/settings/base.py:149`). Settings pages are `Profile` forms (`src/postulo/accounts/forms.py:664-701` for *Accessibility*); a new preference joins `PROFILE_FIELDS` (`src/postulo/core/export.py:111`), and #464 (open) says preferences are not all exported. - **No draft exists.** `app.js:805-808` leaves “what is typed is not kept” as a decision for later; #258 listed the options (browser storage, a server row) and chose neither. Checked against `main` at `f09b6010b`. ## Proposal 1. **Settle scope first**, under *Decisions*. The first commit takes the Settings pages only. 2. **A per-person preference,** `Profile.save_as_you_go`, default off, on *Settings → Accessibility* beside the other behaviour switches, written into `PROFILE_FIELDS` as #464 settles for the rest. 3. **A field endpoint, built from the cell's.** `core/cells.py`'s one-field form generalised from a table column to “one field of this form's instance”, with the same ownership scoping, validation, messages and `updated_at` stamp. A cell's route does not become the form's. 4. **A save on `change`,** which is blur for text and at once for a select, checkbox or radio; never on a keystroke. A select commits as `data-autosubmit` already decides. 5. **A visible state** beside the form's heading, in a live region: *Saving…*, *Saved* with the time, or *Not saved* with the reason; a refusal under its field through the existing feedback partial. *Save* stays: for the keyboard, with scripts off, and for the person who wants the moment. 6. **The guard follows.** With it on, a field that saved is not dirty; one that failed or is in flight is. ## Decisions - **Setting, or a per-form opt-in.** Options: no setting, forms marked safe save as you go; a setting for every form; a form-by-form mark the developer sets and a setting the person turns on. **Recommended:** both: a form says whether it *can* (`data-save-as-you-go`), the person says whether it *does*, default off. Saving without a context change does not breach WCAG 3.2.2, but a silent write does take *Save* away as the moment of decision, hence the visible state and the live region. - **What can never save on its own.** Create forms (no row yet); anything that writes a timeline entry (the application's status, offers, interviews); sign-in, password, token and passkey pages; forms with rows added and removed; uploads; forms whose fields validate each other. **Recommended:** exactly that list, in the code and in the wiki. - **Field or form.** **Recommended:** field. A whole-form post on every change refuses the half-typed fields it did not need to read. - **Drafts.** Out of this issue, as #258 left them. Saving the field to its record is the only “draft” an existing record needs; a new record has no row to save to. - **Undo.** A wrong change is now saved. **Recommended:** no undo in the first step; blur rather than keystroke is the mitigation, and a later issue can add an *Undo* beside *Saved*. - **Which forms next.** After Settings: the company, contact and listing edit forms. **Recommended:** the application form stays explicit while its status goes through `change_status`. ## Needed by / Builds on Needed by #657, which assumes the guard's dirty rules above. Builds on #258, #135, #227 and #518 (the guard's open bug). ## Done when - `tests/e2e/test_save_as_you_go.py` (new): with the setting on, changing a *Settings → Appearance* field saves that field without a reload and shows *Saved*; a refused value shows its message under the field and is not stored; a stale stamp is refused with the cell's sentence; with the setting off, nothing saves until *Save*; the unsaved-work guard does not ask after an autosave and does ask while a save is refused; with scripts off the form is what it is today. - `tests/security/test_isolation_sweep.py` gains the new endpoint (another account's record is a 404, a field not on the form's list cannot be named); `tests/test_page_coverage.py` is updated; `tests/test_editable_cells.py` and `tests/e2e/test_editable_cells.py` still pass on the generalised form. - A record type is not added, but a preference is: the archive format (29 today) moves to one more than whatever it is when the commit lands, because `PROFILE_FIELDS` gains a name (the tests compare the number with `FORMAT_VERSION`, not with a literal); the candidate format (4 today) does not, the API has no preference to carry, and the processing record (`core/gdpr.py`) has nothing new. Strings in en-GB, fr-FR and pt-PT (drafts) and Weblate (#349); the wiki's *Settings* page. From the list of improvements of 2026-10-02.
tiagoagueda added this to the 0.6.0 milestone 2026-10-03 01:58:37 +00:00
Author
Owner

Done in f67a9477d (on main; CI green at 15e6d7ee4), for Settings forms only as the issue scoped it: the Appearance and Accessibility fields listed in the code save on change when the person turns it on. The company, contact and listing forms are not covered; that is a follow-up. Wiki: Accessibility page.

Done in f67a9477d (on main; CI green at 15e6d7ee4), for Settings forms only as the issue scoped it: the Appearance and Accessibility fields listed in the code save on change when the person turns it on. The company, contact and listing forms are not covered; that is a follow-up. Wiki: Accessibility page.
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#656
No description provided.