Files
summercms/docs/backend/settings.md
Jakub Zych 21c5618dd1 feat(18-04): settings forms take method-backed dropdown options from the settings model (task 1)
- the settings schema passes the settings model as the DropdownOptionsProvider
- boot refuses a dropdown method on a settings model without DropdownOptions
- cabana README and docs/backend/settings.md describe it
2026-10-13 14:08:14 +02:00

151 lines
9.9 KiB
Markdown

---
title: Settings
description: Declare settings pages with pact.SettingsItem, as singleton forms backed by a model or as links to admin controllers, and read settings from plugin code.
section: backend
order: 60
---
# Settings
A WinterCMS settings model extends `SettingModel`, stores its values in `system_settings` and registers its page with `registerSettings`. In SummerCMS a settings page is a singleton row of the plugin's own table, edited through a form described in `fields.yaml`, and declared with `pact.HasSettings`.
## Declaring a settings page
`pact.HasSettings` returns `pact.SettingsItem` entries. Each names the page's code, label, category and icon for the settings index, the permissions it needs, the form file inside the plugin's embedded tree, and a factory for its model:
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Settings
// Settings replaces registerSettings().
func (p *BlogPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "blog",
Label: "acme.blog::lang.settings.label",
Description: "acme.blog::lang.settings.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Model: "BlogSettings",
Permissions: []string{"acme.blog.access_settings"},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &BlogSettings{} },
}}
}
```
The model is a GORM struct with a `Fillable` list and a `Rules` method; [cabana](../../modules/cabana/README.md) refuses at start-up a settings model without either:
```go src=modules/cabana/example_controller_test.go#BlogSettings
// BlogSettings is the singleton row behind the plugin's settings page.
type BlogSettings struct {
ID uint `gorm:"column:id;primaryKey"`
PostsPerPage int `gorm:"column:posts_per_page"`
CommentsEnabled bool `gorm:"column:comments_enabled"`
}
```
```go src=modules/cabana/example_controller_test.go#BlogSettings.Rules
// Rules validates a settings save, as a WinterCMS settings model's $rules.
func (BlogSettings) Rules() map[string]string {
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
}
```
The form uses the same field types and options as a controller form; see [Forms](forms.md):
```yaml src=modules/cabana/testdata/docs/models/settings/fields.yaml
fields:
posts_per_page:
label: acme.blog::lang.settings.posts_per_page
type: number
span: left
default: 10
comments_enabled:
label: acme.blog::lang.settings.comments_enabled
type: switch
span: right
```
A dropdown whose `options` names a method (`options: getCurrencyOptions`) takes its choices from the settings model, as a WinterCMS settings model's `get*Options` method did. The model implements `pact.DropdownOptionsProvider`; `DropdownOptions` receives the field name and returns `pact.Option` values whose labels may be phrase keys. A settings model without it stops the start-up naming the field.
Create the table in a migration like any other; see [Migrations](../database/migrations.md).
## Linking a settings entry to an admin controller
A WinterCMS `registerSettings` entry can point at a backend controller instead of a settings model, with `'url' => Backend::url('acme/blog/posts')`. The entry appears on the Settings page and opens that controller's list. In SummerCMS the `Controller` field of `pact.SettingsItem` does the same: set it to the admin controller ID (`vendor.plugin.controller`) and leave `Model`, `Form` and `NewModel` empty. The plugin returns this entry from `Settings` next to, or instead of, its singleton pages:
```go src=modules/cabana/example_settings_test.go#settings-link
link := pact.SettingsItem{
Code: "posts",
Label: "acme.blog::lang.posts.title",
Description: "acme.blog::lang.posts.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Order: 500,
Permissions: []string{"acme.blog.access_settings"},
Controller: "acme.blog.posts",
}
```
A link entry is not a singleton and needs no embedded admin tree. [cabana](../../modules/cabana/README.md) stops start-up when a link entry also declares `Model`, `Form` or `NewModel`, when it names a controller no plugin registered, or when its code repeats another settings code; singleton and link entries share one code namespace.
`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code.
A link entry may also open a [singleton controller](admin-controllers.md#singleton-controllers), one whose `pact.AdminSingleton` record the admin opens directly in its form, the way WinterCMS plugins auto-created one definition and redirected to it. The entry stays a link to the controller's list route: since a singleton controller has no list, the admin asks its singleton route for the record and opens it, also when the link is opened in a new tab.
In the admin, the entry's card on the Settings page opens the controller's pages. When the controller has no main navigation item of its own, the admin treats it as part of Settings: the rail marks Settings as current and the breadcrumbs read Settings, then the entry's label.
## How values are stored
The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does.
The admin API serves the page at `<prefix>/api/v1/settings/{code}` (values) and `.../settings/{code}/schema` (the form), and `GET .../settings` lists the pages the administrator may open.
## Write-only secrets
A `type: password` field on a settings form is a write-only secret: an API key, a webhook signing secret. Its model column must be a `lagoon.Encrypted` (or a pointer to one), so the value is encrypted at rest under `app.key`; any other column type stops the start-up naming the field.
```yaml
fields:
secret_key:
label: acme.pay::lang.settings.secret_key
type: password
tab: acme.pay::lang.settings.tab_live
```
- `GET /settings/{code}` never includes the field in `data`. It reports the field's state in `meta.secrets` instead: `stored` when the column holds a value, `env` when the column is empty and a fallback applies (below), or `empty`. The `meta` key is omitted when the form has no password field.
- `PUT /settings/{code}` keeps the stored value when the key is absent or an empty string, clears it when the value is JSON `null`, and stores any other string through `lagoon.NewEncrypted`. A value that is neither a string nor `null` is a 422 on the field. The answer carries the new states in `meta.secrets` and never the values; cabana never logs a secret and never echoes it in an error.
- A `required` password field is satisfied by a stored value, so an absent key does not fail the required check.
The admin shows the field empty on every load with a status line under it: a stored value with a "Remove stored value" button (the clear is deferred until Save and can be undone), the configuration fallback, or "Not set". Typing a value replaces the stored one on Save; an untouched field is not sent.
A settings model may implement `cabana.SecretFallbacks` to name, per field, the configuration key the application falls back to when the column is empty, so the page can say that a value from the server configuration is in use:
```go src=modules/cabana/settings_secret_test.go#secretSettings.SecretFallbacks
// SecretFallbacks maps each secret field to the config key the application
// reads when the column is empty; SUMMER_* variables map onto those keys.
func (secretSettings) SecretFallbacks() map[string]string {
return map[string]string{"api_key": "acme.secret.api_key"}
}
```
cabana only checks whether the key resolves to a non-empty value (`env` state); reading and using the fallback value stays the plugin's job. The states are the constants `cabana.SecretStored`, `cabana.SecretEnv` and `cabana.SecretEmpty`, and the meta shape is `cabana.SettingsMeta`.
## Rich text on a settings page
`type: richeditor` is accepted on a settings form. The settings save runs every submitted value through the same seaglass sanitizer as a controller save (see [Rich text](forms.md#rich-text)): clean HTML is stored byte for byte, otherwise the clean value is stored and the answer lists what was removed in `meta.notices`, which the admin shows under the field. A value that is not a string or `null` is a 422 on the field. `repeater` and `dependsOn` are still refused on settings forms, as they are on relation forms.
## Tabs
Settings fields take `tab` like record form fields. When any field declares one, the admin renders the tab strip in the page header, with the fields that have no `tab` on the default tab first; the form card sits inside the active tab's panel. After a 422 the first tab with an error is activated and shows its error count. Without any `tab` the page is unchanged.
## Reading settings in plugin code
Settings are an ordinary table, so plugin code reads them with GORM:
```go src=modules/beachcomber/example_test.go#gate
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
var enabled bool
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
return err == nil && enabled
}))
```
That example reads a flag from a settings row inside a search gate, treating a failed read as off. A setting that changes rarely and that operators, not administrators, own belongs in configuration instead; see [Configuration](../services/configuration.md).