Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

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

SaveBar(…) · SaveActions(saved: …) · AutoSave(after: 2) — how a page offers to keep its 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. `SaveBar` rises at the foot while there are changes, `SaveActions` puts Save and Discard inline beside the record's name, and `AutoSave` keeps the changes by itself a moment later with an Undo while they wait. All three drive the same unit of work, so switching is one line.

stable2 examples compiled by CIuikitdataunit-of-work

Summary#

A page that edits rows already has a unit of work — the route's — and every field bound to a row joins it. What the page still has to decide is how to OFFER to keep the changes. The UI kit ships three answers, each a control you drop in, each reading and driving that same unit of work:

controlwhat the person seesreach for it when
SaveBara bar rises at the foot while there are changes: Undo · Discard · Savea page of many edits a person reviews before keeping — settings, terms, a long form
SaveActions● Edited · Undo · Discard · Save inline, beside the record's name or in a drawer's heada record edited in place, a side panel, a dense console
AutoSaveSaving in 2 s · Undo, then All changes saved — there is no Savea note, a CRM record, anything edited in passing
entity Contact {
  [Required, MaxLength(120)] string Name;
  [MaxLength(400)] string? Note;
  security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}

// Inline: Save and Discard beside the name, only while there is something to keep.
[Route("/contacts/{id}")] [Render(CSR)] [AllowAnonymous]
component ContactPage(Guid id) {
  live var contact = Contact.FirstOrDefault(c => c.Id == id);
  render {
    if (contact != null) {
      Stack(gap: 4, p: 6) {
        PageHead(contact.Name) { SaveActions(saved: "Contact saved."); }
        TextAreaField("Note", value: contact.Note, rows: 4);
      }
    }
  }
}

// Kept by itself: no Save at all, and an Undo while the change waits.
[Route("/contacts/{id}/jot")] [Render(CSR)] [AllowAnonymous]
component ContactJot(Guid id) {
  live var contact = Contact.FirstOrDefault(c => c.Id == id);
  render {
    if (contact != null) {
      Stack(gap: 4, p: 6) {
        PageHead(contact.Name) { AutoSave(after: 2); }
        TextAreaField("Note", value: contact.Note, rows: 4);
      }
    }
  }
}

Signature#

SaveBar(Action onSave = null, string saved = "Saved.", string blockedBy = "", Action onSaved = null)
SaveActions(string saved = "Saved", string blockedBy = "", Action onSaved = null)
AutoSave(int after = 2, string saved = "All changes saved")

Description#

What do all three have in common?#

They read Navigation.IsDirty to know whether there is anything to keep, and they act through UnitOfWork: Commit() to keep, Discard() to drop every change, Undo() to step back one step. The platform takes a step for every action the person runs and every field they type into, named by it ("Undo artwork"); a UnitOfWork.Mark("Rename the status") names a step itself and groups the ones after it ([[ui-page-unit-of-work#undo]]). Nothing about how the page EDITS changes between them — the fields bind rows exactly as before — so moving a page from one to another is the one line that names the control.

A save the model's own rules would refuse is HELD, not sent: the edits stay, every field in the way is shown, and the control says how many ("1 field needs attention."). A save the SERVER refuses is never silent either — its reason is shown where the control is ("Not saved — …") — but what that commit carried is gone, so the page shows the stored row again ([[ui-page-unit-of-work#held-save]]).

And the edits are not lost on the way out: while the page is dirty, leaving it asks first — Keep editing, Save and leave, or Discard and leave — in the kit shells' own look, and from the platform for any other way out (LeavePrompt — asking before a page with unsaved edits is left, [[ui-page-unit-of-work#leaving]]).

Which one fits my page?#

Ask what a person expects to happen when they look away.

  • They expect nothing to be kept until they say so — finance, settings, anything with consequences: SaveBar. The bar appears only when there is something to keep, says so in words, and carries Mod+S and Mod+Z.
  • They are working in one record and want to keep it right there — a drawer, an inspector, a record's head: SaveActions. It takes no room at all while there is nothing to keep, then shows the same three verbs inline.
  • They expect it to be kept, as a document editor keeps a document — AutoSave. Once the person PAUSES for after seconds (two by default) the change is kept; every further edit starts the count again (UnitOfWork.EditCount()), so nothing is kept mid-word. Until then it says so and can be taken back.

What does Undo mean once something saves itself?#

AutoSave's Undo is honest about time: while the change is waiting it steps back to the last savepoint (or, with none, drops the changes not yet kept). Once the change is kept it is kept — the window to change your mind is the one before. That is why the waiting state is visible and counts down rather than happening silently.

How do I hold a save back until the page is complete?#

SaveBar and SaveActions take blockedBy: — the sentence saying why the page cannot be kept as it stands. While it is not empty the control says it and Save does nothing (Mod+S included). Compute it from the page's own values so it changes as the person types.

How do I drive them in a test?#

entity Note {
  [Required, MaxLength(60)] string Title;
  security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}

[Route("/notes/{id}")] [Render(CSR)] [AllowAnonymous]
component NotePage(Guid id) {
  live var note = Note.FirstOrDefault(n => n.Id == id);
  render {
    if (note != null) {
      Stack(gap: 3, p: 6) {
        PageHead("Note") { SaveActions(saved: "Kept."); }
        Field("Title", value: note.Title);
      }
    }
  }
}

[Test]
void an_inline_save_keeps_the_edit() {
  var n = new Note { Title = "Draft" };
  UnitOfWork.Commit();
  Ui.Visit("/notes/" + n.Id);
  Ui.Fill("Title", "Final");
  Assert.Visible("Edited");
  Ui.Click("Save");
  Assert.Visible("Kept.");
  Assert.Equal("Final", Note.Single().Title);
}

For AutoSave, make the edit, Assert.Visible("Saving in"), then Ui.Tick() once per second of after — the control's clock runs on on every, which a test fires on demand rather than waiting for it.

See also#

Related

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…

creating & saving data

A UI `action` creates, updates and deletes data by writing `new Entity { … }`, assigning fields, and calling…

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…

Arrangements — five ways to build a whole app, and which look wears each

A look decides how an app's pixels read; an ARRANGEMENT decides how the app is built — where navigation lives, how a…

Dock

The page's drawers: the page in the middle, a drawer docked on the right (the record a row opened, an inspector) and…

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:…