feat(cabana): per-request options for plugin field types
- pact.FormFieldType gains RequestOptions, called with the application's database once per form schema request for each type the form uses - the form schema route merges the answer into a copy of each field's typeOptions; a fields.yaml key wins, the compiled schema never changes - an error logs one warning naming the type and serves the fields.yaml options only - TestFieldTypeRequestOptions covers every screen, the precedence, the call count, the error path, unchanged schemas, form extension fields and the authenticated handler; a runnable example backs the docs - the typeOptions description, the regenerated OpenAPI document and schema.d.ts, the field types page and the pact and cabana READMEs document it
This commit is contained in:
@@ -36,7 +36,7 @@ Use cases:
|
||||
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
|
||||
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
|
||||
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
|
||||
- Plugin field types: a plugin implementing `pact.HasFormFieldTypes` registers admin form field types under short aliases that any controller's `fields.yaml` uses as `type:`. Boot checks the aliases (no built-in type, no alias claimed by two plugins), the element prefix, the scripts and styles and each field's option keys and `CheckOptions`. A save runs the type's `Decode` inside the save transaction after the record is loaded: the decoded value is stored, a `pact.FormFieldError` is a 422 on the field, any other error an opaque 500, and the notices a type returns are localized into the response's `meta.notices` (`cabana.FieldNotice`), which is absent when no type reported anything. Show and save responses serve the value through the type's `Load`. A translatable type's value is a locale map stored through the same lift, `cabana.TranslationWriter` and hydrate path as `mlmarkdown`, decoded and loaded per locale. On update, a value equal to the one the form was served for that field and locale is neither decoded nor written. Form hooks read what a save wrote through `cabana.FieldWritesFromContext`. A list column may name a plugin alias as its `type` and shows the type's `ListValue`. The cabana-owned upload route hands an element's file to the type's `Upload`. The form schema carries `element`, `translatable` and `typeOptions` for such a field. Settings and relation forms refuse plugin field types (supported in controller forms only). See [Plugin field types](../../docs/backend/field-types.md).
|
||||
- Plugin field types: a plugin implementing `pact.HasFormFieldTypes` registers admin form field types under short aliases that any controller's `fields.yaml` uses as `type:`. Boot checks the aliases (no built-in type, no alias claimed by two plugins), the element prefix, the scripts and styles and each field's option keys and `CheckOptions`. A save runs the type's `Decode` inside the save transaction after the record is loaded: the decoded value is stored, a `pact.FormFieldError` is a 422 on the field, any other error an opaque 500, and the notices a type returns are localized into the response's `meta.notices` (`cabana.FieldNotice`), which is absent when no type reported anything. Show and save responses serve the value through the type's `Load`. A translatable type's value is a locale map stored through the same lift, `cabana.TranslationWriter` and hydrate path as `mlmarkdown`, decoded and loaded per locale. On update, a value equal to the one the form was served for that field and locale is neither decoded nor written. Form hooks read what a save wrote through `cabana.FieldWritesFromContext`. A list column may name a plugin alias as its `type` and shows the type's `ListValue`. The cabana-owned upload route hands an element's file to the type's `Upload`. The form schema carries `element`, `translatable` and `typeOptions` for such a field; a type's `pact.FormFieldType.RequestOptions`, called once per schema request for each type the form uses, adds per-request options under the fields.yaml ones (a fields.yaml key wins, an error is logged and serves the fields.yaml options only). Settings and relation forms refuse plugin field types (supported in controller forms only). See [Plugin field types](../../docs/backend/field-types.md).
|
||||
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
|
||||
- Field triggers: fields.yaml `trigger` (`action: disable|hide`, `field`, `condition: checked|unchecked`) makes the admin disable or hide a field while a switch or checkbox of the same form is on or off; the schema carries it as `trigger` (`cabana.FieldTrigger`), a disabled or hidden field is left out of the save body, and an invalid trigger, or one in a repeater row, settings form or relation form, stops boot.
|
||||
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. A controller that also implements `cabana.PermissionRecordLockProvider` fixes some values of one stored record (a role's code-declared grants, an administrator's own overrides): the record response carries them as `meta.permissionLocks`, the admin shows them locked with their note and never sends them, and a save keeps their stored values. The widget fill contract is unchanged: a widget still writes scalar fields only.
|
||||
|
||||
@@ -592,7 +592,7 @@ func AdminListSchema() {}
|
||||
// AdminFormSchema documents the form schema route.
|
||||
//
|
||||
// @Summary Admin form schema
|
||||
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route. `trigger` ({action, field, condition}) disables (action disable: shown read-only) or hides (action hide) the field while the switch or checkbox `field` of the same form is checked or unchecked; the admin leaves such a field out of the save body and the server keeps its stored value, so the trigger is a display rule only. A `type: richeditor` field's value is a string of HTML, sanitized on every save; what the sanitizer removed is reported in the save response's `meta.notices`. A `type: repeater` field carries `form.fields` (the fields of one row), `prompt`, `maxItems`, `minItems` and `titleFrom`; its value in a record response and in a save body is a list of row objects keyed by the nested field names, and a row's errors are keyed `<field>.<index>.<nested>`.
|
||||
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys, plus per-request options the type supplies; a key set in fields.yaml wins). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route. `trigger` ({action, field, condition}) disables (action disable: shown read-only) or hides (action hide) the field while the switch or checkbox `field` of the same form is checked or unchecked; the admin leaves such a field out of the save body and the server keeps its stored value, so the trigger is a display rule only. A `type: richeditor` field's value is a string of HTML, sanitized on every save; what the sanitizer removed is reported in the save response's `meta.notices`. A `type: repeater` field carries `form.fields` (the fields of one row), `prompt`, `maxItems`, `minItems` and `titleFrom`; its value in a record response and in a save body is a list of row objects keyed by the nested field names, and a row's errors are keyed `<field>.<index>.<nested>`.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotesPlugin is the blog plugin with a notes field type.
|
||||
@@ -61,3 +62,30 @@ func ExampleCompileFormWithTypes() {
|
||||
// Output:
|
||||
// notes notes acme-blog-notes 6
|
||||
}
|
||||
|
||||
// siteTopHeading stands in for a site setting a plugin reads per request:
|
||||
// the highest heading level a body may use.
|
||||
func siteTopHeading(_ context.Context, _ *gorm.DB) (int, error) { return 2, nil }
|
||||
|
||||
// headingLevels is a RequestOptions func: the editor offers the heading
|
||||
// levels from the site's top heading down to level 4, read on every form
|
||||
// schema request, so a changed setting needs no restart.
|
||||
func headingLevels(ctx context.Context, db *gorm.DB) (map[string]any, error) {
|
||||
top, err := siteTopHeading(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
levels := []int{}
|
||||
for level := top; level <= 4; level++ {
|
||||
levels = append(levels, level)
|
||||
}
|
||||
return map[string]any{"headingLevels": levels}, nil
|
||||
}
|
||||
|
||||
func ExampleCompileFormWithTypes_requestOptions() {
|
||||
typ := pact.FormFieldType{Name: "editor", Element: "acme-blog-editor", RequestOptions: headingLevels}
|
||||
options, _ := typ.RequestOptions(context.Background(), nil)
|
||||
fmt.Println(options["headingLevels"])
|
||||
// Output:
|
||||
// [2 3 4]
|
||||
}
|
||||
|
||||
292
modules/cabana/field_request_options_smoke_test.go
Normal file
292
modules/cabana/field_request_options_smoke_test.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// roNotesFields declares the notes type four times: without a context and
|
||||
// once each for the create, update and preview screens. Every one sets rows,
|
||||
// which the type's request options also name.
|
||||
const roNotesFields = ftNotesField +
|
||||
" notes_create:\n type: notes\n label: Create\n context: create\n rows: 4\n" +
|
||||
" notes_update:\n type: notes\n label: Update\n context: update\n rows: 4\n" +
|
||||
" notes_preview:\n type: notes\n label: Preview\n context: preview\n rows: 4\n"
|
||||
|
||||
// roNote is the model of the request options controller: one column per
|
||||
// notes field of roNotesFields.
|
||||
type roNote struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Notes string `gorm:"column:notes"`
|
||||
NotesCreate string `gorm:"column:notes_create"`
|
||||
NotesUpdate string `gorm:"column:notes_update"`
|
||||
NotesPreview string `gorm:"column:notes_preview"`
|
||||
}
|
||||
|
||||
func (*roNote) Fillable() []string {
|
||||
return []string{"title", "notes", "notes_create", "notes_update", "notes_preview"}
|
||||
}
|
||||
func (*roNote) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// roController is ftController editing roNote.
|
||||
type roController struct{ ftController }
|
||||
|
||||
func (roController) NewRecord() any { return &roNote{} }
|
||||
|
||||
// roPlugin is ftPlugin with roController.
|
||||
type roPlugin struct{ ftPlugin }
|
||||
|
||||
func (roPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{roController{}}
|
||||
}
|
||||
|
||||
// roDemo is the request options plugin with the notes type typ.
|
||||
func roDemo(typ pact.FormFieldType) roPlugin {
|
||||
return roPlugin{ftPlugin{id: "acme.demo", fsys: ftFS(roNotesFields), types: []pact.FormFieldType{typ}}}
|
||||
}
|
||||
|
||||
// roCounter counts RequestOptions calls.
|
||||
type roCounter struct{ calls int }
|
||||
|
||||
// roNotesType is the notes type whose request options add headingLevels and
|
||||
// try to override rows; err makes them fail.
|
||||
func roNotesType(counter *roCounter, err error) pact.FormFieldType {
|
||||
typ := ftNotesType()
|
||||
typ.Options = []string{"rows", "headingLevels"}
|
||||
typ.RequestOptions = func(_ context.Context, _ *gorm.DB) (map[string]any, error) {
|
||||
counter.calls++
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"headingLevels": []any{2, 3, 4}, "rows": 9}, nil
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
// roSchema serves the form schema of acme.demo.notes as principal and
|
||||
// returns the status, the fields by name and the raw body.
|
||||
func roSchema(t *testing.T, svc *service, principal *bouncer.Principal) (int, map[string]map[string]any, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/notes/schema/form"), nil)
|
||||
if principal != nil {
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
||||
}
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "notes")
|
||||
rec := httptest.NewRecorder()
|
||||
svc.formSchema(rec, req)
|
||||
return rec.Code, roFields(t, rec.Body.Bytes()), rec.Body.Bytes()
|
||||
}
|
||||
|
||||
// roFields indexes a form schema response's fields by name.
|
||||
func roFields(t *testing.T, raw []byte) map[string]map[string]any {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Data struct {
|
||||
Fields []map[string]any `json:"fields"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
for _, field := range body.Data.Fields {
|
||||
name, _ := field["name"].(string)
|
||||
out[name] = field
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roAdmin() *bouncer.Principal {
|
||||
return &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
||||
}
|
||||
|
||||
// roCaptureLog sends the default logger to a buffer for the test.
|
||||
func roCaptureLog(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
return &buf
|
||||
}
|
||||
|
||||
// TestFieldTypeRequestOptions: a plugin field type's RequestOptions reach
|
||||
// the served form schema per request, under the fields.yaml options, for
|
||||
// every screen and for a form extension's field; an error fails open; a type
|
||||
// without them serves the schema as before.
|
||||
func TestFieldTypeRequestOptions(t *testing.T) {
|
||||
t.Run("create, update and preview fields carry the request options", func(t *testing.T) {
|
||||
counter := &roCounter{}
|
||||
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
code, fields, raw := roSchema(t, &service{reg: reg}, roAdmin())
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", code, raw)
|
||||
}
|
||||
for _, name := range []string{"notes", "notes_create", "notes_update", "notes_preview"} {
|
||||
opts, _ := fields[name]["typeOptions"].(map[string]any)
|
||||
if !reflect.DeepEqual(opts["headingLevels"], []any{float64(2), float64(3), float64(4)}) {
|
||||
t.Fatalf("%s typeOptions=%v", name, fields[name]["typeOptions"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an option set in fields.yaml wins over the request value", func(t *testing.T) {
|
||||
counter := &roCounter{}
|
||||
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
_, fields, _ := roSchema(t, &service{reg: reg}, roAdmin())
|
||||
if opts, _ := fields["notes"]["typeOptions"].(map[string]any); opts["rows"] != float64(4) {
|
||||
t.Fatalf("typeOptions=%v, want rows 4 from fields.yaml", fields["notes"]["typeOptions"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RequestOptions runs once per schema request for each type", func(t *testing.T) {
|
||||
counter := &roCounter{}
|
||||
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
svc := &service{reg: reg}
|
||||
roSchema(t, svc, roAdmin())
|
||||
if counter.calls != 1 {
|
||||
t.Fatalf("calls=%d after one request with four fields of the type, want 1", counter.calls)
|
||||
}
|
||||
roSchema(t, svc, roAdmin())
|
||||
if counter.calls != 2 {
|
||||
t.Fatalf("calls=%d after two requests, want 2", counter.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the compiled schema is never changed", func(t *testing.T) {
|
||||
counter := &roCounter{}
|
||||
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
cc, _ := reg.Get("acme.demo.notes")
|
||||
before, _ := cc.Form.CanonicalJSON()
|
||||
roSchema(t, &service{reg: reg}, roAdmin())
|
||||
after, _ := cc.Form.CanonicalJSON()
|
||||
if !bytes.Equal(before, after) || strings.Contains(string(after), "headingLevels") {
|
||||
t.Fatalf("compiled schema changed:\n%s\n%s", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an error serves the fields.yaml options and logs one warning", func(t *testing.T) {
|
||||
buf := roCaptureLog(t)
|
||||
counter := &roCounter{}
|
||||
reg, err := ftCompile(roDemo(roNotesType(counter, errors.New("settings unavailable"))))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
code, fields, raw := roSchema(t, &service{reg: reg}, roAdmin())
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", code, raw)
|
||||
}
|
||||
for _, name := range []string{"notes", "notes_create", "notes_update", "notes_preview"} {
|
||||
if opts, _ := fields[name]["typeOptions"].(map[string]any); !reflect.DeepEqual(opts, map[string]any{"rows": float64(4)}) {
|
||||
t.Fatalf("%s typeOptions=%v, want the fields.yaml options only", name, fields[name]["typeOptions"])
|
||||
}
|
||||
}
|
||||
logged := buf.String()
|
||||
if n := strings.Count(logged, "request options failed"); n != 1 || !strings.Contains(logged, "notes") || !strings.Contains(logged, "settings unavailable") {
|
||||
t.Fatalf("want one warning naming the type, got %d:\n%s", n, logged)
|
||||
}
|
||||
if !strings.Contains(logged, "level=WARN") {
|
||||
t.Fatalf("the warning is not at WARN level:\n%s", logged)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a type without RequestOptions serves the schema unchanged", func(t *testing.T) {
|
||||
reg, err := ftCompile(ftDemo(ftNotesField))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
cc, _ := reg.Get("acme.demo.notes")
|
||||
view, err := cc.Form.Localize(context.Background(), nil, dropdownProvider(cc.Controller))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := view.CanonicalJSON()
|
||||
code, _, raw := roSchema(t, &service{reg: reg}, roAdmin())
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", code, raw)
|
||||
}
|
||||
var body struct {
|
||||
Data FormView `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body.Data.Assets = ControllerAssets{}
|
||||
view.Assets = ControllerAssets{}
|
||||
got, _ := body.Data.CanonicalJSON()
|
||||
want, _ = view.CanonicalJSON()
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("served fields differ from the compiled schema:\n%s\n%s", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a form extension's plugin field gets its request options", func(t *testing.T) {
|
||||
counter := &roCounter{}
|
||||
ext := feExt("acme.extra", nil, "acme.blog.posts", "fields:\n note:\n type: extranote\n label: Note\n after: a\n mode2: on\n")
|
||||
typ := feWidgetType()
|
||||
typ.Options = []string{"mode2"}
|
||||
typ.RequestOptions = func(context.Context, *gorm.DB) (map[string]any, error) {
|
||||
counter.calls++
|
||||
return map[string]any{"genericAnchors": []any{"here"}, "mode2": "request"}, nil
|
||||
}
|
||||
ext.fsys = fstest.MapFS{"assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")}}
|
||||
ext.types = []pact.FormFieldType{typ}
|
||||
reg, err := feCompile(feBlog(), ext)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
(&service{reg: reg}).formSchema(rec, feRequest(roAdmin(), http.MethodGet, adminAPI("/acme/blog/posts/schema/form"), nil, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
opts, _ := roFields(t, rec.Body.Bytes())["note"]["typeOptions"].(map[string]any)
|
||||
if !reflect.DeepEqual(opts["genericAnchors"], []any{"here"}) || opts["mode2"] != "on" || counter.calls != 1 {
|
||||
t.Fatalf("typeOptions=%v calls=%d", opts, counter.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only the authenticated schema handler calls RequestOptions", func(t *testing.T) {
|
||||
counter := &roCounter{}
|
||||
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
code, _, raw := roSchema(t, &service{reg: reg}, nil)
|
||||
if code != http.StatusUnauthorized || counter.calls != 0 {
|
||||
t.Fatalf("status=%d calls=%d body=%s", code, counter.calls, raw)
|
||||
}
|
||||
code, _, _ = roSchema(t, &service{reg: reg}, &bouncer.Principal{ID: 2, Backend: true})
|
||||
if code != http.StatusForbidden || counter.calls != 0 {
|
||||
t.Fatalf("an administrator without the controller permission: status=%d calls=%d", code, counter.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -270,6 +270,40 @@ func compilePluginField(ft *compiledFieldType, values map[string]ast.Node, field
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeRequestOptions adds each plugin field type's RequestOptions answer to
|
||||
// the TypeOptions of the request's fields that use the type. Each type is
|
||||
// asked once, however many fields use it; a key the field sets in
|
||||
// fields.yaml wins. fields is the request's own slice: a field gets a new
|
||||
// map, so the compiled schema is never changed. An error is logged once per
|
||||
// type and the field keeps its fields.yaml options.
|
||||
func mergeRequestOptions(ctx context.Context, db *gorm.DB, fields []FormField) {
|
||||
answers := map[*compiledFieldType]map[string]any{}
|
||||
for i := range fields {
|
||||
ft := fields[i].fieldType
|
||||
if ft == nil || ft.def.RequestOptions == nil {
|
||||
continue
|
||||
}
|
||||
extra, asked := answers[ft]
|
||||
if !asked {
|
||||
var err error
|
||||
extra, err = ft.def.RequestOptions(ctx, db)
|
||||
if err != nil {
|
||||
moduleLog.WarnContext(ctx, "cabana: field type request options failed", "type", ft.def.Name, "plugin", ft.owner, "error", err)
|
||||
extra = nil
|
||||
}
|
||||
answers[ft] = extra
|
||||
}
|
||||
if len(extra) == 0 {
|
||||
continue
|
||||
}
|
||||
merged := copyJSONMap(extra)
|
||||
for key, value := range fields[i].TypeOptions {
|
||||
merged[key] = copyJSONValue(value)
|
||||
}
|
||||
fields[i].TypeOptions = merged
|
||||
}
|
||||
}
|
||||
|
||||
// nodeJSONValue decodes a YAML option value into a JSON-compatible value:
|
||||
// nil, string, bool, int, float64, []any or map[string]any.
|
||||
func nodeJSONValue(node ast.Node) (any, error) {
|
||||
|
||||
@@ -857,6 +857,9 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
// A plugin field type's per-request options (pact.FormFieldType.
|
||||
// RequestOptions) join its fields.yaml options in kept only.
|
||||
mergeRequestOptions(r.Context(), s.requestDB(r.Context()), kept)
|
||||
view.Fields = kept
|
||||
view.Assets = s.controllerAssets(cc)
|
||||
if codes := s.enabledContentLocales(r.Context()); len(codes) > 0 {
|
||||
@@ -873,6 +876,16 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// requestDB is the application's database bound to ctx, nil when none is
|
||||
// configured.
|
||||
func (s *service) requestDB(ctx context.Context) *gorm.DB {
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return db.WithContext(ctx)
|
||||
}
|
||||
|
||||
// schemaFieldHider is implemented by cabana's own admin controllers that
|
||||
// hide some fields from some administrators (the Administrators screen's
|
||||
// is_superuser for non-superusers). hiddenSchemaFields names the form
|
||||
|
||||
@@ -396,8 +396,9 @@ type FormField struct {
|
||||
// Translatable is true when a plugin field type's value is a map of
|
||||
// content locale to string.
|
||||
Translatable bool `json:"translatable,omitempty"`
|
||||
// TypeOptions are a plugin field type's own fields.yaml keys and values;
|
||||
// the admin hands them to the element as JSON.
|
||||
// TypeOptions are a plugin field type's own fields.yaml keys and values,
|
||||
// plus the options its RequestOptions supplies for the request (a key set
|
||||
// in fields.yaml wins); the admin hands them to the element as JSON.
|
||||
TypeOptions map[string]any `json:"typeOptions,omitempty"`
|
||||
|
||||
optionsMethod string
|
||||
|
||||
@@ -32,7 +32,7 @@ Use cases:
|
||||
- My account contributions: `pact.HasAccountContributions` with `pact.AccountContribution` and `pact.AccountAction`, so a plugin adds a section with a status line and buttons to the administrator's own account page, run for the signed-in administrator only.
|
||||
- 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; a `Detached` action runs outside the framework transaction), `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), `pact.HasAdminActionExtensions` with `pact.AdminActionExtension` (a plugin adds record actions to any admin controller by controller ID, one it does not own included; they are offered on that controller's preview screen after the owner's) and `pact.AdminFormExtension` (a plugin places fields and tabs in another controller's form, before or after the owner's, and loads and saves their values itself inside the record's save transaction), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
||||
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
|
||||
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`, and `RequestOptions` adds options that change per request (a site setting the element follows) to the served form schema. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
|
||||
- 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`), values of unstored list columns filled once per page (`pact.ListExtendRecords`), the record title of the form and preview screens (`pact.FormRecordTitle`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), 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`).
|
||||
- Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree.
|
||||
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot. A record type may set `Content` to a `pact.ContentDescriptor`, which describes its records for consumers that work on every content type at once (sitemaps, search indexers, metadata editors): the admin controller that edits them, their morph type, `Describe` (a `pact.ContentRecord` with per-locale `pact.ContentLocale` URL, title, description, image and word count), and the optional `Records` (paged through `pact.ContentList` and `pact.ContentPage`) and `Match` (a public path to a `pact.ContentMatchResult`).
|
||||
@@ -182,7 +182,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.ReorderAfterSave` | Optional controller hook `ReorderAfterSave(ctx, partition)`: runs after a reorder save wrote the new order, inside the same transaction (`cabana.TxFromContext` works); an error rolls the reorder back. |
|
||||
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
|
||||
| `pact.HasFormFieldTypes` | Implemented by a plugin (not a controller) that registers admin form field types. |
|
||||
| `pact.FormFieldType` | One field type: the alias `Name`, the custom-element tag `Element`, `Translatable`, the `Scripts` and `Styles` under `assets/`, the `Options` keys, and the funcs `CheckOptions` (optional), `Decode` (required), `Load`, `ListValue` (`func(ctx context.Context, value any) string`) and `Upload` (optional). |
|
||||
| `pact.FormFieldType` | One field type: the alias `Name`, the custom-element tag `Element`, `Translatable`, the `Scripts` and `Styles` under `assets/`, the `Options` keys, and the funcs `CheckOptions` (optional), `Decode` (required), `Load`, `ListValue` (`func(ctx context.Context, value any) string`), `Upload` and `RequestOptions` (`func(ctx context.Context, db *gorm.DB) (map[string]any, error)`, merged under the fields.yaml options of each form schema request) (optional). |
|
||||
| `pact.FormFieldValue` | What `Decode` and `Load` receive: the transaction, the record, the field, its options, the content locale (translatable types), whether the value lives in the host column, the value (always a string for a translatable type) and an exact reader of another attribute in the same locale. |
|
||||
| `pact.FormFieldDecoded` | `Decode`'s answer: the `Value` to store (a string for a translatable type) and optional `Notices`. |
|
||||
| `pact.FormFieldNotice` | A message about a saved value: a phrase key or text with params; a positive `Count` selects the plural form. |
|
||||
|
||||
@@ -686,6 +686,15 @@ type AdminPartialData interface {
|
||||
// set, turns a stored value into the value the element receives. ListValue,
|
||||
// when set, returns the text a list column of this type shows. Upload, when
|
||||
// set, stores a file the element uploads and returns its URL.
|
||||
//
|
||||
// RequestOptions, when set, adds options that change per request, such as a
|
||||
// site setting the element follows. The admin calls it once per form schema
|
||||
// request for each type the form uses, with the application's database (nil
|
||||
// when none is configured), and merges the answer into every field's
|
||||
// options: an option the field sets in fields.yaml wins. An error is logged
|
||||
// and the schema is served with the fields.yaml options only. The options
|
||||
// reach every administrator who may open the form, so they must hold no
|
||||
// secret.
|
||||
type FormFieldType struct {
|
||||
Name string
|
||||
Element string
|
||||
@@ -698,6 +707,9 @@ type FormFieldType struct {
|
||||
Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"`
|
||||
ListValue func(ctx context.Context, value any) string `json:"-"`
|
||||
Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"`
|
||||
// RequestOptions answers options for one form schema request; nil when
|
||||
// the type has none.
|
||||
RequestOptions func(ctx context.Context, db *gorm.DB) (map[string]any, error) `json:"-"`
|
||||
}
|
||||
|
||||
// FormFieldValue is what the framework hands Decode and Load. DB is the save
|
||||
|
||||
Reference in New Issue
Block a user