Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

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

class X : ISettingsProvider { SettingsLink[] Sections() } · SettingsArea() { Outlet; } · [Layout] component SettingsFrame() · [OrgPolicy(OrgPolicyMode.Default | Lock | Range)] · SetByOrganization<T>(x => x.Field)

A kit with something an admin configures implements `ISettingsProvider` and serves its pages under `[Layout(SettingsFrame)]`. `SettingsArea()` with no list gathers every provider in the app, the app's own included, so adding a kit adds its settings and the app's whole configuration reads as one place. A setting can declare `[OrgPolicy(OrgPolicyMode.X)]` to say whether an organisation's policy may govern it. `osy model` lists every such setting, and `SettingsSection(…, setByOrganization:)` shows "Set by your organization".

preview2 examples compiled by CIuikitsettingsconfiguration

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 it

Signature#

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:

modethe organisation…the app…
Defaultseeds the valuemay change it
Locksets it; the page says "Set by your organization"may not change it
Rangebounds 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#

Related

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

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…

App.Implementations<T>()

One new instance of every class in the app's composition — its own and every package's — that implements the interface…

routes and pages

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

[Composable] — presentational components in public pages

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

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…