feat(cabana): settings entries that link to an admin controller

pact.SettingsItem gains an additive Controller field, the equivalent of a
WinterCMS registerSettings 'url' => Backend::url(...) entry. A link entry
declares no Model, Form or NewModel and needs no AdminFS; start-up fails
when it combines Controller with a singleton form or a model, or names an
unregistered controller. Settings codes stay one namespace.

GET /settings lists a link entry with its controller only when the
principal passes the item's permissions and may open the controller.
Registry.Setting never returns a link entry, so the singleton settings
endpoints answer 404 for its code. SettingsEntry carries controller,
empty for singletons; the OpenAPI document, generated SPA types and
settings fixture follow, and the pact and cabana READMEs and the
settings docs describe the link.
This commit is contained in:
Jakub Zych
2026-10-07 18:22:01 +02:00
parent b040ee3a74
commit 4a9b0896b1
15 changed files with 338 additions and 14 deletions

View File

@@ -30,7 +30,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
@@ -46,7 +46,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
@@ -191,7 +191,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. |
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. `cabana.Registry.Setting` returns singleton settings only, never a controller link. |
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options; `cabana.CRUDService.BulkAction` runs a declared bulk action on a scoped, locked id set. |
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |

View File

@@ -131,13 +131,18 @@ func (r *Registry) Get(id string) (*CompiledController, bool) {
return cc, ok && cc != nil
}
// Setting returns one compiled singleton setting by stable code.
// Setting returns one compiled singleton setting by stable code. Link entries
// (pact.SettingsItem with Controller set) are not singletons and are not
// returned, so the singleton settings endpoints answer 404 for their codes.
func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
if r == nil {
return nil, false
}
setting, ok := r.settings[code]
return setting, ok && setting != nil
if !ok || setting == nil || setting.Item.Controller != "" {
return nil, false
}
return setting, true
}
// Allows reports whether principal satisfies any of the required permission

View File

@@ -0,0 +1,24 @@
package cabana_test
import "git.golem15.com/golem15/summercms/modules/pact"
// LinkedBlogPlugin adds a settings entry that opens the posts controller from
// the Settings page.
type LinkedBlogPlugin struct{ BlogPlugin }
// Settings keeps the singleton page and adds a controller link.
func (p *LinkedBlogPlugin) Settings() []pact.SettingsItem {
// docs:start 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",
}
// docs:end settings-link
return append(p.BlogPlugin.Settings(), link)
}

View File

@@ -101,3 +101,20 @@ func TestDocsDeclarations(t *testing.T) {
t.Fatal("BlogSettings has no posts_per_page rule")
}
}
// TestDocsSettingsLink activates the plugin with a settings entry that links
// to its posts controller, as the Settings page shows.
func TestDocsSettingsLink(t *testing.T) {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
if _, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&LinkedBlogPlugin{}}); err != nil {
t.Fatal(err)
}
settings := (&LinkedBlogPlugin{}).Settings()
if len(settings) != 2 || settings[1].Controller != "acme.blog.posts" || settings[1].Model != "" {
t.Fatalf("Settings() = %+v", settings)
}
}

View File

@@ -29,6 +29,9 @@ type SettingsEntry struct {
Order int `json:"order"`
Keywords []string `json:"keywords"`
Model string `json:"model"`
// Controller is the admin controller a link entry opens; empty for a
// singleton settings form.
Controller string `json:"controller"`
}
// Metadata returns only entries the backend principal may open. Denied
@@ -65,7 +68,9 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
})
for _, compiled := range r.settings {
item := compiled.Item
if !Allows(principal, item.Permissions) {
// A link entry is listed only when the principal can also open its
// controller, so a denied controller ID never leaks.
if !Allows(principal, item.Permissions) || (item.Controller != "" && !r.canOpen(principal, item.Controller)) {
continue
}
keywords := append([]string(nil), item.Keywords...)
@@ -77,6 +82,7 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
Description: translateKey(ctx, tr, item.Description),
Category: translateKey(ctx, tr, item.Category), Icon: item.Icon,
Order: item.Order, Keywords: keywords, Model: item.Model,
Controller: item.Controller,
})
}
sort.SliceStable(settings, func(i, j int) bool {

View File

@@ -196,6 +196,16 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
if _, exists := reg.settings[item.Code]; exists {
return fmt.Errorf("cabana: duplicate setting %s", item.Code)
}
if item.Controller != "" {
// A link entry opens an admin controller and needs no
// singleton form, so no AdminFS either.
compiled, err := compileSettingLink(plugin.ID(), item)
if err != nil {
return err
}
reg.settings[item.Code] = compiled
continue
}
if !hasAssets || assets == nil || assets.AdminFS() == nil {
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
}
@@ -250,6 +260,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil {
return err
}
if controller := setting.Item.Controller; controller != "" {
if _, ok := reg.byID[controller]; !ok {
return fmt.Errorf("cabana: setting %s references unknown controller %s", code, controller)
}
}
}
return nil
}

View File

@@ -17,7 +17,8 @@ import (
)
// CompiledSetting is a singleton settings registration after strict schema
// compilation and writable-field binding.
// compilation and writable-field binding. For a link entry (Item.Controller
// set) Form and Writable are nil.
type CompiledSetting struct {
PluginID string
Item pact.SettingsItem
@@ -34,6 +35,19 @@ type SettingsResult struct {
// SettingsService reads and transactionally updates compiled singleton rows.
type SettingsService struct{ DB *gorm.DB }
// compileSettingLink registers a settings entry that opens an admin
// controller. It declares no singleton form and no model; the controller ID
// itself is checked against the registry once every plugin is collected.
func compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetting, error) {
if item.Form != "" || item.NewModel != nil {
return nil, fmt.Errorf("cabana: setting %s links controller %s and declares a singleton form; declare one", item.Code, item.Controller)
}
if item.Model != "" {
return nil, fmt.Errorf("cabana: setting %s links controller %s and names a model", item.Code, item.Controller)
}
return &CompiledSetting{PluginID: pluginID, Item: item}, nil
}
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
if item.NewModel == nil {
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)

View File

@@ -0,0 +1,202 @@
package cabana
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// settingsLinkItem is a settings entry that opens an admin controller instead
// of a singleton form.
func settingsLinkItem() pact.SettingsItem {
return pact.SettingsItem{
Code: "locales", Label: "Locales", Description: "Manage locales", Category: "Demo",
Icon: "languages", Order: 30, Permissions: []string{"acme.demo.manage_settings"},
Controller: "acme.demo.widgets",
}
}
// settingsLinkPlugin declares only a link entry and ships no admin assets.
func settingsLinkPlugin(items ...pact.SettingsItem) contributionPlugin {
return contributionPlugin{
id: "acme.links",
permissions: []pact.Permission{{Code: "acme.links.unused", Roles: []string{"developer"}}},
settings: items,
}
}
// settingsLinkRegistry compiles the metadata plugin (one singleton setting)
// together with a plugin holding one controller link.
func settingsLinkRegistry(t *testing.T) *Registry {
t.Helper()
reg := metadataRegistry()
if err := compileContributions(reg, []party.Plugin{metadataPlugin(), settingsLinkPlugin(settingsLinkItem())}); err != nil {
t.Fatal(err)
}
return reg
}
func TestSettingsLinkCompilesWithoutAdminFS(t *testing.T) {
reg := settingsLinkRegistry(t)
stored, ok := reg.settings["locales"]
if !ok || stored == nil {
t.Fatal("link entry was not registered")
}
if stored.Form != nil || stored.Writable != nil || stored.Item.Controller != "acme.demo.widgets" || stored.PluginID != "acme.links" {
t.Fatalf("link entry = %#v", stored)
}
if _, ok := reg.Setting("locales"); ok {
t.Fatal("Registry.Setting returned a link entry")
}
if setting, ok := reg.Setting("demo"); !ok || setting.Form == nil {
t.Fatalf("singleton lookup = %#v %v", setting, ok)
}
}
func TestSettingsLinkCompileValidation(t *testing.T) {
withForm := settingsLinkItem()
withForm.Form = "models/settings/fields.yaml"
withFactory := settingsLinkItem()
withFactory.NewModel = func() any { return &singletonSetting{} }
withModel := settingsLinkItem()
withModel.Model = "Settings"
missing := settingsLinkItem()
missing.Controller = "acme.demo.missing"
duplicate := settingsLinkItem()
duplicate.Code = "demo"
unknownPermission := settingsLinkItem()
unknownPermission.Permissions = []string{"acme.demo.nope"}
cases := []struct {
name string
item pact.SettingsItem
want string
}{
{"form", withForm, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
{"model factory", withFactory, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
{"model", withModel, "cabana: setting locales links controller acme.demo.widgets and names a model"},
{"unknown controller", missing, "cabana: setting locales references unknown controller acme.demo.missing"},
{"duplicate code", duplicate, "duplicate setting demo"},
{"unknown permission", unknownPermission, "unknown permission"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := compileContributions(metadataRegistry(), []party.Plugin{metadataPlugin(), settingsLinkPlugin(tc.item)})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want %q", err, tc.want)
}
})
}
}
func TestSettingsLinkMetadata(t *testing.T) {
reg := settingsLinkRegistry(t)
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
_, settings := reg.Metadata(context.Background(), developer, nil)
if len(settings) != 2 {
t.Fatalf("developer settings = %#v", settings)
}
byCode := map[string]SettingsEntry{}
for _, entry := range settings {
byCode[entry.Code] = entry
}
link, single := byCode["locales"], byCode["demo"]
if link.Controller != "acme.demo.widgets" || link.Model != "" || link.Label != "Locales" || link.Order != 30 {
t.Fatalf("link entry = %#v", link)
}
if link.Keywords == nil {
t.Fatal("link entry keywords are nil")
}
if single.Controller != "" || single.Model != "Settings" {
t.Fatalf("singleton entry = %#v", single)
}
// The principal passes the item's permission but cannot open the
// controller (it requires acme.demo.access): the link is hidden, the
// singleton stays.
restricted := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.demo.manage_settings": true}}
_, settings = reg.Metadata(context.Background(), restricted, nil)
if len(settings) != 1 || settings[0].Code != "demo" {
t.Fatalf("restricted settings = %#v", settings)
}
// Controller access alone does not pass the item's own permission.
controllerOnly := &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
_, settings = reg.Metadata(context.Background(), controllerOnly, nil)
if len(settings) != 0 {
t.Fatalf("controller-only settings = %#v", settings)
}
}
func TestSettingsLinkEndpoints(t *testing.T) {
reg := settingsLinkRegistry(t)
svc := &service{reg: reg}
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
request := func(method, rel, code string) *http.Request {
var body *strings.Reader
if method == http.MethodPut {
body = strings.NewReader(`{"enabled":true}`)
} else {
body = strings.NewReader("")
}
req := httptest.NewRequest(method, adminAPI(rel), body)
if code != "" {
req.SetPathValue("code", code)
}
return req.WithContext(bouncer.WithUser(req.Context(), developer))
}
for _, tc := range []struct {
name string
method string
rel string
handler func(*service, http.ResponseWriter, *http.Request)
}{
{"schema", http.MethodGet, "/settings/locales/schema", (*service).settingsSchema},
{"get", http.MethodGet, "/settings/locales", (*service).settingsGet},
{"put", http.MethodPut, "/settings/locales", (*service).settingsPut},
} {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
tc.handler(svc, rec, request(tc.method, tc.rel, "locales"))
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), `"not_found"`) {
t.Fatalf("%s %s = %d %s", tc.method, tc.rel, rec.Code, rec.Body.String())
}
})
}
rec := httptest.NewRecorder()
svc.settingsList(rec, request(http.MethodGet, "/settings", ""))
if rec.Code != http.StatusOK {
t.Fatalf("GET /settings = %d %s", rec.Code, rec.Body.String())
}
var envelope struct {
Data []map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if len(envelope.Data) != 2 {
t.Fatalf("GET /settings data = %#v", envelope.Data)
}
for _, entry := range envelope.Data {
controller, ok := entry["controller"]
if !ok {
t.Fatalf("entry without controller key: %#v", entry)
}
want := ""
if entry["code"] == "locales" {
want = "acme.demo.widgets"
}
if controller != want {
t.Fatalf("entry %v controller = %v, want %q", entry["code"], controller, want)
}
}
}

View File

@@ -12,7 +12,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`.
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
@@ -100,7 +100,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasMailTemplates` | Ships mail templates and layout aliases. |
| `pact.Permission` | One backend permission entry (code, tab, label, roles). |
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu. |
| `pact.SettingsItem` | One settings screen entry. |
| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
| `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. |
| `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. |

View File

@@ -398,7 +398,9 @@ type HasNavigation interface {
Navigation() []NavigationItem
}
// SettingsItem is one registerSettings() entry.
// SettingsItem is one registerSettings() entry. An item is either a singleton
// settings form (Model, Form and NewModel) or a link to an admin controller
// (Controller), never both.
type SettingsItem struct {
Code string
Label string
@@ -409,8 +411,13 @@ type SettingsItem struct {
Order int
Keywords []string
Permissions []string
Form string `json:"-"`
NewModel func() any `json:"-"`
// Controller, when set, makes the entry a link to that admin controller
// ID (vendor.plugin.controller): the equivalent of a WinterCMS
// registerSettings 'url' => Backend::url(...) entry. A link entry
// declares no Model, Form or NewModel and is not a singleton.
Controller string
Form string `json:"-"`
NewModel func() any `json:"-"`
}
// HasSettings is implemented by plugins that declare settings screens.