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:
@@ -1875,6 +1875,10 @@
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"controller": {
|
||||
"description": "Controller is the admin controller a link entry opens; empty for a\nsingleton settings form.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1900,6 +1904,7 @@
|
||||
"required": [
|
||||
"category",
|
||||
"code",
|
||||
"controller",
|
||||
"description",
|
||||
"icon",
|
||||
"keywords",
|
||||
|
||||
5
admin/src/api/schema.d.ts
vendored
5
admin/src/api/schema.d.ts
vendored
@@ -4886,6 +4886,11 @@ export interface components {
|
||||
"cabana.SettingsEntry": {
|
||||
category: string;
|
||||
code: string;
|
||||
/**
|
||||
* @description Controller is the admin controller a link entry opens; empty for a
|
||||
* singleton settings form.
|
||||
*/
|
||||
controller: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
keywords: string[];
|
||||
|
||||
3
admin/tests/fixtures/settings.json
vendored
3
admin/tests/fixtures/settings.json
vendored
@@ -9,6 +9,7 @@
|
||||
"category": "System",
|
||||
"keywords": [],
|
||||
"model": "Acme\\Demo\\Models\\Settings",
|
||||
"controller": "",
|
||||
"order": 20
|
||||
},
|
||||
{
|
||||
@@ -19,6 +20,7 @@
|
||||
"category": "System",
|
||||
"keywords": [],
|
||||
"model": "Acme\\Demo\\Models\\MailSettings",
|
||||
"controller": "",
|
||||
"order": 10
|
||||
},
|
||||
{
|
||||
@@ -29,6 +31,7 @@
|
||||
"category": "Appearance",
|
||||
"keywords": [],
|
||||
"model": "Acme\\Demo\\Models\\Branding",
|
||||
"controller": "",
|
||||
"order": 5
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Settings
|
||||
description: Declare singleton settings pages with pact.SettingsItem, backed by a model, a fields.yaml form and validation rules, and read them from plugin code.
|
||||
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
|
||||
---
|
||||
@@ -64,6 +64,27 @@ fields:
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -35,7 +35,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
|
||||
| Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](../services/jobs.md), [conga](../../modules/conga/README.md) |
|
||||
| `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](../plugins/scheduling.md), [pact](../../modules/pact/README.md) |
|
||||
| Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](../services/mail.md), [postcard](../../modules/postcard/README.md) |
|
||||
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
|
||||
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries; an entry with `'url' => Backend::url(...)` sets the `Controller` field to the admin controller ID | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
|
||||
| Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) |
|
||||
| Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) |
|
||||
| The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
24
modules/cabana/example_settings_test.go
Normal file
24
modules/cabana/example_settings_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
202
modules/cabana/settings_link_test.go
Normal file
202
modules/cabana/settings_link_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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,6 +411,11 @@ type SettingsItem struct {
|
||||
Order int
|
||||
Keywords []string
|
||||
Permissions []string
|
||||
// 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:"-"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user