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 field | its row |
|---|---|
bool | a switch, with the hint under it |
| an enum | a dropdown of the enum's members, by their [Label] |
int | a number field — [Range(min, max)] (or [Min]/[Max]) bounds it and its steppers |
decimal | a decimal number field, bounded the same way |
TimeSpan | a duration field: it reads "4 hours", and opens to a number and a unit (minutes, hours, days) |
TimeZoneInfo | a 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 |
string | a 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#
- ISettingsProvider and [OrgPolicy]: kits announce their settings, and a setting says whether an organisation may govern it:
ISettingsProviderfor a page of your own, and[OrgPolicy]'s modes - SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what:
SettingsPage,SettingsSection,SaveBar, the pieces a declared section is drawn with - package.osy: a kit's settings join the app's settings area — the rule, and the warning that keeps it
- Changes:
<Entity>.Changes(id), where "Changed by" comes from - settings a package asks its app for (app settings):
[AppSetting], code configuration the app sets inapp.osy, not on a settings page