Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

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

[Settings("Title", Authorize = Policy, Route = …, Group = …, Order = …, Icon = …, Hint = …, Description = …, Saved = …, Owner = …)] [SettingsGroup("Group", "what it is for")] entity Row { [Setting("Label", Group = …, Hint = …, Order = …)] T Field = default; }

Mark the row staff configure `[Settings("Title", Authorize = Policy)]` and each field on it `[Setting("Label")]`, and the settings area draws the whole section with no page code: each field by its type (a switch, a dropdown, a number field bounded by its `[Range]`, a duration field, a time-zone picker, a text field), Save and Discard, who changed each group, and "Set by your organization" for an `[OrgPolicy(OrgPolicyMode.Lock)]` field the organisation sets. A page of your own stays the escape hatch for records and lists.

preview2 examples compiled by CIuikitsettingsconfiguration

Summary#

Most of what staff configure is a single row of switches, choices, numbers, durations and short text. Declare it on the row, and the settings area does the rest: it lists the section to whoever Authorize admits, serves its page, draws each field by its type, saves only on Save, says who last changed each group, and locks a field the organisation sets. The kit writes no page, no provider and no Save.

[Settings("Notifications", Authorize = IsAdmin)]
entity NotificationSettings {
  [Setting("Tell the whole team")] bool TellEveryone = true;
  [Setting("Remind after")] TimeSpan RemindAfter = TimeSpan.FromHours(4);
}

Signature#

[Settings(string title,                     // the section's name in the settings area, and the page's heading
          Authorize = <policy>,             // who may open it — the page's [Authorize] and the link's Shown (required)
          Route = "/settings/…",            // default: "/settings/" + the title, lower-cased and dashed
          Group = "…", Order = 100, Icon = Icons.Gear,   // where the area lists it (as a SettingsLink)
          Hint = "…",                       // the line under it in the area's index
          Description = "…",                // the line under the page's heading
          Saved = "… saved.",               // what the Save bar says once a save lands
          Owner = Person)]                  // a PERSONAL section — the field saying whose row it is; each reader
                                            // edits their own, made for them the first time (see below)
[SettingsGroup(string title, string description)]   // a group of fields, in the order the entity declares them
entity Row {
  [Setting(string label, Group = "…", Hint = "…", Order = 0)] T Field = default;
}

// What the section is made of — public, to use elsewhere or to take over by name:
[Composable] component RowFields(Row row);  // the groups alone (a desk places them); saved by whatever holds the row
[Composable] component RowEditor();         // the page's body — the row, its groups and the Save bar
[Route(Route)] component RowPage();          // the page, in [Layout(SettingsFrame)], gated by Authorize

public bool SetByOrganization<T>(x => x.Field);   // is this [OrgPolicy] field set by the reader's organisation?

Description#

Which control does each field get?#

A field's TYPE chooses its row, the way a block's parameter types choose its editor:

the fieldits row
boola switch, with the hint under it
an enuma dropdown of the enum's members, by their [Label]
inta number field — [Range(min, max)] (or [Min]/[Max]) bounds it and its steppers
decimala decimal number field, bounded the same way
TimeSpana duration field: it reads "4 hours", and opens to a number and a unit (minutes, hours, days)
TimeZoneInfoa time-zone picker (TimeZonePicker) over the app's declared zones, each read as its place and region — "Stockholm — Europe". A name that is not a time zone cannot be saved, because it cannot be typed
stringa text field; past 200 characters ([MaxLength]) a text area

Anything else — a date, a reference to another row, a list — is refused by name: a row cannot hold it, and a page of your own (SettingsPage, SettingsSection) is the escape hatch. A nullable field is refused too: a setting always has a value, so give it a default.

Short fields (numbers, durations, choices, codes up to 40 characters) that follow each other sit side by side in a grid (SettingsGrid); a switch takes its own line. Every row is a SettingRow inside a SettingsSection, so a declared section reads exactly like a page built by hand.

How are the fields grouped?#

Each [SettingsGroup("Sales", "…")] on the entity is a section of the page — its title and its line on the left, its fields on the right — in the order the entity declares them. A field joins one with Group = "Sales"; a field naming no group is in a section named after the page. Within a group, fields go by Order and then as declared.

When is it saved, and what holds a Save back?#

Nothing is saved until Save, from the page's SaveBar, which offers Discard and Undo as on every settings page. While a value cannot be saved the bar says why and Save does nothing: a required text left empty, a text past its [MaxLength], a number outside its [Range]. A rule only the server holds — an invariant — refuses the Save there, and the bar says "Not saved — " and the rule's message.

The row is made with every field's default the first time the page is opened, so every stored field of a settings row needs a default (or ?): the compiler refuses one that has neither, naming it.

Who changed it?#

Under each group: "Changed by Ada Lindqvist · 3 min ago" — the latest change a person made to that group's fields. It comes from the app's change trail, so it appears when the APP keeps one (use Osysharp.Observability;); without a trail there is nobody to name, and the line is left out rather than guessed. The row being made with its defaults is not a change.

What does the organisation set?#

A field marked [OrgPolicy(OrgPolicyMode.Lock)] is asked about on its own: when the reader's organisation sets it, its control is disabled and the row says "Set by your organization" — or, when the organisation sets every field of a group, the group says it once. See [[ui-settings-registry#org-policy]] for the modes and the seam.

In code of your own, ask by the field — SetByOrganization<ReviewSettings>(s => s.PublishAtOnce). The field must be marked [OrgPolicy], and a renamed field is a compile error. The string form is refused: a key written as text goes on compiling after the field is renamed, and quietly stops being the organisation's.

A section each person keeps for themselves#

Most settings are the app's — one row, changed by whoever Authorize admits. A person's own preferences (what is mailed to them, how dense their list is) are one row EACH: name the field saying whose row it is with Owner = Person, and the section finds the reader's own row — by whose it is, never by what the rules let them read, so an admin who may read everybody's still opens their own — and makes it, with every default and the reader as its owner, the first time they open the page. The field is a stored, non-nullable reference to the app's principal ([Required] Principal Person;), needs no default, and is never a [Setting]; the entity's rules say who may read and change a row (allow read, create, update when … where Person == user).

[Settings("Notifications", Authorize = IsMember, Owner = Person, Route = "/settings/notifications")]
entity NotificationPreferences {
  [Required] Principal Person;
  [Setting("Mail me")] NotificationMail Mail = NotificationMail.MentionsAtOnce;
  security { allow read, create, update when IsMember where Person == user; }
}

What do I write instead of a page?#

Nothing, for a row of settings. For records and lists — saved replies, queues with members, mailboxes to connect — write the page yourself under [Layout(SettingsFrame)] and announce it with an ISettingsProvider (ISettingsProvider and [OrgPolicy]: kits announce their settings, and a setting says whether an organisation may govern it). To change one declared section's page, declare a component of the same name (RowPage, RowEditor) in the app; to place the fields somewhere else, call RowFields(row) and save from there.

Examples#

A section with no page code#

[Principal] entity Member { [MaxLength(100)] string Name; }
policy IsMember => IsAuthenticated;

enum DigestDay { [Label("Monday")] Monday, [Label("Friday")] Friday }

[Settings("Notifications", Authorize = IsMember, Group = "Workspace", Order = 2, Icon = Icons.Bell,
          Hint = "When people hear about new work.", Saved = "Notifications saved.")]
[SettingsGroup("New work", "Who hears about something new.")]
[SettingsGroup("Digest", "The weekly summary.")]
entity NotificationSettings {
  [Setting("Tell the whole team", Group = "New work", Hint = "Off, only the person it is assigned to hears.")]
  bool TellEveryone = true;
  [Setting("Remind after", Group = "New work")]
  TimeSpan RemindAfter = TimeSpan.FromHours(4);
  [Setting("Send on", Group = "Digest")]
  DigestDay DigestOn = DigestDay.Monday;
  [Setting("Items in a digest", Group = "Digest"), Range(5, 50)]
  int DigestSize = 20;
  [Setting("Lock after", Group = "Digest")] [OrgPolicy(OrgPolicyMode.Lock)]
  bool LockAfterSending = false;
  [Setting("Time zone", Group = "Digest", Hint = "The digest goes out at 07:00 here.")]
  TimeZoneInfo SendIn = TimeZoneInfo.FindSystemTimeZoneById("Europe/London");
  security { allow read, create, update when IsMember; }
}

string Announced() {
  return string.Join(", ", AnnouncedSettings().Select(l => l.Label + " " + l.To));
}
[TestFixture]
void Team() { new Member { Name = "Ada" }; }

principal Ada => Member.Single(m => m.Name == "Ada");

[Test(Team)]
[runas(Ada)]
void The_section_is_announced_and_nothing_is_set_by_an_organisation() {
  Assert.Equal("Notifications /settings/notifications", Announced());
  Assert.False(SetByOrganization<NotificationSettings>(s => s.LockAfterSending));
}

See also#

Related

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…

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…

package.osy

The manifest that makes a git repository a publishable Osy# package. It names the package, says which of the…

settings a package asks its app for (app settings)

A package — or an app — declares a setting by marking a class `[AppSetting("Name")]`. The app that uses it fills it in…

Changes

The lightweight, ungated answer to "who changed this row, and when": field NAMES only, never values, newest first…