Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

LeavePrompt — asking before a page with unsaved edits is left

LeavePrompt(canSave, error, onSave, onDiscard, onKeep, subtitle: …, saveLabel: …, discardLabel: …) — the kit's one leave prompt; SidebarShell's rail, WorkbenchShell's activity bar and explorer, and FocusedShell's exit raise it for you

When somebody presses another row in the sidebar shell's rail while the page holds unsaved edits, the shell asks first: Keep editing, Save and leave, or Discard and leave. A page declares nothing to get it — its edits joining the route's unit of work is what makes it dirty — and it can say when Save may be offered with a `canSave` guard. `LeavePrompt` is the dialog itself, for a control of your own that leaves a page.

stable3 examples compiled by CIuikitnavigationunit-of-work

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 pagea row in SidebarShell's rail ispressing it
clean — nothing to losea linkgoes, in one press, with no question
dirty — unsaved editsa buttonasks 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 not

The 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?#

answerwhat happens to the editswhere the reader ends up
Keep editingnothing — they are still therethe same page
Save and leavekept: the route's unit of work is committedwhere they asked to go
Discard and leavedropped for real — the route is closed, not merely hidden by retainwhere 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 AutoSave that 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#

Related

Save, discard, undo — three ways to show a page's unit of work

Every edit on a page joins the route's unit of work; how the page OFFERS to keep it is a choice of presentation…

Navigation

The routes the user has open, which of them hold UNSAVED edits, and the verbs that move between them or close one…

App shells

An app shell is the frame around every page: a brand, a navigation tree, the signed-in person and a menu behind them, a…

FocusedShell

An app shell for completing a single task — a checkout, an approval, a configuration step, a guided flow. It is built…

SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what

The kit's settings vocabulary: `SettingsArea` lays out an app's settings — its sections in a grouped nav, the current…

Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard

Opens a component in an overlay above the current screen, with a unit of work you choose: `Inherit` makes its edits a…