Settings, declared
An app describes its configuration and the host does the rest: it draws the
panel, validates what gets typed, saves it per organisation and hands it back
to you in ctx.settings, defaults already applied.
The panel below is the real one, mounted in this page — one field per type.
The field types
Section titled “The field types”settings: [ { key: "shopName", type: "text", label: "Shop name", maxLength: 60 }, { key: "spread", type: "number", label: "Spread", unit: "%", min: 0, max: 100, step: 0.5 }, { key: "autoUpdate", type: "boolean", label: "Update every morning", default: true }, { key: "regime", type: "select", label: "VAT regime", options: [/* … */], default: "standard" }, { key: "metals", type: "multiselect", label: "Metals you buy", options: [/* … */] }, { key: "fee", type: "numberUnit", label: "Dealer fee", units: [/* % or €/g */] }, { key: "yields", type: "numberTable", label: "Yield by fineness", rows: [/* … */], unit: "%" },]Two of them exist because the obvious alternative reads worse.
numberUnit is an amount together with the unit it’s expressed in: 2 %
or 1.50 €/g. Two separate fields would be the same thing taking four times
the room and reading half as well.
numberTable is one number per row of a fixed list — the yield of each
fineness, the margin of each chapter of a price list. A field per row is the
same thing written worse, and adding a row would mean touching the manifest
instead of the table.
Sections
Section titled “Sections”Fields gather into sections declared in settingGroups and named by the field:
settingGroups: [ { key: "price", label: "Gold quote", help: "Where the calculation starts." }, { key: "fees", label: "Fees" },],settings: [ { key: "autoUpdate", type: "boolean", group: "price", label: "Update every morning" },],Which things belong together is domain knowledge, which is why the app decides and not the host: for a gold buyer, fineness yields and fees are two separate subjects whatever their field types.
Showing a field only when it means something
Section titled “Showing a field only when it means something”{ key: "manualPrice", type: "number", label: "Gold price", visibleWhen: { key: "autoUpdate", equals: false },}A price to be typed by hand, while the automatic update is on, is a field that asks to be filled in and then ignores you.
It is presentation only, deliberately: the value stays in storage while
hidden, so flipping the switch back doesn’t lose what had been written. For the
same reason a hidden field cannot be required, and definePlugin rejects
it — the server validates without knowing what’s on screen, so it would demand
a value nobody can see and the panel could never be saved.
Changing a field later
Section titled “Changing a field later”The saved values belong to your customers. Changing a field’s type, making it required, or narrowing the allowed values invalidates what’s already in storage: validation stops recognising it, the host skips the installation, and the app quietly stops working.
When that’s what you need, it takes three moves in this order: bump version,
write migrateSettings to rewrite the old shape into the new one, and only
then change the field.
migrateSettings(stored, fromVersion) { if (typeof stored.dealerFee === "number") { return { ...stored, dealerFee: { value: stored.dealerFee, unit: "percent" } }; } return stored;}It must be pure and synchronous: it runs on every load until the installation is realigned. Labels, help text and numeric ranges are free to change — they don’t touch what’s stored.