Skip to content

ui: a generated form guards its unsaved changes, and Save stays on the document (#7359) - #7361

Open
delchev wants to merge 1 commit into
masterfrom
issue-7359-form-dirty-guard
Open

ui: a generated form guards its unsaved changes, and Save stays on the document (#7359)#7361
delchev wants to merge 1 commit into
masterfrom
issue-7359-form-dirty-guard

Conversation

@delchev

@delchev delchev commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Cause

A generated Harmonia form had no notion of unsaved changes. loadHeader() / loadRecord() copied the record into this.form and kept no pristine snapshot, so nothing anywhere could tell an edited form from a saved one - grep -ri "dirty\|beforeunload\|unsaved" over the whole template set returned nothing. Every way out dropped the edit silently: backToList() / cancel() navigated directly, a sidebar entry or the browser's Back button went through Pinecone with nobody asking, and a reload or a closed tab took the buffer with it. Two adjacent behaviours made it worse: Save in edit mode navigated to the list (save and leave were the same click, while create mode stayed), and saving a line item while the header was dirty committed the line - and re-read the header - so the user believed the whole document was saved.

Reported on a sales invoice; it was a property of every generated form.

Change

One mechanism, in the shared runtime (application-core), wired into every generated form:

  • basePage snapshots the save payload after each load and each successful save (markPristine), and is dirty while the buffer differs from it. Comparing toPayload() rather than form is deliberate: it already normalizes dates, dropdown ids to strings and a multiselect to its csv, so "3" vs 3 does not read as an edit. Preview and a document the /mutable pre-check reported closed can never be dirty. It also owns the dialog's state and its three answers.
  • App.leaveGuard is what the open form registers with. In-app navigation is vetoed through Pinecone's own global handler: a handler that throws aborts navigate before the route renders and before the history entry is pushed (router 7.5.2), so the page keeps its address while the dialog is up. The browser's Back button has already moved the URL by then - restoreHash puts it back, query string included - and a reload / closed tab falls to beforeunload. bypass is what lets the guard's own answer perform the navigation it just refused.
  • One dialog per view: Save and leave / Discard / Keep editing - the first button reads Save and continue when the user is staying on the page. Plus an "Unsaved changes" badge next to the status pill, Save disabled on a clean edit form (a create is always offerable), and the footer Cancel relabelled to Discard while dirty.

Surfaces: perspective/document (including the line-item Add, Fill Month and line delete, each of which would otherwise save behind an unsaved header), perspective/manage, and the my/ and partner/ form and document pages. On the document surfaces Save now stays on the record - it re-reads the header and raises a "Saved" toast - and every navigation the page performs itself clears the guard first, or it would veto its own route change. The plain manage form keeps its save-then-return-to-list behaviour (its returnTo / FK-dialog flows depend on it); only the dropping of the edit is fixed there.

Deferred, and why: the admin/ inline editor. It is a standalone page with no Pinecone router and no shared runtime (it loads apiError.js alone and speaks fetch directly), and its Cancel is an explicit action inside its own panel - wiring it would mean a third implementation of the mechanism rather than a use of this one.

Verification

  • DependsOnHarmoniaIT (Chrome, real generated app) now drives the journey on the form the user is standing on: the picked value makes the form dirty -> the Unsaved changes badge is there -> Back to list is vetoed and the dialog appears -> Keep editing leaves the edit exactly as it was. A snapshot taken at the wrong moment, a member Alpine cannot resolve, or a dialog Harmonia never opens all render as a page that looks right and still loses the edit, so this half can only be proven in a browser. Green.
  • HarmoniaUnsavedChangesIT (new, HTTP-free) is the sweep that keeps a surface from being left behind or written back to a direct navigation: every page snapshots AND guards its exits, every view renders the dialog and the marker, and the shared runtime carries the veto. Green.
  • ModelGenerationIT (every template renders, twice, with the descriptor asserted) and IntentEmissionCoverageIT (a generated intent app with a document master, line items and a personal surface - it compiles and runs the generated output) both green.
  • mvn -T 1C formatter:validate green with the formatter cache wiped first.

Not run locally: the rest of the UI suite, and the PostgreSQL leg - neither is touched by a template/resource change.

Fixes #7359

🤖 Generated with Claude Code

…e document (#7359)

A generated Harmonia form had no notion of unsaved changes. An edited header
was dropped without a word by Back to list, the footer Cancel, a sidebar
entry, the browser's Back button and a reload; nothing marked the form as
dirty; saving a line item while the header was dirty committed the line and
left the user believing the whole document was saved; and Save in edit mode
navigated to the list, so saving and leaving were the same click.

The mechanism is one, and it lives in the SHARED runtime:

- basePage snapshots the SAVE PAYLOAD after every load and after every
  successful save (toPayload already normalizes dates, dropdown ids and a
  multiselect csv, so "3" vs 3 does not read as an edit) and is dirty while
  the buffer differs from it. Preview and a document the /mutable pre-check
  reported closed can never be dirty.
- App.leaveGuard is what the open form registers with. In-app navigation is
  vetoed through Pinecone's own global handler - a handler that throws aborts
  navigate before the route renders and before the history entry is pushed
  (router 7.5.2) - so the page keeps its address while the dialog is up; the
  browser's Back button has already moved the URL, which restoreHash puts
  back; a reload or a closed tab falls to beforeunload.
- One dialog per view with three answers: Save and leave (Save and continue
  when the user is staying), Discard, Keep editing. An "Unsaved changes"
  badge in the header bar, Save disabled on a clean edit form, and the footer
  Cancel relabelled to Discard while dirty.

Wired into every generated form that owns an editable header: the document
page (plus its line-item Add / Fill Month / line delete, each of which would
otherwise save behind an unsaved header), the manage form, and the my /
partner form and document pages. On the document surfaces Save now STAYS on
the record - it re-reads the header and toasts "Saved" - and clears the guard
before any navigation it performs itself. The admin inline editor is
deliberately left out: it is a standalone page with no router and no shared
runtime, and its Cancel is an explicit action inside its own panel.

Verified: DependsOnHarmoniaIT (Chrome) drives the journey on a generated
form - dirty badge, Back to list vetoed, the dialog, Keep editing, the edit
still there; HarmoniaUnsavedChangesIT is the sweep that keeps a surface from
being left behind; ModelGenerationIT (every template renders, twice) and
IntentEmissionCoverageIT (a generated intent app with a document and a
personal surface) both green; formatter:validate green with the cache wiped.

Fixes #7359

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui: generated forms have no dirty state - Back/Cancel/navigation silently drop unsaved header edits, and Save in edit mode leaves the document

1 participant