Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

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

SettingsArea(sections, title, home) { Outlet; } · SettingsPage(title, description) { SettingsSection(title, description, locked, lockedBecause, changed) { … } SaveBar(onSave, saved, blockedBy, onSaved); } · SettingRow(hint, setByOrganization) · SettingsGrid · ChangedBy(by, at, now)

The kit's settings vocabulary: `SettingsArea` lays out an app's settings — its sections in a grouped nav, the current one lit, each section's page beside it (on a phone, the nav first and each section a screen of its own); `SettingsPage` and `SettingsSection` give every page the same shape; `SaveBar` saves or discards a page's edits as one explicit act, with Undo back to the last savepoint; and `ChangedBy` says who last changed something, and when.

preview1 example compiled by CIuikitsettingslayout

Summary#

Settings are pages people change rarely and must be able to trust: where each thing lives, that nothing is stored until they say so, that a slip can be taken back, and who changed it last. The kit gives each of those a component, so every settings page of an app reads the same.

SettingsArea(SettingsSections(), title: "Settings", home: "/settings") { Outlet; }

Signature#

public sealed class SettingsLink {
  string Label; string To; string Group = ""; Icons Icon = Icons.Gear; string Hint = ""; bool Shown = true;
  int Order = 100;
}
[Composable] component SettingsArea(SettingsLink[]? sections = null, string title = "Settings", string home = "/settings");
[Composable] component SettingsPage(string title, string description = "");
[Composable] component SettingsSection(string title, string description = "", bool locked = false,
                                       string lockedBecause = "", string changed = "", bool setByOrganization = false);
[Composable] component SaveBar(Action onSave = null, string saved = "Saved.", string blockedBy = "", Action onSaved = null);
[Composable] component SettingRow(string hint = "", bool setByOrganization = false);   // one setting: its control, its line
[Composable] component SettingsGrid();                       // short settings side by side
string ChangedBy(string by, DateTime? at, DateTime now);     // "Changed by Ada Lindqvist · 3 min ago"
string TimeAgo(DateTime at, DateTime now);                   // "just now", "4 min ago", "yesterday", "12 Mar"

Description#

The area and its sections#

Put SettingsArea in a [Layout] and give it the sections, each a SettingsLink to the page that holds it. Links with the same Group are listed together under it; the one being looked at is lit. Shown takes a policy as a value (Shown = IsAdmin), so a person sees only the sections they may open — and each page still carries its own [Authorize], because hiding a link is not refusing a page. On a phone, home is the list of sections with each one's Hint, and every section is a screen of its own with a way back. In a test the nav is a named region, "<title> sections": Ui.Click("Delivery", within: "Settings sections").

With no list, SettingsArea() gathers the sections every kit and the app itself announce (ISettingsProvider), grouped and ordered, and shows "Nothing to set up" when there are none. That is the usual form once kits ship their own settings pages. See ISettingsProvider and [OrgPolicy]: kits announce their settings, and a setting says whether an organisation may govern it.

A page and its sections#

SettingsPage(title, description) is a page's heading and a line saying what it is for, then its sections in a readable column. SettingsSection puts what a group of settings is on the left and its controls on the right, stacked on a phone; it is a named region, so a test says within: "Zones".

  • locked shows the controls and says they are not this reader's to change (lockedBecause). Give each control disabled: from the same policy — the rows are refused by their own security { } rules either way.
  • changed is the "who changed this, and when" line — ChangedBy(…), fed from the row's history: with using Osysharp.Observability;, <Entity>.Changes(id) answers newest first, so its first entry is the last change.

One setting, the way every section draws it#

SettingRow(hint:, setByOrganization:) holds one control (its slot), the line under it saying what it does, and "Set by your organization" when the reader's organisation sets that one setting while the rest of its section is the app's. SettingsGrid { … } puts short settings — numbers, durations, choices, codes — side by side, one column on a phone. A [Settings] section is drawn from exactly these ([Settings] and [Setting]: a settings section declared on its row, each field drawn by its type), so a page built by hand with them reads the same.

Nothing is stored until Save#

The page edits rows directly — Field("Name", value: z.Name), new DeliveryZone { … }, row.Delete() — and none of it is stored: it lives in the page's unit of work. SaveBar appears only while there is something unsaved (Navigation.IsDirty), says so, and offers three things:

  • Save (Mod+S) commits every change at once. blockedBy holds it back: the sentence saying why the page cannot be saved as it stands. While it is not empty the bar says it and Save does nothing, so compute it from the page's own values and it follows the person as they type (blockedBy: Refusal()). onSave runs as Save is pressed, before the commit — for a derived value. ⚠ It is fired, not awaited (a component's callback never is), so it cannot stop the save: a sentence it throws is lost. A check belongs in blockedBy.
  • A save the server refuses says why. A refused commit gives the page's edits back, so nothing is left unsaved — and the bar says "Not saved — " and the reason in its place, rather than closing with the reason inside it. onSaved runs once a save has landed, to re-read what the page shows about it (who changed it).
  • Discard drops every unsaved change.
  • Undo (Mod+Z) goes back to the last savepoint. Make one before each edit with UnitOfWork.Mark("Add a zone"); its label is the button's ("Undo add a zone").

Leaving the page through the app shell's rail with changes still unsaved asks first — Keep editing, Save and leave, or Discard and leave (LeavePrompt — asking before a page with unsaved edits is left); Save and leave keeps them exactly as the bar's Save would.

Once a save lands, the bar says saved in its place. In a test: Ui.Click("Save"), Ui.Click("Discard"), and Assert.Visible("Unsaved changes") while there are some.

Examples#

A bakery's delivery settings#

entity DeliveryZone {
  [Required("Name the zone."), MaxLength(80)] string Name;
  decimal Fee = 0;
  bool Active = true;
  security { allow read, create, update when IsAuthenticated; }
}

[Layout]
component SettingsLayout() {
  render {
    SettingsArea([
      new SettingsLink { Label = "Delivery", To = "/settings/delivery", Group = "Orders", Icon = Icons.MapPin,
                         Hint = "Where you deliver, and what it costs." },
      new SettingsLink { Label = "Opening hours", To = "/settings/hours", Group = "Shop", Icon = Icons.Clock,
                         Hint = "When the counter is open." },
    ], title: "Settings", home: "/settings") { Outlet; }
  }
}

[Route("/settings")] [Layout(SettingsLayout)] [Title("Settings")]
component SettingsHome() {
  render { SettingsPage("Settings", description: "How the bakery takes orders."); }
}

[Route("/settings/delivery")] [Layout(SettingsLayout)] [Title("Delivery")]
component DeliverySettings() {
  live var zones = DeliveryZone.OrderBy(z => z.Name).ToList();

  action AddZone() {
    UnitOfWork.Mark("Add a zone");
    new DeliveryZone { Name = "New zone" };
  }

  render {
    SettingsPage("Delivery", description: "Where the van goes, and what a customer pays for it.") {
      SettingsSection("Zones", description: "A zone that is off is kept, and offered to nobody.") {
        foreach (var z in zones) {
          Row(gap: 3, align: Align.End, label: z.Name) {
            Stack(grow: 1) { Field("Name", value: z.Name); }
            Field("Fee", value: z.Fee);
            Switch("Delivering", value: z.Active);
          }
        }
        Row { Button("Add a zone", onPress: AddZone, tone: Tone.Ghost, size: Size.Sm); }
      }
      // Held back, with the reason on the bar, while a zone has no name.
      SaveBar(saved: "Delivery saved.", blockedBy: zones.Any(z => z.Name.Trim() == "") ? "Name every zone." : "");
    }
  }
}

[Route("/")] [Title("Bakery")]
component Home() { render { TextLink("Settings", to: "/settings"); } }

Apps/harbour's Settings (statuses, SLA policies, business hours, tiers, routing, fields and forms, saved replies, shared views, people) is the whole vocabulary in a working app.

See also#

Related

Osysharp.Ui (the UI kit)

The bundled UI kit — ready-made styled controls like `Button`, the shared design-system vocabularies (`Tone`, `Size`)…

creating & saving data

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

routes and pages

How a component becomes a page: it declares a route with `[Route("/catalog/{slug}")]`, and navigating to a matching…

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…

layout primitives

The built-in layout primitives and how they arrange children. `Stack` stacks children in a column, `Row` lays them in a…

[Composable] — presentational components in public pages

Mark a presentational, composition-only component `[Composable]` so a public page can compose it without marking it…

ISettingsProvider and [OrgPolicy]: kits announce their settings, and a setting says whether an organisation may govern it

A kit with something an admin configures implements `ISettingsProvider` and serves its pages under…

[Settings] and [Setting]: a settings section declared on its row, each field drawn by its type

Mark the row staff configure `[Settings("Title", Authorize = Policy)]` and each field on it `[Setting("Label")]`, and…

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…

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