Summary#
An app's settings come from many places: the people it manages, how long it keeps personal data, the tiers its customers are on. Each of those is a kit's. With a settings registry, every kit announces its sections, and the app's settings area gathers them all. The app writes no navigation, a kit added means its sections appear, and every page sits in the app's own frame.
component MyShellDesign() : WorkspaceShell { render { MyShell { Slot; } } } // once — the settings area stands in itSignature#
public interface ISettingsProvider { SettingsLink[] Sections(); }
public List<SettingsLink> AnnouncedSettings(); // every provider's links this reader is Shown, ordered
[Composable] component SettingsArea(SettingsLink[]? sections = null, string title = "Settings", string home = "/settings");
[Layout] component SettingsFrame(); // SettingsArea() in the app's WorkspaceShell
public int SettingsLink.Order = 100; // where it sits in a gathered area
[OrgPolicy(OrgPolicyMode.Default | OrgPolicyMode.Lock | OrgPolicyMode.Range)] // on a settings field
public interface IOrgPolicySource { bool Sets(string setting); } // the org layer implements it
public bool SetByOrganization<T>(x => x.Field); // SetByOrganization<PrivacySetup>(p => p.ReviewPlans)
[Composable] component SettingsSection(…, bool setByOrganization = false);Description#
A kit announces its sections#
⭐ A row of settings needs no page and no provider at all: mark the row [Settings("Title", Authorize = …)] and its
fields [Setting("Label")], and the section is announced and drawn for you — see [Settings] and [Setting]: a settings section declared on its row, each field drawn by its type. What
follows is the escape hatch, for sections that hold records and lists.
A class that implements ISettingsProvider is discovered, never wired. SettingsArea() with no list asks
App.Implementations<ISettingsProvider>() (see App.Implementations<T>()) for every one, the kits' and the
app's own, and shows the links this reader is Shown. Shown takes the provider's policy as a value
(Shown = ManagesPeople), and a class method may name the app's policies exactly as a function does.
They are grouped and ordered. A group sits where its lowest Order puts it, with ties broken by the group's name.
Within a group, links are ordered by Order and then by label. Kits leave gaps (People 10, Organizations 30,
Privacy 60) so an app's own sections can go between them. The app's own provider is how it adds a section.
To reorder or hide sections, pass a list: SettingsArea(MyLinks()). To start from the kits' links, call
AnnouncedSettings() from a server body (an on mount) and filter or reorder what it returns.
⭐ Every kit with settings announces them, and the compiler holds it to that. A package whose own source has a
configuration surface (a settings page, a settings row, an [OrgPolicy] field) and no ISettingsProvider gets the
warning SETTINGS_NOT_ANNOUNCED on its own compile. What counts, and the example a kit copies, are in
package.osy under "A package with settings joins the app's settings area".
⚑ Hiding a link is never the security. Every settings page carries its own [Authorize], and the functions
behind it carry theirs. A reader who types the address of a section they are not shown is refused by the page itself.
A kit's page sits in the app's frame#
A kit serves its settings pages at its own routes, [Route("/settings/privacy")] [Layout(SettingsFrame)]. The Ui kit's
SettingsFrame is the area inside the app's design of WorkspaceShell (WorkspaceShell, WorkspaceFrame and [Nav] — a kit's pages in your app's shell and navigation) — the same shell every
kit's working page stands in — so an app that designs its shell once has every settings page in it. When the settings
want a frame of their own, the app takes SettingsFrame over by declaring a [Layout] component SettingsFrame(),
which is the rule an app's own Card follows over the kit's. A kit's page can be taken over by name in the same way
(see routes and pages).
A setting may be governed by an organisation#
[OrgPolicy(OrgPolicyMode.X)] on a field of a kit's [AppSetting] class, or of a row an admin edits, declares that
an organisation's policy may govern it:
| mode | the organisation… | the app… |
|---|---|---|
Default | seeds the value | may change it |
Lock | sets it; the page says "Set by your organization" | may not change it |
Range | bounds a number (a floor, never wider) | chooses within the bounds |
The compiler records the marker and refuses it without one of the three modes, or as Range on anything but a
number. osy model --json lists every such setting under orgPolicy, keyed Namespace.Type.Field, with its mode and
the package that declares it. The same mode appears on the field as orgPolicy.
Nothing enforces it yet, and nothing stores an organisation's values. The seam where an organisation's values will
arrive is IOrgPolicySource, which the org layer's package implements. SetByOrganization<T>(x => x.Field) asks
every source, and with none in the app it answers false. It names the setting by its MEMBER — the field must be
marked [OrgPolicy], and renaming it is a compile error — and the compiler hands the sources the key osy model
lists. A key written as a string is refused: it went on compiling after the field was renamed, and quietly stopped
being the organisation's. A page passes the answer to
SettingsSection(…, setByOrganization: …) and disables its controls from the same value. A Range setting is never
"set by" an organisation, so its page keeps the app's controls.
Examples#
Two sections, announced and gathered#
[Principal] entity Member { [MaxLength(100)] string Name; }
policy IsMember => IsAuthenticated;
entity Workspace {
[Required, MaxLength(120)] string Name;
/// How long a deleted file is kept before it is gone for good.
[OrgPolicy(OrgPolicyMode.Range)] int KeepDeletedDays = 30;
security { allow read, update when IsMember; }
}
class WorkspaceSettings : ISettingsProvider {
public SettingsLink[] Sections() {
return [new SettingsLink { Label = "Workspace", To = "/settings/workspace", Group = "Workspace", Order = 1,
Shown = IsMember, Hint = "Its name, and how long deleted files are kept." }];
}
}
class BillingSettings : ISettingsProvider {
public SettingsLink[] Sections() {
return [new SettingsLink { Label = "Billing", To = "/settings/billing", Group = "Account", Order = 80,
Shown = IsMember, Icon = Icons.File }];
}
}
[Layout]
component SettingsFrame() {
render {
Stack(gap: 0, h: "100vh") {
Row(px: 4, py: 3, label: "Top bar") { Text("Acme", fontWeight: FontWeight.Semibold); }
SettingsArea() { Outlet; }
}
}
}
[Route("/settings/workspace")] [Layout(SettingsFrame)] [Authorize(IsMember)] [Title("Workspace")]
component WorkspacePage() {
live var spaces = Workspace.ToList();
render {
SettingsPage("Workspace") {
foreach (var w in spaces) {
SettingsSection("Name") { Field("Workspace name", value: w.Name); }
}
SaveBar(saved: "Workspace saved.");
}
}
}
[Route("/settings/billing")] [Layout(SettingsFrame)] [Authorize(IsMember)] [Title("Billing")]
component BillingPage() { render { SettingsPage("Billing", description: "Your plan and your invoices."); } }
string GatheredLabels() {
return string.Join(", ", AnnouncedSettings().Select(l => l.Label));
}[TestFixture]
void Team() { new Member { Name = "Ada" }; }
principal Ada => Member.Single(m => m.Name == "Ada");
[Test(Team)]
[runas(Ada)]
void Both_sections_are_announced_in_order() {
Assert.Equal("Workspace, Billing", GatheredLabels());
Assert.False(SetByOrganization<Workspace>(w => w.KeepDeletedDays));
}
[Test]
void A_reader_who_may_open_neither_is_shown_neither() {
Assert.Equal("", GatheredLabels()); // anonymous: each provider's own policy says no
}See also#
- [Settings] and [Setting]: a settings section declared on its row, each field drawn by its type:
[Settings]and[Setting]— a section declared on its row, with no page code - SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what:
SettingsPage,SettingsSection,SaveBarandChangedBy, the shape every settings page shares - App.Implementations<T>(): how a provider is discovered without being wired
- routes and pages: a package's pages, and taking one over by name
- settings a package asks its app for (app settings):
[AppSetting], which is developer configuration written in code, unlike the runtime data an admin edits here