# LeavePrompt — asking before a page with unsaved edits is left

> 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.

<!-- id: ui-leave-prompt · area: ui · stability: stable · html: https://osysharp.com/reference/ui/leave-prompt/ -->

## Summary        {#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](https://osysharp.com/reference/ui/saving/)). 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.

```osy title="a page in the sidebar shell — edited in place, guarded on the way out with nothing declared" test app=ui-leave-prompt
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      {#signature}

```osy syntax
[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    {#description}

### What does a page do to be guarded?   {#opt-in}

**Nothing.** The shell asks [Navigation](https://osysharp.com/reference/ui/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](https://osysharp.com/reference/ui/data-mutation/)) — 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:

```osy title="the page's own say over whether Save is offered" syntax
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?   {#answers}

| 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 `AutoSave` that just landed) leaves at once.

### How does it tie to SaveBar, SaveActions and AutoSave?   {#saving}

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](https://osysharp.com/reference/ui/saving/)) 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](https://osysharp.com/reference/ui/settings/)) inside the sidebar shell is
guarded the same way.

### Can I raise it from my own control?   {#your-own}

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:

```osy title="a Back button of your own that asks first, with the kit's prompt" test app=ui-leave-prompt
[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}

```osy title="each answer, checked on the row it leaves behind" test app=ui-leave-prompt
[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       {#see-also}

- [Save, discard, undo — three ways to show a page's unit of work](https://osysharp.com/reference/ui/saving/) — `SaveBar`, `SaveActions` and `AutoSave`, which keep the edits while the reader stays
- [Navigation](https://osysharp.com/reference/ui/navigation/) — `Navigation.IsDirty`, `Save`, `Close` and `CanSave`, the facts and verbs the prompt is built on
- [App shells](https://osysharp.com/reference/ui/shell/) — the sidebar shell whose rail raises it
- [FocusedShell](https://osysharp.com/reference/ui/shell-focused/) — 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](https://osysharp.com/reference/ui/settings/) — settings pages behind a `SaveBar`, guarded the same way inside the shell
- [Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard](https://osysharp.com/reference/ui/dialogs/) — the `Dialog` it is drawn in
