docs(32-01): form extensions in admin-controllers, cabana and pact READMEs
- admin-controllers: placement, values, Load/Save, permissions, widgets, partials and boot errors, with the ExtrasPlugin snippet - Example_formExtension compiles an owner form with the extension - coming-from-wintercms maps extendFormFields, addFields, addTabFields and removeField to the form extension - cabana README feature bullet and Activate row
This commit is contained in:
@@ -56,6 +56,7 @@ Use cases:
|
||||
- Refusals: a lifecycle hook, a relation hook, a bulk, record, toolbar or widget action, a `pact.ReorderAfterSave` hook or a `pact.AdminSingleton` `SingletonRecord` call 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. A `cabana.ValidationError` returned from the same places is answered 422 `validation_failed` with its `Details`, each message that is a translation key (`vendor.plugin::group.key`) translated in the request locale and any other text passed through unchanged. The plugin's error value is never modified, so it may be a shared variable. The surrounding transaction is rolled back. Every other error stays the opaque 500, logged on the server.
|
||||
- Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack.
|
||||
- Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active.
|
||||
- Form fields on another plugin's controller: a `pact.AdminActionExtension` with `Form` set to a `pact.AdminFormExtension` places fields and tabs in any admin controller's form by its ID. Its fields document is the `fields.yaml` format plus `before:`/`after:` anchors, `zone:` (only the tab zone for now) and a `tabs:` list, compiled at boot with every `fields.yaml` check; extensions apply in plugin activation order and the form is sent in the compiled order, so the SPA adds no ordering. `Hide` removes owner fields, `Permissions` and `Visible` decide per request who sees the fields (schema, show and save alike), widget fields run the extension's `Actions` and partial fields render from its `Dir` through `PartialData`. Extension values leave the save body before any owner path reads them; `Save` runs in the record's save transaction after the owner row is written (`pact.AdminFormErrors` answers 422, its notices join `meta.notices`) and `Load` serves the values on show. A form no extension lays out compiles unchanged. Unknown anchors, duplicate names, anchor cycles, anchors outside `Requires()`, other zones, `fileupload` fields and clashing widget action or partial names stop the boot naming the plugin, the controller and the name.
|
||||
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
|
||||
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
|
||||
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
|
||||
@@ -238,7 +239,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation, permissions and record action extensions, plus the admin's own Administrators and Roles screens, and returns the admin `cabana.Routes`, or nil when no plugin registers a controller. |
|
||||
| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation, permissions, record action extensions and form extensions, plus the admin's own Administrators and Roles screens, and returns the admin `cabana.Routes`, or nil when no plugin registers a controller. |
|
||||
| `cabana.Routes` | Guard middleware, mount function and normalized prefix of the admin API and SPA. |
|
||||
| `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. |
|
||||
| `cabana.RuntimeCommands` | Returns the `admin:create`, `admin:reset-password` and `admin:token:*` commands. |
|
||||
|
||||
147
modules/cabana/example_form_extension_test.go
Normal file
147
modules/cabana/example_form_extension_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// ExtraValue is a row of the acme.extras plugin's own table: the subtitle
|
||||
// and the featured flag it keeps for a post of the acme.blog plugin.
|
||||
type ExtraValue struct {
|
||||
PostID uint `gorm:"column:post_id;primaryKey;autoIncrement:false"`
|
||||
Subtitle string `gorm:"column:subtitle"`
|
||||
Featured bool `gorm:"column:featured"`
|
||||
}
|
||||
|
||||
func (ExtraValue) TableName() string { return "acme_extras_values" }
|
||||
|
||||
// extrasFields is the extension's fields document: fields.yaml keys plus
|
||||
// before:/after: anchors, and a tabs: list for the new tab.
|
||||
const extrasFields = `tabs:
|
||||
- name: acme.extras::lang.tab.publishing
|
||||
after: acme.blog::lang.tab.content
|
||||
fields:
|
||||
subtitle:
|
||||
label: acme.extras::lang.subtitle
|
||||
type: text
|
||||
span: left
|
||||
after: title
|
||||
featured:
|
||||
label: acme.extras::lang.featured
|
||||
type: switch
|
||||
tab: acme.extras::lang.tab.publishing
|
||||
`
|
||||
|
||||
// ExtrasPlugin adds a subtitle after the title and a Publishing tab to the
|
||||
// acme.blog posts form, which it does not own, and hides the owner's summary
|
||||
// field. It stores its values in its own table.
|
||||
type ExtrasPlugin struct{}
|
||||
|
||||
func (ExtrasPlugin) ID() string { return "acme.extras" }
|
||||
func (ExtrasPlugin) Requires() []string { return []string{"acme.blog"} }
|
||||
func (ExtrasPlugin) Register(*backpack.App) error { return nil }
|
||||
func (ExtrasPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (ExtrasPlugin) AdminFS() fs.FS {
|
||||
return fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte(extrasFields)}}
|
||||
}
|
||||
|
||||
var _ pact.HasAdminActionExtensions = ExtrasPlugin{}
|
||||
|
||||
// AdminActionExtensions adds fields and a tab to the acme.blog posts form.
|
||||
func (ExtrasPlugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
return []pact.AdminActionExtension{{
|
||||
Controller: "acme.blog.posts",
|
||||
Form: &pact.AdminFormExtension{
|
||||
Fields: "extensions/posts.yaml", // in this plugin's AdminFS
|
||||
Hide: []string{"summary"},
|
||||
Permissions: []string{"acme.extras.edit"},
|
||||
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
|
||||
var row ExtraValue
|
||||
err := in.DB.Where("post_id = ?", in.ID).Limit(1).Find(&row).Error
|
||||
return map[string]any{"subtitle": row.Subtitle, "featured": row.Featured}, err
|
||||
},
|
||||
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
||||
subtitle, _ := in.Values["subtitle"].(string)
|
||||
if len(subtitle) > 120 {
|
||||
return pact.AdminFormSaveResult{}, pact.AdminFormErrors{"subtitle": {"acme.extras::lang.subtitle_too_long"}}
|
||||
}
|
||||
id, err := strconv.ParseUint(in.ID, 10, 64)
|
||||
if err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
featured, _ := in.Values["featured"].(bool)
|
||||
row := ExtraValue{PostID: uint(id), Subtitle: subtitle, Featured: featured}
|
||||
// in.DB is the record's save transaction: an error rolls back
|
||||
// the owner's row too.
|
||||
return pact.AdminFormSaveResult{}, in.DB.Save(&row).Error
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// exampleBlog is the owner: acme.blog with a posts form of its own.
|
||||
type exampleBlog struct{}
|
||||
|
||||
func (exampleBlog) ID() string { return "acme.blog" }
|
||||
func (exampleBlog) Requires() []string { return nil }
|
||||
func (exampleBlog) Register(*backpack.App) error { return nil }
|
||||
func (exampleBlog) Boot(*backpack.App) error { return nil }
|
||||
func (exampleBlog) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{feCtl{id: "acme.blog.posts"}}
|
||||
}
|
||||
func (exampleBlog) AdminFS() fs.FS {
|
||||
return feOwnerPlugin{id: "acme.blog", fields: `fields:
|
||||
title:
|
||||
label: acme.blog::lang.title
|
||||
type: text
|
||||
span: left
|
||||
summary:
|
||||
label: acme.blog::lang.summary
|
||||
type: text
|
||||
body:
|
||||
label: acme.blog::lang.body
|
||||
type: text
|
||||
tab: acme.blog::lang.tab.content
|
||||
meta:
|
||||
label: acme.blog::lang.meta
|
||||
type: text
|
||||
tab: acme.blog::lang.tab.meta
|
||||
`}.AdminFS()
|
||||
}
|
||||
|
||||
// Example_formExtension compiles the owner's form with the extension, as
|
||||
// the admin does at boot, and prints each tab with its fields.
|
||||
func Example_formExtension() {
|
||||
plugins := []party.Plugin{exampleBlog{}, ExtrasPlugin{}}
|
||||
reg, err := feCompile(plugins...)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
cc, _ := reg.Get("acme.blog.posts")
|
||||
var tabs []string
|
||||
fields := map[string][]string{}
|
||||
for _, field := range cc.Form.Fields {
|
||||
tab := displayTab(field.Tab)
|
||||
if _, seen := fields[tab]; !seen {
|
||||
tabs = append(tabs, tab)
|
||||
}
|
||||
fields[tab] = append(fields[tab], field.Name)
|
||||
}
|
||||
for _, tab := range tabs {
|
||||
fmt.Printf("%s: %s\n", tab, strings.Join(fields[tab], ", "))
|
||||
}
|
||||
// Output:
|
||||
// default: title, subtitle
|
||||
// acme.blog::lang.tab.content: body
|
||||
// acme.extras::lang.tab.publishing: featured
|
||||
// acme.blog::lang.tab.meta: meta
|
||||
}
|
||||
@@ -35,6 +35,10 @@ type feWide struct {
|
||||
On bool `gorm:"column:on"`
|
||||
Title string `gorm:"column:title"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
// Summary, Body and Meta are the columns of Example_formExtension.
|
||||
Summary string `gorm:"column:summary"`
|
||||
Body string `gorm:"column:body"`
|
||||
Meta string `gorm:"column:meta"`
|
||||
}
|
||||
|
||||
// feCtl is a configurable owner controller over the feWide model.
|
||||
|
||||
Reference in New Issue
Block a user