Summary#
A page's edits live in the route's unit of work until something keeps them — a SaveBar, SaveActions, or AutoSave
once its moment passes (Save, discard, undo — three ways to show a page's unit of work). While they are unkept, the page is dirty (Navigation.IsDirty), and the
kit's shells will not let a press on the way out throw them away silently:
| the page | a row in SidebarShell's rail is | pressing it |
|---|---|---|
| clean — nothing to lose | a link | goes, in one press, with no question |
| dirty — unsaved edits | a button | asks Leave without saving? — Keep editing · Save and leave · Discard and leave |
WorkbenchShell does the same with every way out it draws — an activity-bar mark, a row in the explorer, a row in the
phone's drawer. FocusedShell's Exit asks the same question in a task's words — Save and exit · Discard and exit
([[ui-shell-focused#exit-guard]]). All of them are this one prompt, LeavePrompt, so every shell in the kit asks in the same
words and with the same safety rules.
entity Job {
[Required, MaxLength(120)] string Customer;
[Required, MaxLength(200)] string Artwork;
security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}
[Layout] [AllowAnonymous]
component Workshop() {
render {
SidebarShell(new AppChrome { Product = "Workshop", Home = "/jobs", Nav = [
new NavItem { Label = "Jobs", To = "/jobs", Icon = Icons.Home, Exact = true },
new NavItem { Label = "Prices", To = "/prices", Icon = Icons.Tag },
] }) { Outlet(retain: 4); }
}
}
[Route("/jobs")] [Layout(Workshop)] [AllowAnonymous] [Render(CSR)] [Title("Jobs")]
component JobsPage() {
live var job = Job.OrderBy(j => j.Customer).FirstOrDefault();
// Save is offered on the way out only while the edit could be kept as it stands.
live var canSave = job != null && job.Artwork.Trim() != "";
render {
if (job != null) {
Stack(gap: 4, p: 6) {
PageHead(job.Customer) { SaveActions(saved: "Job saved.", blockedBy: canSave ? "" : "Name the artwork."); }
Field("Artwork", value: job.Artwork);
}
}
}
}
[Route("/prices")] [Layout(Workshop)] [AllowAnonymous] [Render(CSR)] [Title("Prices")]
component PricesPage() {
render { Stack(gap: 4, p: 6) { PageHead("Prices"); Text("By the metre."); } }
}Signature#
[Composable]
component LeavePrompt(bool canSave, string error, Action onSave, Action onDiscard, Action onKeep,
string subtitle = "This page has changes that have not been saved yet.",
string saveLabel = "Save and leave", string discardLabel = "Discard and leave");Description#
What does a page do to be guarded?#
Nothing. The shell asks Navigation for itself: Navigation.IsDirty is in every component's scope, so no
page declares a handler, a slot or a field on AppChrome to get the guard. A field bound to a row, a new row, a
Delete() — anything that joins the route's unit of work (creating & saving data) — makes the page dirty, and keeping
or discarding it makes it clean again. A page that saves as it goes with AutoSave is dirty only for the moment a
change waits, so it is asked about only in that window.
Off the shell's own rows — a link inside the page, Navigation.Go from an action, the browser's Back — the PLATFORM
asks the same question with the same three answers, in a plain prompt of its own, so no way out of a dirty page throws
its edits away silently, with or without a shell ([[ui-page-unit-of-work#leaving]]).
The one thing a page MAY say is whether its edits can be saved as they stand — a canSave member:
live var canSave = Navigation.IsDirty && Validation.All.Count == 0; // All: every broken rule, shown yet or notThe shell reads it at the moment it asks (Navigation.CanSave(path)). A page with no canSave member can always be
saved; a page whose canSave is false gets the prompt without Save and leave — Keep editing and Discard and leave
only — because a Save that could only fail is not an answer. Give it the same condition as the blockedBy: on the
page's own save control, as the example does, so the two never disagree.
What does each answer do?#
| answer | what happens to the edits | where the reader ends up |
|---|---|---|
| Keep editing | nothing — they are still there | the same page |
| Save and leave | kept: the route's unit of work is committed | where they asked to go |
| Discard and leave | dropped for real — the route is closed, not merely hidden by retain | where they asked to go |
- Keep editing is the dismiss. Escape and a click on the backdrop answer it too, so the safe answer is the one reached by doing nothing.
- The destructive answer is last and toned
Danger. Discard and leave closes the route with force (Navigation.Close(path, true)), so coming back shows the row as it was stored. - A refused save keeps the prompt up, with the server's words under That could not be saved, and the edits are still there to fix. A prompt that closed on a failed save would look exactly like one that worked.
- The question is asked again at the press, against the page as it is then — so an edit kept a moment earlier
(an
AutoSavethat just landed) leaves at once.
How does it tie to SaveBar, SaveActions and AutoSave?#
They are two halves of one unit of work. The save controls (Save, discard, undo — three ways to show a page's unit of work) offer to keep the edits while the reader
stays; the leave prompt offers it as they go. Both read Navigation.IsDirty, so the bar or the inline Save is
showing exactly when the rail would ask, and a Save pressed in either place makes the other quiet. Save and leave
commits the same unit of work the page's own Save would — the same rules, the same refusals — and a refusal is shown
in the prompt rather than in the bar. A settings page behind a SaveBar (SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what) inside the sidebar shell is
guarded the same way.
Can I raise it from my own control?#
Yes — a "Back to the list" button, a layout of your own, a breadcrumb. A Link cannot decline to go (the router
follows it before any onClick runs), so the guard is a button while the page is dirty, exactly as the rail does it:
[Composable]
component BackToJobs() {
string leaving = "";
bool canSaveLeaving = false;
string leaveError = "";
action Back() {
if (!Navigation.IsDirty) { Navigation.Go("/jobs"); }
else { leaveError = ""; canSaveLeaving = Navigation.CanSave(Navigation.CurrentPath); leaving = Navigation.CurrentPath; }
}
action KeepEditing() { leaving = ""; leaveError = ""; }
action DiscardAndLeave() { Navigation.Close(leaving, true); Navigation.Go("/jobs"); leaving = ""; }
action SaveAndLeave() {
leaveError = "";
try { Navigation.Save(leaving); Navigation.Go("/jobs"); leaving = ""; }
catch (ValidationException ex) { leaveError = ex.Message; }
}
render {
Button("Back to jobs", onPress: Back, tone: Tone.Ghost);
if (leaving != "") {
LeavePrompt(canSaveLeaving, leaveError, SaveAndLeave, DiscardAndLeave, KeepEditing,
saveLabel: "Save and go back", discardLabel: "Discard and go back");
}
}
}canSave is snapshotted in the action because a render cannot call Navigation.CanSave; subtitle, saveLabel and
discardLabel put the prompt in your control's words, and the title — Leave without saving? — stays the kit's.
How do I test it?#
[Test]
void discard_and_leave_drops_the_edit() {
new Job { Customer = "Ada Byrne", Artwork = "Harbour at dusk" };
UnitOfWork.Commit();
Ui.Visit("/jobs");
Ui.Fill("Artwork", "Harbour at dawn");
Ui.Click("Prices", within: "Main navigation");
Assert.Dialog("Leave without saving?");
Ui.Click("Discard and leave");
Assert.OnPage("/prices");
Assert.Equal("Harbour at dusk", Job.Single().Artwork);
}
[Test]
void keep_editing_stays_with_the_edit() {
new Job { Customer = "Ada Byrne", Artwork = "Harbour at dusk" };
UnitOfWork.Commit();
Ui.Visit("/jobs");
Ui.Fill("Artwork", "Harbour at dawn");
Ui.Click("Prices", within: "Main navigation");
Ui.Click("Keep editing");
Assert.OnPage("/jobs");
Assert.Value("Artwork", "Harbour at dawn");
}Assert the ROW, not only the page: a prompt that said Discarded and kept the edit, or Saved and kept nothing, is
exactly what the guard is there to rule out. A clean page is left with one press — Assert.Hidden("Leave without saving?") after the click.
See also#
- Save, discard, undo — three ways to show a page's unit of work —
SaveBar,SaveActionsandAutoSave, which keep the edits while the reader stays - Navigation —
Navigation.IsDirty,Save,CloseandCanSave, the facts and verbs the prompt is built on - App shells — the sidebar shell whose rail raises it
- FocusedShell — the focused shell's Exit, which asks the same question in a task's words
- SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what — settings pages behind a
SaveBar, guarded the same way inside the shell - Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard — the
Dialogit is drawn in