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:
@@ -534,17 +534,89 @@ Contributions are compiled only when the admin is active, that is when some plug
|
||||
|
||||
## Adding fields to another controller's form
|
||||
|
||||
The same `pact.AdminActionExtension` carries form fields: set `Form` to a `pact.AdminFormExtension`. The extension describes its fields in a fields document, the `fields.yaml` format plus three placement keys, and stores their values itself through `Load` and `Save`. The owner's model, columns and tables are never touched.
|
||||
A plugin can also place fields and tabs in another controller's form, such as a blog post form it does not own, and keep their values in its own tables. This is WinterCMS's `extendFormFields` with real placement. The same `pact.AdminActionExtension` carries it: set `Form` to a `pact.AdminFormExtension`.
|
||||
|
||||
The fields come in a fields document, the `fields.yaml` format plus three keys per field (`before:`, `after:`, `zone:`) and an optional `tabs:` list:
|
||||
|
||||
```yaml
|
||||
# extensions/posts.yaml in the acme.extra plugin's AdminFS
|
||||
tabs:
|
||||
- name: acme.extras::lang.tab.publishing # the tab key
|
||||
after: acme.blog::lang.tab.content # an owner tab; before: works too
|
||||
fields:
|
||||
subtitle:
|
||||
label: acme.extra::lang.subtitle
|
||||
label: acme.extras::lang.subtitle
|
||||
type: text
|
||||
after: title # the owner's field; before: works the same way
|
||||
span: left
|
||||
after: title # an owner field
|
||||
featured:
|
||||
label: acme.extras::lang.featured
|
||||
type: switch
|
||||
tab: acme.extras::lang.tab.publishing # end of that tab
|
||||
```
|
||||
|
||||
The plugin returns an extension with `Controller: "acme.blog.posts"` and `Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: ..., Save: ...}`, where `Load` takes a `pact.AdminFormLoad` and returns the values by field name, and `Save` takes a `pact.AdminFormSave` and returns a `pact.AdminFormSaveResult`.
|
||||
`Fields` names the document's path in the plugin's `AdminFS` (resolved like `config_form.yaml`'s `form:`); `FieldsYAML` takes the document's bytes instead, for fields a plugin builds at boot, such as one tab per registered content type. `Load` returns the values for a record by field name and `Save` stores them:
|
||||
|
||||
The admin places `subtitle` between `title` and the field that followed it, and the form schema sends the fields in that order. On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill` or the owner's columns; after the owner row is written, `Save` runs inside the same transaction with `pact.AdminFormSave.Values`, and an error rolls the whole save back. On show, `Load` returns the values by field name.
|
||||
```go src=modules/cabana/example_form_extension_test.go#ExtrasPlugin.AdminActionExtensions
|
||||
// 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
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
### Placement
|
||||
|
||||
Every field and tab of a compiled form has a stable name: the field key, and the tab key, which is the raw `tab:` value of the owner's `fields.yaml` (a phrase key or literal text). `default` names the tab of fields without a `tab:`.
|
||||
|
||||
- A field goes `after:` or `before:` its anchor field. Without an anchor it goes to the end of its tab, the default tab when it has no `tab:`. A field with an anchor and no `tab:` joins the anchor's tab; with both, they must agree.
|
||||
- A new tab is declared under `tabs:` and goes `after:` or `before:` an anchor tab, or after the last tab without one. `after: default` is allowed; `before: default` stops the boot, because the admin always renders the default tab first. A form without tabs that gains one renders its own fields under the default tab ("Basic"), then the new tab.
|
||||
- `zone:` takes `tabs` (the default) only for now. `outside` and `secondary` (`pact.AdminFormZoneOutside`, `pact.AdminFormZoneSecondary`) are reserved and stop the boot with "is not supported yet".
|
||||
- Extensions apply in plugin activation order (`Requires()` first). Several extensions anchored at the same point keep that order. An anchor may name a field or tab another plugin's extension added only when that plugin is in the contributor's `Requires()`; the fields of one document may anchor on each other in any order.
|
||||
- `Hide` lists owner fields to leave out of the form for every administrator: they leave the schema, the record and the save, and a tab that loses all its fields disappears. The owner's model must not require a hidden field.
|
||||
- cabana compiles the order once at boot and sends the fields in that order. A form an extension lays out is regrouped by tab in first-appearance order, the default tab first, exactly as the admin renders tabs; a form no extension lays out (including one that only gets a `Save` hook) is compiled byte for byte as before. The admin renders what it receives and adds no ordering of its own, and `span` (left, right, full) keeps working next to inserted fields.
|
||||
- Only top-level fields and tabs are placed; fields inside repeaters stay the owner's.
|
||||
|
||||
### Values, Load and Save
|
||||
|
||||
An extension adds value fields (`text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` with listed options, `markdown`, `mltext`, `mltextarea`, `mlmarkdown` and plugin field types), `widget` fields and `partial` fields. Types that bind to the owner's model are refused at boot, `fileupload` included: an extension owns no attachments, so a plugin field type with `Upload` stores files instead.
|
||||
|
||||
- On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill`, the owner's columns, its translation rows or its attachments. After the owner row is written (and its translations and after hooks have run), `Save` runs inside the same transaction with `pact.AdminFormSave`: the `Record` with its `ID`, `Created`, and `Values`, the submitted values of the extension's fields whose `context` allows the operation. `Save` runs on every create and update, with or without values.
|
||||
- A translatable field (`mltext`, `mltextarea`, `mlmarkdown`, a translatable plugin field type) reaches `Save` as a `map[string]string` of locale to text, and `Load` returns it the same way. A plugin field type's `Decode` runs before `Save` (its notices are kept, a `pact.FormFieldError` is a 422 on the field) and its `Load` after the extension's `Load`.
|
||||
- A `Save` error rolls the whole save back, the owner's row included. `pact.AdminFormErrors` (field name to messages, phrase keys or text) answers 422 with those lines on the fields; any other error is logged and answered with the generic 500 body. `pact.AdminFormSaveResult.Notices` are save notices per field, shown on the field like a field type's notices; a notice may name an owner field.
|
||||
- On show and in the save response, `Load` returns the values by field name; keys that are not the extension's fields are dropped.
|
||||
|
||||
### Permissions, widgets and partials
|
||||
|
||||
`Permissions` (backend permission codes some plugin registers) and `Visible` (asked per request) decide who sees the extension. An administrator who fails either gets none of its fields in the form schema or the record; a save drops their posted keys for it and does not call its `Save`, so stored values stay as they are. Its widget actions and partials answer 404 for them.
|
||||
|
||||
A `widget` field's tag uses the contributing plugin's prefix (`acme-extras-...`), its `action` names one of the extension's `Actions` (a name the owner already registers stops the boot), and its `fill` keys name value fields of the same extension. Its element script comes from one of the plugin's field types (loaded on every admin page) or from the plugin's own `pact.AdminClientAssets`, which cabana adds to the target form's assets. A `partial` field renders `_<path>.htm` from `Dir` in the contributing plugin's `AdminFS` through `PartialData`, with the same view model rules as `pact.AdminPartialData`; a partial name the owner already uses stops the boot.
|
||||
|
||||
### Boot errors
|
||||
|
||||
The start-up stops, naming the plugin, the controller and the offending name, on: an unknown controller or one without a form; an unknown field or tab anchor; a duplicate field name (against the owner or an earlier extension) or tab key; two tab keys whose labels translate to the same text in some locale; an anchor cycle among one document's fields; an anchor on another plugin's field or tab outside `Requires()`; `before: default`; a zone other than `tabs`; a `Hide` of an unknown field or of a field another field references through `fill`, `dependsOn`, `trigger` or `preset`; a refused field type; value fields without `Save`; a widget action or partial name the owner already uses; both `Fields` and `FieldsYAML`; and any `fields.yaml` error in the document.
|
||||
|
||||
@@ -26,6 +26,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
|
||||
| Laravel collections: `map`, `filter`, `reduce`, `keyBy`, `groupBy`, `unique`, `partition`, `first` and `pluck` | Generic functions on plain slices and maps: `sieve.Map`, `sieve.TryMap`, `sieve.Filter`, `sieve.Reduce`, `sieve.KeyBy`, `sieve.GroupBy`, `sieve.Uniq`, `sieve.Partition` and `sieve.Find`. Pluck is `sieve.Map` with a field accessor, unique by key is `sieve.UniqBy`, `chunk` is the standard `slices.Chunk`, and sets are built with `sieve.SetOf` | [sieve](../../modules/sieve/README.md) |
|
||||
| `fields.yaml` and `columns.yaml` | The same YAML, embedded in the plugin through `pact.AdminAssets` and compiled at boot into a `cabana.CompiledController` | [Forms](../backend/forms.md), [Lists and filters](../backend/lists-and-filters.md), [cabana](../../modules/cabana/README.md) |
|
||||
| Backend controllers with the Form, List and Relation behaviours | A `pact.AdminController` returned from `pact.HasAdminControllers`; the generic admin API replaces the behaviours, and hooks such as `pact.FormBeforeCreate` and `pact.ListExtendQuery` replace behaviour overrides | [Admin controllers](../backend/admin-controllers.md), [cabana](../../modules/cabana/README.md) |
|
||||
| `extendFormFields` (the `backend.form.extendFields` event) with `addFields`, `addTabFields` and `removeField` | A form extension: `pact.HasAdminActionExtensions` returning a `pact.AdminActionExtension` whose `Form` is a `pact.AdminFormExtension`. Its fields document places fields `before:` or `after:` an owner field and declares new tabs under `tabs:` (`addFields`, `addTabFields`, with real placement instead of the end of the form), `Hide` replaces `removeField`, and `Load` and `Save` keep the values in the plugin's own tables inside the record's save transaction | [Admin controllers](../backend/admin-controllers.md#adding-fields-to-another-controllers-form), [pact](../../modules/pact/README.md) |
|
||||
| The Reorder behaviour and `config_reorder.yaml` | A `config_reorder.yaml` next to `config_list.yaml` and the `reorder` toolbar button: a drag-and-drop tree, or a flat list for records without a parent column; `pact.ReorderAfterSave` runs after a reorder save | [Lists and filters](../backend/lists-and-filters.md#tree-reorder), [cabana](../../modules/cabana/README.md) |
|
||||
| Form widgets and `registerFormWidgets` | A plugin field type: `pact.HasFormFieldTypes` returning `pact.FormFieldType` values, usable as `type:` in any controller's `fields.yaml`; a one-off element with a server action is a `type: widget` field | [Plugin field types](../backend/field-types.md), [Partials and widgets](../backend/partials-and-widgets.md#form-widgets) |
|
||||
| HTML cleaning of rich editor content | Every `richeditor` value is sanitized on save by `seaglass.Sanitize`, an allowlist policy; plugin code calls `seaglass.SanitizeReport` to learn what was removed | [Rich text](../backend/forms.md#rich-text), [seaglass](../../modules/seaglass/README.md) |
|
||||
|
||||
@@ -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