diff --git a/docs/backend/admin-controllers.md b/docs/backend/admin-controllers.md index d5e54290..d91674ad 100644 --- a/docs/backend/admin-controllers.md +++ b/docs/backend/admin-controllers.md @@ -531,3 +531,20 @@ The admin merges the contributions at boot, after the target's own record action The start-up stops on an unknown controller ID, a target whose `config_form.yaml` has no `preview` block (record actions are offered on the preview screen), a name the target already registers (its owner's, or an earlier contribution's), a reserved name (`create`, `delete`), a name that is not an identifier, an action without `Run`, an empty label, or a prompt without a label. Prefix contributed names with your plugin (`acme_...`) so they cannot collide with names the owner adds later. Contributions are compiled only when the admin is active, that is when some plugin registers admin controllers; a plugin that only adds actions does not switch the admin on. + +## 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. + +```yaml +# extensions/posts.yaml in the acme.extra plugin's AdminFS +fields: + subtitle: + label: acme.extra::lang.subtitle + type: text + after: title # the owner's field; before: works the same way +``` + +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`. + +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. diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index 4a7fa9e4..08a7a1ea 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -256,6 +256,12 @@ type CompiledController struct { // singleton is true for a controller implementing pact.AdminSingleton // (D-11): one record, opened directly; no list, create, bulk or delete. singleton bool + // formExtensions are the form contributions other plugins (or the owner) + // add through pact.AdminActionExtension.Form (D-91), in plugin activation + // order; extensionFields maps the name of every field they add to its + // extension. + formExtensions []*compiledFormExtension + extensionFields map[string]*compiledFormExtension } // Registry is the immutable controller map keyed by controller ID. diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index e7696ec2..eeddf77c 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -252,6 +252,11 @@ func BindWritableFields(cc *CompiledController) error { if !valueFormField(field) || protectedFillKey(field.Name) || cc.virtual[field.Name] { continue } + // A form extension's field is never a column of the owner's model + // (D-91): its extension loads and saves it. + if cc.extensionOf(field.Name) != nil { + continue + } if _, known := cols[field.Name]; !known { return errors.New("cabana: controller " + id + ": field " + field.Name + " is not a model column") } @@ -533,6 +538,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil { return err } + if err := hydrateFormExtensions(withTx(ctx, tx), tx, cc, target, result.Data, "update"); err != nil { + return err + } result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target) return err }) @@ -747,6 +755,9 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i if update { op = "update" } + // D-91: form extension values leave the body first, so no owner path + // (Fill, bindings, translations, attachments) ever reads them. + extensions := liftExtensionValues(cc, in.Body, op) // Writable relation keys are lifted before scalar projection (which drops // every nested value); only keys present in the body are applied. relations, err := liftRelationValues(cc, in.Body, op) @@ -925,6 +936,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i if err != nil { return err } + // D-91: form extensions save their own values in this transaction, + // after the owner row is written; an error rolls everything back. + extNotices, err := saveFormExtensions(ctx, tx, cc, target, extensions, !update) + if err != nil { + return err + } + notices = append(notices, extNotices...) result, err = projectFullRecord(ctx, tx, cc, target) if err != nil { return err @@ -932,7 +950,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op); err != nil { return err } - return hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, op) + if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, op); err != nil { + return err + } + return hydrateFormExtensions(ctx, tx, cc, target, result.Data, op) }) if err != nil { return RecordResult{}, err diff --git a/modules/cabana/form_extensions.go b/modules/cabana/form_extensions.go new file mode 100644 index 00000000..159051cd --- /dev/null +++ b/modules/cabana/form_extensions.go @@ -0,0 +1,608 @@ +package cabana + +import ( + "context" + "errors" + "fmt" + "reflect" + "sort" + "strings" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/goccy/go-yaml/ast" + "github.com/goccy/go-yaml/parser" + "gorm.io/gorm" +) + +// defaultTabKey is the name a form extension uses for the tab of the fields +// without a tab: key (D-91 P-04). +const defaultTabKey = "default" + +// compiledFormExtension is one plugin's contribution to one controller's +// form (pact.AdminActionExtension.Form, D-91) after the boot checks. +type compiledFormExtension struct { + pluginID string + def pact.AdminFormExtension + // fields are the names of the fields the extension adds, in compiled + // order; own holds them by name. + fields []string + own map[string]FormField +} + +// formContribution is one extension waiting to be compiled onto a target. +type formContribution struct { + plugin party.Plugin + def pact.AdminFormExtension +} + +// extensionDoc is a parsed form extension fields document: the new tabs and +// the fields with their placement keys, in document order. +type extensionDoc struct { + tabs []extensionTab + fields []extensionField +} + +// extensionTab is one entry of a fields document's tabs: list. +type extensionTab struct { + name, before, after, zone string +} + +// extensionField is one compiled field of a fields document with its +// before:, after: and zone: keys. +type extensionField struct { + field FormField + before, after, zone string +} + +// placementKeys are the keys a form extension's field takes next to the +// fields.yaml keys; they are taken out before the field is compiled. +var placementKeys = map[string]bool{"before": true, "after": true, "zone": true} + +// compileFormExtensions merges the form fields and tabs plugins add through +// pact.AdminActionExtension.Form (D-91) into the compiled forms of reg, in +// plugin slice (activation) order. It must run after every controller is +// compiled. An extension's fields document is compiled like a fields.yaml +// (every type, option and plugin field type check applies), then each field +// is placed at its anchor. Every failure stops boot and names the plugin, the +// controller and the offending name. +func compileFormExtensions(reg *Registry, plugins []party.Plugin) error { + if reg == nil { + return fmt.Errorf("cabana: registry is nil") + } + targets := map[string][]formContribution{} + var order []string + for _, plugin := range plugins { + if plugin == nil { + continue + } + src, ok := plugin.(pact.HasAdminActionExtensions) + if !ok || src == nil { + continue + } + for _, ext := range src.AdminActionExtensions() { + if ext.Form == nil { + continue + } + cc, ok := reg.byID[ext.Controller] + if !ok || cc == nil { + return fmt.Errorf("cabana: plugin %s extends the form of unknown admin controller %q", plugin.ID(), ext.Controller) + } + if cc.Form == nil { + return fmt.Errorf("cabana: plugin %s extends the form of admin controller %s, which has no form (config_form.yaml)", plugin.ID(), ext.Controller) + } + if _, seen := targets[ext.Controller]; !seen { + order = append(order, ext.Controller) + } + targets[ext.Controller] = append(targets[ext.Controller], formContribution{plugin: plugin, def: *ext.Form}) + } + } + for _, id := range order { + if err := applyFormExtensions(reg, reg.byID[id], targets[id]); err != nil { + return err + } + } + return nil +} + +// extErr is a form extension boot error naming the plugin and the controller. +func extErr(pluginID, controller, format string, args ...any) error { + return fmt.Errorf("cabana: plugin %s: controller %q: "+format, append([]any{pluginID, controller}, args...)...) +} + +// applyFormExtensions compiles the contributions of one target controller. +func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []formContribution) error { + controller := cc.Controller.ID() + docs := make([]extensionDoc, len(contributions)) + layout := false + for i, c := range contributions { + doc, err := loadExtensionDoc(reg, c, controller) + if err != nil { + return err + } + docs[i] = doc + if len(doc.fields) > 0 || len(doc.tabs) > 0 || len(c.def.Hide) > 0 { + layout = true + } + } + form := newFormLayout(cc.Form.Fields) + for i, c := range contributions { + ext := &compiledFormExtension{pluginID: c.plugin.ID(), def: c.def, own: map[string]FormField{}} + if err := form.apply(ext, docs[i], controller); err != nil { + return err + } + cc.formExtensions = append(cc.formExtensions, ext) + if cc.extensionFields == nil { + cc.extensionFields = map[string]*compiledFormExtension{} + } + for _, name := range ext.fields { + cc.extensionFields[name] = ext + } + } + if layout { + // A new slice: the owner's compiled list may be shared. + cc.Form.Fields = form.flatten() + } + return nil +} + +// loadExtensionDoc reads and parses one extension's fields document. +func loadExtensionDoc(reg *Registry, c formContribution, controller string) (extensionDoc, error) { + pluginID := c.plugin.ID() + if strings.TrimSpace(c.def.Fields) != "" && len(c.def.FieldsYAML) > 0 { + return extensionDoc{}, extErr(pluginID, controller, "form extension sets both Fields and FieldsYAML") + } + raw := c.def.FieldsYAML + source := "FieldsYAML" + if file := strings.TrimSpace(c.def.Fields); file != "" { + assets, ok := c.plugin.(pact.AdminAssets) + if !ok || assets == nil || assets.AdminFS() == nil { + return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: the plugin has no AdminFS", file) + } + name, err := assetPath(pluginID, file) + if err != nil { + return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err) + } + if raw, err = readAsset(assets.AdminFS(), name); err != nil { + return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err) + } + source = name + } + if len(raw) == 0 { + return extensionDoc{}, nil + } + doc, err := parseExtensionDoc(raw, reg.fieldTypes) + if err != nil { + return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", source, err) + } + return doc, nil +} + +// parseExtensionDoc compiles a form extension fields document: a mapping +// with fields: (a fields.yaml mapping whose entries may add before:, after: +// and zone:) and tabs: (a list of name/before/after/zone mappings). +func parseExtensionDoc(raw []byte, types *fieldTypeRegistry) (extensionDoc, error) { + file, err := parser.ParseBytes(raw, 0) + if err != nil { + return extensionDoc{}, normalizeYAMLError(err) + } + var entries []*ast.MappingValueNode + for i, doc := range file.Docs { + if doc == nil { + continue + } + if i > 0 { + return extensionDoc{}, fmt.Errorf("want one YAML document") + } + switch body := unwrapNode(doc.Body).(type) { + case nil, *ast.NullNode: + case *ast.MappingNode: + entries = body.Values + case *ast.MappingValueNode: + entries = []*ast.MappingValueNode{body} + default: + return extensionDoc{}, fmt.Errorf("the document must be a mapping with fields: and tabs:") + } + } + var out extensionDoc + seen := map[string]bool{} + for _, entry := range entries { + key, err := nodeString(unwrapNode(entry.Key)) + if err != nil { + return extensionDoc{}, err + } + if seen[key] { + return extensionDoc{}, fmt.Errorf("duplicate key %s", key) + } + seen[key] = true + switch key { + case "fields": + if out.fields, err = decodeExtensionFields(entry.Value, types); err != nil { + return extensionDoc{}, err + } + case "tabs": + if out.tabs, err = decodeExtensionTabs(entry.Value); err != nil { + return extensionDoc{}, err + } + default: + return extensionDoc{}, fmt.Errorf("unknown key %s (want fields or tabs)", key) + } + } + return out, nil +} + +// decodeExtensionFields takes the placement keys out of every field entry +// and compiles the rest through decodeFieldMapping. +func decodeExtensionFields(node ast.Node, types *fieldTypeRegistry) ([]extensionField, error) { + node = unwrapNode(node) + placement := map[string]map[string]string{} + if mapping, ok := node.(*ast.MappingNode); ok { + for _, entry := range mapping.Values { + name, err := nodeString(unwrapNode(entry.Key)) + if err != nil { + continue // decodeFieldMapping reports the name + } + body, ok := unwrapNode(entry.Value).(*ast.MappingNode) + if !ok { + continue + } + keys := map[string]string{} + kept := make([]*ast.MappingValueNode, 0, len(body.Values)) + for _, item := range body.Values { + key, err := nodeString(unwrapNode(item.Key)) + if err != nil || !placementKeys[key] { + kept = append(kept, item) + continue + } + value, err := nodeString(unwrapNode(item.Value)) + if err != nil { + return nil, fmt.Errorf("field %s: %s: %w", name, key, err) + } + keys[key] = strings.TrimSpace(value) + } + body.Values = kept + placement[name] = keys + } + } + compiled, err := decodeFieldMapping(node, nil, types) + if err != nil { + return nil, err + } + out := make([]extensionField, 0, len(compiled)) + for _, field := range compiled { + keys := placement[field.Name] + out = append(out, extensionField{field: field, before: keys["before"], after: keys["after"], zone: keys["zone"]}) + } + return out, nil +} + +// decodeExtensionTabs decodes the tabs: list. +func decodeExtensionTabs(node ast.Node) ([]extensionTab, error) { + node = unwrapNode(node) + if _, ok := node.(*ast.NullNode); ok || node == nil { + return nil, nil + } + list, ok := node.(*ast.SequenceNode) + if !ok { + return nil, fmt.Errorf("tabs must be a list of mappings with name, before, after and zone") + } + out := make([]extensionTab, 0, len(list.Values)) + for i, item := range list.Values { + mapping, ok := unwrapNode(item).(*ast.MappingNode) + if !ok { + return nil, fmt.Errorf("tabs[%d] must be a mapping with name, before, after and zone", i) + } + var tab extensionTab + for _, entry := range mapping.Values { + key, err := nodeString(unwrapNode(entry.Key)) + if err != nil { + return nil, fmt.Errorf("tabs[%d]: %w", i, err) + } + value, err := nodeString(unwrapNode(entry.Value)) + if err != nil { + return nil, fmt.Errorf("tabs[%d]: %s: %w", i, key, err) + } + value = strings.TrimSpace(value) + switch key { + case "name": + tab.name = value + case "before": + tab.before = value + case "after": + tab.after = value + case "zone": + tab.zone = value + default: + return nil, fmt.Errorf("tabs[%d]: unknown key %s (want name, before, after or zone)", i, key) + } + } + if tab.name == "" { + return nil, fmt.Errorf("tabs[%d]: name is required", i) + } + out = append(out, tab) + } + return out, nil +} + +// tabGroup is one tab of a form being laid out: its raw tab key ("" for the +// default tab) and its fields in order. +type tabGroup struct { + key string + fields []FormField +} + +// formLayout is a form being laid out by its extensions: the fields grouped +// by tab in first-appearance order, the default tab first (P-02), plus who +// added each field. +type formLayout struct { + groups []*tabGroup + // fieldPlugin maps every field name to the plugin that added it; owner + // fields map to "". + fieldPlugin map[string]string +} + +func newFormLayout(fields []FormField) *formLayout { + layout := &formLayout{fieldPlugin: map[string]string{}} + index := map[string]*tabGroup{} + for _, field := range fields { + group, ok := index[field.Tab] + if !ok { + group = &tabGroup{key: field.Tab} + index[field.Tab] = group + layout.groups = append(layout.groups, group) + } + group.fields = append(group.fields, field) + layout.fieldPlugin[field.Name] = "" + } + // The SPA always renders the default tab first. + for i, group := range layout.groups { + if group.key == "" && i > 0 { + copy(layout.groups[1:i+1], layout.groups[:i]) + layout.groups[0] = group + break + } + } + return layout +} + +// flatten is the laid-out field list. +func (l *formLayout) flatten() []FormField { + out := []FormField{} + for _, group := range l.groups { + out = append(out, group.fields...) + } + return out +} + +// group returns the tab group with key, nil when the form has none. +func (l *formLayout) group(key string) *tabGroup { + for _, group := range l.groups { + if group.key == key { + return group + } + } + return nil +} + +// locate returns the group holding field name and its index there. +func (l *formLayout) locate(name string) (*tabGroup, int) { + for _, group := range l.groups { + for i, field := range group.fields { + if field.Name == name { + return group, i + } + } + } + return nil, -1 +} + +// apply places one extension's fields. +func (l *formLayout) apply(ext *compiledFormExtension, doc extensionDoc, controller string) error { + pluginID := ext.pluginID + if len(doc.tabs) > 0 { + return extErr(pluginID, controller, "tab %s: new tabs are not supported yet", doc.tabs[0].name) + } + for _, item := range doc.fields { + field := item.field + if owner, taken := l.fieldPlugin[field.Name]; taken { + by := "the owner" + if owner != "" { + by = "plugin " + owner + } + return extErr(pluginID, controller, "field %s: duplicate field name (%s already adds it)", field.Name, by) + } + anchor, before := item.after, false + if anchor == "" && item.before != "" { + anchor, before = item.before, true + } + var ( + group *tabGroup + index int + ) + if anchor != "" { + group, index = l.locate(anchor) + if group == nil { + return extErr(pluginID, controller, "field %s: unknown anchor %q", field.Name, anchor) + } + if field.Tab == "" { + field.Tab = group.key + } else if field.Tab != group.key { + return extErr(pluginID, controller, "field %s: anchor %q is in another tab than %q", field.Name, anchor, field.Tab) + } + if !before { + index++ + } + } else { + group = l.group(field.Tab) + if group == nil { + if field.Tab != "" { + return extErr(pluginID, controller, "field %s: unknown tab %q", field.Name, field.Tab) + } + group = &tabGroup{} + l.groups = append([]*tabGroup{group}, l.groups...) + } + index = len(group.fields) + } + group.fields = append(group.fields[:index], append([]FormField{field}, group.fields[index:]...)...) + l.fieldPlugin[field.Name] = pluginID + ext.fields = append(ext.fields, field.Name) + ext.own[field.Name] = field + } + return nil +} + +// extensionOf returns the form extension that added field name, nil for an +// owner field. +func (cc *CompiledController) extensionOf(name string) *compiledFormExtension { + if cc == nil { + return nil + } + return cc.extensionFields[name] +} + +// extensionValues are the submitted values of one save, per extension. +type extensionValues map[*compiledFormExtension]map[string]any + +// liftExtensionValues takes the values of every extension field out of a +// save body before anything else reads it, so they never reach Fill, the +// owner's bindings or translation rows (D-91, T-32-01). Only fields whose +// context allows op are kept for their extension's Save. +func liftExtensionValues(cc *CompiledController, body map[string]any, op string) extensionValues { + out := extensionValues{} + if cc == nil || len(cc.extensionFields) == 0 || body == nil { + return out + } + for name, ext := range cc.extensionFields { + value, present := body[name] + if !present { + continue + } + delete(body, name) + if !contextAllows(cc, name, op) { + continue + } + if out[ext] == nil { + out[ext] = map[string]any{} + } + out[ext][name] = value + } + return out +} + +// saveFormExtensions runs every extension's Save inside the record's save +// transaction, after the owner row is written. created is true on create. +func saveFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, values extensionValues, created bool) ([]pluginNotice, error) { + if cc == nil { + return nil, nil + } + var notices []pluginNotice + for _, ext := range cc.formExtensions { + if ext.def.Save == nil { + continue + } + own := values[ext] + if own == nil { + own = map[string]any{} + } + result, err := ext.def.Save(ctx, pact.AdminFormSave{ + DB: tx, + Controller: controllerID(cc), + Record: target, + ID: recordIDText(target), + Created: created, + Values: own, + }) + if err != nil { + return nil, extensionFailure(cc, ext, "save", err) + } + notices = append(notices, extensionNotices(result)...) + } + return notices, nil +} + +// extensionNotices are a Save result's notices in field name order. +func extensionNotices(result pact.AdminFormSaveResult) []pluginNotice { + var out []pluginNotice + names := make([]string, 0, len(result.Notices)) + for name := range result.Notices { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + for _, notice := range result.Notices[name] { + out = append(out, pluginNotice{field: name, notice: notice}) + } + } + return out +} + +// extensionFailure classifies an extension hook error: AdminFormErrors is +// validation_failed with its messages on the fields, a cabana refusal passes +// through, anything else is logged and becomes the opaque lifecycle error. +func extensionFailure(cc *CompiledController, ext *compiledFormExtension, stage string, err error) error { + var invalid pact.AdminFormErrors + if errors.As(err, &invalid) && len(invalid) > 0 { + details := make(map[string]any, len(invalid)) + for name, messages := range invalid { + details[name] = append([]string(nil), messages...) + } + return &ValidationError{Details: details} + } + var refused *ValidationError + var forbidden *ForbiddenError + if errors.As(err, &refused) || errors.As(err, &forbidden) { + return err + } + moduleLog.Error("cabana: form extension "+stage+" failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err) + return &lifecycleError{ControllerID: controllerID(cc)} +} + +// hydrateFormExtensions adds every extension's Load values to a show or save +// response. Only keys that are the extension's own fields whose context +// allows op are kept. +func hydrateFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, data map[string]any, op string) error { + if cc == nil || data == nil { + return nil + } + for _, ext := range cc.formExtensions { + if ext.def.Load == nil { + continue + } + loaded, err := ext.def.Load(ctx, pact.AdminFormLoad{DB: tx, Controller: controllerID(cc), Record: target, ID: recordIDText(target)}) + if err != nil { + moduleLog.Error("cabana: form extension load failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err) + return &lifecycleError{ControllerID: controllerID(cc)} + } + for _, name := range ext.fields { + value, ok := loaded[name] + if !ok || !contextAllows(cc, name, op) { + continue + } + data[name] = value + } + } + return nil +} + +// recordIDText is the record's primary key as text, "" when it has none. +func recordIDText(model any) string { + v := reflect.ValueOf(model) + for v.Kind() == reflect.Pointer { + if v.IsNil() { + return "" + } + v = v.Elem() + } + if !v.IsValid() { + return "" + } + field := fieldByColumn(v, primaryColumn(model)) + if !field.IsValid() || !field.CanInterface() { + return "" + } + value := derefValue(field.Interface()) + if value == nil { + return "" + } + return fmt.Sprint(value) +} diff --git a/modules/cabana/form_extensions_postgres_test.go b/modules/cabana/form_extensions_postgres_test.go new file mode 100644 index 00000000..47b2e588 --- /dev/null +++ b/modules/cabana/form_extensions_postgres_test.go @@ -0,0 +1,340 @@ +package cabana + +import ( + "bytes" + "context" + "encoding/json" + "io/fs" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "gorm.io/gorm" +) + +// The D-91 form extension save, show and schema paths on a real Postgres +// database (testcontainers, like newListService): an owner plugin acme.blog +// with the posts controller, and a contributor acme.extra that adds fields +// and stores their values in its own table. + +// fePost is the owner's model: columns a and b only. +type fePost struct { + ID uint `gorm:"column:id;primaryKey"` + A string `gorm:"column:a"` + B string `gorm:"column:b"` +} + +func (fePost) TableName() string { return "acme_blog_posts" } +func (*fePost) Fillable() []string { return []string{"a", "b"} } +func (*fePost) Rules() map[string]string { return map[string]string{} } + +// feExtraValue is the contributor's own table: one value per post. +type feExtraValue struct { + RecordID uint `gorm:"column:record_id;primaryKey;autoIncrement:false"` + Value string `gorm:"column:value"` +} + +func (feExtraValue) TableName() string { return "acme_extra_values" } + +// fePosts is the owner's admin controller. +type fePosts struct{} + +func (fePosts) ID() string { return "acme.blog.posts" } +func (fePosts) ModelName() string { return "Post" } +func (fePosts) ConfigDir() string { return "controllers/posts" } +func (fePosts) NewRecord() any { return &fePost{} } +func (fePosts) RequiredPermissions() []string { return []string{"acme.blog.access"} } + +// feOwner is the owner plugin; fields is the body of its fields.yaml. +type feOwner struct { + fields string +} + +func (feOwner) ID() string { return "acme.blog" } +func (feOwner) Requires() []string { return nil } +func (feOwner) Register(*backpack.App) error { return nil } +func (feOwner) Boot(*backpack.App) error { return nil } +func (p feOwner) AdminFS() fs.FS { return feOwnerFS(p.fields) } +func (feOwner) AdminControllers() []pact.AdminController { + return []pact.AdminController{fePosts{}} +} + +// feOwnerFields is the owner form of the tracer: a and b, no tabs. +const feOwnerFields = "fields:\n a:\n type: text\n label: A\n b:\n type: text\n label: B\n" + +func feOwnerFS(fields string) fstest.MapFS { + return fstest.MapFS{ + "controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/acme/blog/models/post/fields.yaml\nmodelClass: Post\n")}, + "controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/blog/models/post/columns.yaml\nmodelClass: Post\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")}, + "models/post/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n a:\n label: A\n")}, + "models/post/fields.yaml": &fstest.MapFile{Data: []byte(fields)}, + } +} + +// feContributor is a plugin that extends other plugins' forms. +type feContributor struct { + id string + requires []string + fsys fstest.MapFS + types []pact.FormFieldType + extensions []pact.AdminActionExtension +} + +func (p feContributor) ID() string { return p.id } +func (p feContributor) Requires() []string { return p.requires } +func (feContributor) Register(*backpack.App) error { return nil } +func (feContributor) Boot(*backpack.App) error { return nil } +func (p feContributor) AdminFS() fs.FS { return p.fsys } +func (p feContributor) FormFieldTypes() []pact.FormFieldType { return p.types } +func (p feContributor) AdminActionExtensions() []pact.AdminActionExtension { + return p.extensions +} + +var ( + _ party.Plugin = feContributor{} + _ pact.HasAdminActionExtensions = feContributor{} +) + +// feCompile runs the boot path: field types, the registry, then the form +// extensions, as Activate does. +func feCompile(plugins ...party.Plugin) (*Registry, error) { + reg, err := ftCompile(plugins...) + if err != nil { + return nil, err + } + if err := compileFormExtensions(reg, plugins); err != nil { + return nil, err + } + return reg, nil +} + +// feStore is the contributor's storage spy: every Save call's input, the +// owner row as Save read it through the save transaction, and failures to +// return. +type feStore struct { + mu sync.Mutex + saves []pact.AdminFormSave + ownerA []string + fail error + notices map[string][]pact.FormFieldNotice +} + +func (s *feStore) save(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.saves = append(s.saves, in) + var a string + if err := in.DB.Raw("SELECT a FROM acme_blog_posts WHERE id = ?", in.ID).Scan(&a).Error; err != nil { + return pact.AdminFormSaveResult{}, err + } + s.ownerA = append(s.ownerA, a) + if value, ok := in.Values["c"]; ok { + text, _ := value.(string) + id, _ := strconv.ParseUint(in.ID, 10, 64) + row := feExtraValue{RecordID: uint(id), Value: text} + if err := in.DB.Save(&row).Error; err != nil { + return pact.AdminFormSaveResult{}, err + } + } + if s.fail != nil { + return pact.AdminFormSaveResult{}, s.fail + } + return pact.AdminFormSaveResult{Notices: s.notices}, nil +} + +func (s *feStore) load(_ context.Context, in pact.AdminFormLoad) (map[string]any, error) { + var row feExtraValue + err := in.DB.Where("record_id = ?", in.ID).Limit(1).Find(&row).Error + if err != nil { + return nil, err + } + return map[string]any{"c": row.Value, "a": "never served"}, nil +} + +// feExtraFS is the contributor's tree with one fields document. +func feExtraFS(doc string) fstest.MapFS { + return fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte(doc)}} +} + +// feCall runs one admin request against a controller of svc. +func feCall(t *testing.T, svc *service, principal *bouncer.Principal, controller, method, route, id string, body any) *httptest.ResponseRecorder { + t.Helper() + var raw []byte + if body != nil { + var err error + if raw, err = json.Marshal(body); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(method, "/", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + parts := strings.SplitN(controller, ".", 3) + req.SetPathValue("vendor", parts[0]) + req.SetPathValue("plugin", parts[1]) + req.SetPathValue("controller", parts[2]) + if id != "" { + req.SetPathValue("id", id) + } + req = req.WithContext(bouncer.WithUser(req.Context(), principal)) + rec := httptest.NewRecorder() + switch route { + case "schema": + svc.formSchema(rec, req) + case "record": + switch method { + case http.MethodPost: + svc.create(rec, req) + case http.MethodGet: + svc.show(rec, req) + case http.MethodPut: + svc.update(rec, req) + default: + t.Fatalf("method %s", method) + } + default: + t.Fatalf("route %s", route) + } + return rec +} + +// feSchemaNames is the field name list of a form schema response. +func feSchemaNames(t *testing.T, rec *httptest.ResponseRecorder) []string { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("schema status=%d body=%s", rec.Code, rec.Body.String()) + } + var out struct { + Data struct { + Fields []struct { + Name string `json:"name"` + Tab string `json:"tab"` + } `json:"fields"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(out.Data.Fields)) + for _, field := range out.Data.Fields { + names = append(names, field.Name) + } + return names +} + +// feEnv is a Postgres-backed admin service with the owner and contributor. +type feEnv struct { + db *gorm.DB + svc *service +} + +func newFEEnv(t *testing.T, plugins ...party.Plugin) feEnv { + t.Helper() + _, db := newListService(t) + if err := db.Migrator().DropTable(&fePost{}, &feExtraValue{}); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&fePost{}, &feExtraValue{}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Migrator().DropTable(&fePost{}, &feExtraValue{}) }) + reg, err := feCompile(plugins...) + if err != nil { + t.Fatalf("compile: %v", err) + } + app := backpack.New(nil) + if err := app.Publish(db); err != nil { + t.Fatal(err) + } + return feEnv{db: db, svc: &service{app: app, reg: reg}} +} + +func (e feEnv) post(t *testing.T, id uint) fePost { + t.Helper() + var out fePost + if err := e.db.First(&out, id).Error; err != nil { + t.Fatal(err) + } + return out +} + +func (e feEnv) extra(t *testing.T, id uint) (string, bool) { + t.Helper() + var rows []feExtraValue + if err := e.db.Where("record_id = ?", id).Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) == 0 { + return "", false + } + return rows[0].Value, true +} + +// TestFormExtensionTracer (SC-1, D-91): plugin acme.extra adds field c after +// a on acme.blog's form of a and b. The schema lists a, c, b; an update +// stores a and b in the owner row and c in the contributor's table in the +// same transaction; show serves c; the owner model never gains a c column. +func TestFormExtensionTracer(t *testing.T) { + store := &feStore{} + extra := feContributor{ + id: "acme.extra", + fsys: feExtraFS("fields:\n c:\n type: text\n label: C\n after: a\n"), + extensions: []pact.AdminActionExtension{{ + Controller: "acme.blog.posts", + Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: store.load, Save: store.save}, + }}, + } + env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra) + + names := feSchemaNames(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "schema", "", nil)) + if strings.Join(names, " ") != "a c b" { + t.Fatalf("schema fields = %v, want [a c b]", names) + } + + seed := fePost{A: "A0", B: "B0"} + if err := env.db.Create(&seed).Error; err != nil { + t.Fatal(err) + } + idText := strconv.FormatUint(uint64(seed.ID), 10) + rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A1", "b": "B1", "c": "C1"}) + if rec.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String()) + } + saved := pgFTDecode(t, rec) + if saved.Data["c"] != "C1" || saved.Data["a"] != "A1" { + t.Fatalf("update data=%v, want a=A1 and c=C1 served by Load", saved.Data) + } + if got := env.post(t, seed.ID); got.A != "A1" || got.B != "B1" { + t.Fatalf("owner row=%+v", got) + } + if value, ok := env.extra(t, seed.ID); !ok || value != "C1" { + t.Fatalf("contributor row=%q,%v", value, ok) + } + if len(store.saves) != 1 || store.saves[0].Created || store.saves[0].ID != idText || store.saves[0].Controller != "acme.blog.posts" { + t.Fatalf("saves=%+v", store.saves) + } + if len(store.saves[0].Values) != 1 || store.saves[0].Values["c"] != "C1" { + t.Fatalf("Save values=%v, want only c", store.saves[0].Values) + } + if store.ownerA[0] != "A1" { + t.Fatalf("Save read owner a=%q through its DB, want the uncommitted A1 of the same transaction", store.ownerA[0]) + } + + rec = feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "record", idText, nil) + if rec.Code != http.StatusOK { + t.Fatalf("show status=%d body=%s", rec.Code, rec.Body.String()) + } + if shown := pgFTDecode(t, rec); shown.Data["c"] != "C1" || shown.Data["a"] != "A1" { + t.Fatalf("show data=%v", shown.Data) + } + if env.db.Migrator().HasColumn(&fePost{}, "c") { + t.Fatal("the owner table gained a c column") + } +} diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 4b9905e9..17c3b922 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -114,6 +114,12 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if err != nil { return nil, err } + // D-91: fields and tabs other plugins add to a controller's form are + // placed once every form is compiled, before any later pass reads the + // forms. + if err := compileFormExtensions(reg, plugins); err != nil { + return nil, err + } uploadBytes, err := configBytes(app, "http.body_limits.upload_bytes") if err != nil { return nil, err diff --git a/modules/pact/README.md b/modules/pact/README.md index bd42139d..9a625a5a 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -31,7 +31,7 @@ Use cases: - MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list. - 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.AdminPartialData` (the curated view model a partial template renders). +- 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. - 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. @@ -145,8 +145,14 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.AdminRecordActionInput` | What a record action receives: `RecordID` and `Record`, the record loaded and row-locked through the form scope, and `Value`, the trimmed prompt answer (empty without a `Prompt`). | | `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. | | `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. | -| `pact.AdminActionExtension` | Record actions a plugin adds to one admin controller by its `Controller` ID; offered on the target's preview screen after the owner's actions, each checked against the target's `RequiredPermissions` and its own `Permissions`. A contribution to `summercms.cabana.users` is refused with `user.outranked` when the target administrator outranks the caller. | -| `pact.HasAdminActionExtensions` | Implemented by a plugin (not a controller) that adds actions to admin controllers, its own or another plugin's. An unknown controller ID, a target without a preview block or a duplicate name stops the boot. | +| `pact.AdminActionExtension` | What a plugin adds to one admin controller by its `Controller` ID: `RecordActions`, offered on the target's preview screen after the owner's actions, each checked against the target's `RequiredPermissions` and its own `Permissions` (a contribution to `summercms.cabana.users` is refused with `user.outranked` when the target administrator outranks the caller), and `Form`, an optional `pact.AdminFormExtension`. | +| `pact.HasAdminActionExtensions` | Implemented by a plugin (not a controller) that adds actions or form fields to admin controllers, its own or another plugin's. An unknown controller ID, a target without a preview block (record actions) or a form (form fields), or a duplicate name stops the boot. | +| `pact.AdminFormExtension` | Fields and tabs a plugin places in another controller's form, loaded and saved by the plugin itself: a fields document in the `fields.yaml` format (`Fields`, a path in the plugin's `AdminFS`, or `FieldsYAML`, the bytes built at boot) whose fields take `before:`/`after:` anchors and `zone:` and whose `tabs:` list places new tabs; `Hide` (owner fields to leave out), `Permissions` and `Visible` (who sees the fields), `Dir`, `PartialData` and `Actions` (its partial and widget fields), `Load` (values on show) and `Save` (values in the record's save transaction). The owner's model is never written. | +| `pact.AdminFormLoad` | What `Load` receives: the read or save transaction `DB`, the target `Controller` ID, the loaded `Record` and its `ID` as text. | +| `pact.AdminFormSave` | What `Save` receives: the save transaction `DB`, `Controller`, the written `Record`, its `ID`, `Created` (true on create) and `Values`, the submitted values of the extension's fields (a translatable field as a `map[string]string` of locale to text). | +| `pact.AdminFormSaveResult` | What `Save` returns: `Notices`, save notices per field name (`pact.FormFieldNotice`), shown like a field type's notices. | +| `pact.AdminFormErrors` | A `Save` refusal: field name to messages (phrase keys or text). The save answers 422 with them on the fields and rolls back. | +| `pact.AdminFormZoneTabs` | The `zone:` value of the form's tab zone, the default and the only zone rendered so far; `pact.AdminFormZoneOutside` and `pact.AdminFormZoneSecondary` are reserved and stop the boot ("is not supported yet"). | | `pact.HasAccountContributions` | Implemented by a plugin that adds sections to the admin's My account page. | | `pact.AccountContribution` | One My account section: `Name` (an identifier unique across plugins), `Label`, `Description`, an optional pure-read `Status` func for the signed-in administrator and its `Actions`. A non-identifier or taken name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot. | | `pact.AccountAction` | One button of a section: name, label, optional `Confirm` text, `Danger`, extra `Permissions`, an optional pure-read `Applies` rule and the Go `Run` function, which acts for the signed-in administrator only. | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index da5ad487..1e620f95 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -7,6 +7,7 @@ import ( "io/fs" "net/http" "regexp" + "sort" "strings" "time" @@ -424,11 +425,150 @@ type HasAdminRecordActions interface { // plugin registers admin controllers). An unknown controller ID, a target // without a preview block, a name the owner or an earlier extension already // registers on that controller, a reserved name (create, delete), a name that -// is not an identifier, a missing Run or an empty Label stops the boot. Only -// record actions are carried for now; the struct leaves room for more kinds. +// is not an identifier, a missing Run or an empty Label stops the boot. +// Record actions and form contributions (Form) are carried; the struct +// leaves room for more kinds. type AdminActionExtension struct { Controller string RecordActions []AdminRecordAction + // Form adds fields and tabs to the target controller's form and loads + // and saves their values; nil adds none. See AdminFormExtension. + Form *AdminFormExtension +} + +// Form extension zones: the values a field or tab of an AdminFormExtension +// fields document may give as zone:. AdminFormZoneTabs is the form's tab +// zone, the default for an empty zone and the only zone the admin renders +// for now; any other zone stops the boot ("zone ... is not supported yet"). +const ( + AdminFormZoneTabs = "tabs" + AdminFormZoneOutside = "outside" + AdminFormZoneSecondary = "secondary" +) + +// AdminFormExtension adds fields and tabs to an admin controller's form that +// the contributing plugin owns, places them between the owner's fields and +// tabs, and loads and saves their values itself. The owner's model, columns, +// translation rows and attachments are never written: extension values reach +// neither Fill nor the owner's bindings, only Save. +// +// The fields and tabs are a fields document in the fields.yaml format: a +// fields: mapping whose entries take every fields.yaml key plus before:, +// after: (the stable name of an anchor field) and zone:, and an optional +// top-level tabs: list whose entries take name: (the tab key), before: and +// after: (an anchor tab key) and zone:. A tab key is the raw tab: value, as +// owner fields carry it; default names the tab of fields without a tab:. Fields +// is the document's path in the contributing plugin's AdminFS (resolved like +// SettingsItem.Form, so $/vendor/plugin/... and ~/plugins/vendor/plugin/... +// work); FieldsYAML is the document itself, for fields the plugin builds at +// boot. Set at most one of them; an extension with neither adds no field and +// may still hide owner fields and run Load and Save. +// +// Placement: a field goes Before or After its anchor field, or to the end of +// its tab without one; a field without tab: takes its anchor's tab. A new tab +// goes Before or After its anchor tab, or after the last tab without one; +// before: default is refused. A form an extension lays out is regrouped by +// tab in first-appearance order with the default tab first, as the admin +// renders it; a form no extension lays out is unchanged. Extensions apply in +// plugin activation order, and several 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(). +// +// Hide lists owner fields the form leaves out for everyone: they leave the +// schema, show and save, and a tab without fields disappears. Permissions +// are backend permission codes an administrator needs to see the extension's +// fields; Visible, when set, is asked per request as well. An administrator +// who fails either gets none of the fields in the schema or the record, and a +// save drops their posted keys and does not call Save. +// +// Dir is the directory in the contributing plugin's AdminFS that holds the +// _.htm templates of the document's partial fields; PartialData renders +// their view models (the same rules as pact.AdminPartialData). Actions are +// the widget actions of the document's widget fields. +// +// Load returns the extension's values for a record on show and after a +// save, keyed by field name; keys that are not its fields are dropped. Save +// runs inside the record's save transaction after the owner row is written, +// on create and update; Values holds the submitted values of its fields. A +// translatable field (mltext, mltextarea, mlmarkdown or a translatable +// plugin field type) carries a map[string]string of locale to text in Load +// and Save; a plugin field type's Decode runs before Save and its Load after +// Load. A Save error rolls the whole save back: AdminFormErrors answers 422 +// with its messages on the fields, any other error the opaque 500. +// +// The admin compiles extensions at boot. An unknown controller, a target +// without a form, an unknown field or tab anchor, a duplicate field name +// (against the owner or an earlier extension), a duplicate tab key, two tab +// keys whose labels translate to the same text, an anchor cycle, an anchor on +// another plugin's extension outside Requires(), before: default, a zone other +// than the tab zone, a Hide of an unknown field or of a field another field +// references (fill, dependsOn, trigger, preset), a fileupload field (an +// extension owns no attachments; a plugin field type with Upload stores files +// instead), a widget action or partial name the owner already uses, and a +// field document error each stop the boot with the plugin, the controller and +// the offending name. +type AdminFormExtension struct { + Fields string + FieldsYAML []byte + Hide []string + Permissions []string + Visible func(ctx context.Context) bool `json:"-"` + Dir string + Actions []AdminAction + PartialData func(ctx context.Context, name string, record any) (any, error) `json:"-"` + Load func(ctx context.Context, in AdminFormLoad) (map[string]any, error) `json:"-"` + Save func(ctx context.Context, in AdminFormSave) (AdminFormSaveResult, error) `json:"-"` +} + +// AdminFormLoad is what the framework hands AdminFormExtension.Load. DB is +// the read transaction (show) or the save transaction (after Save). Controller +// is the target controller ID, Record the loaded record and ID its primary key +// as text. +type AdminFormLoad struct { + DB *gorm.DB + Controller string + Record any + ID string +} + +// AdminFormSave is what the framework hands AdminFormExtension.Save. DB is the +// record's save transaction (cabana.TxFromContext reads it too); the owner row +// is already written, so Record carries its primary key, also given as ID. +// Created is true on create. Values are the submitted values of the +// extension's fields whose context allows the operation, keyed by field name; +// a field the request did not send is absent. +type AdminFormSave struct { + DB *gorm.DB + Controller string + Record any + ID string + Created bool + Values map[string]any +} + +// AdminFormSaveResult is Save's answer. Notices are non-blocking messages per +// field name, shown as save notices; a notice may name an owner field. +type AdminFormSaveResult struct { + Notices map[string][]FormFieldNotice +} + +// AdminFormErrors is a save refusal from AdminFormExtension.Save: field name +// to messages (phrase keys or text). The framework answers 422 with the +// messages on the fields and rolls the save back. +type AdminFormErrors map[string][]string + +// Error lists the fields in name order with their messages joined by "; ". +func (e AdminFormErrors) Error() string { + names := make([]string, 0, len(e)) + for name := range e { + names = append(names, name) + } + sort.Strings(names) + parts := make([]string, 0, len(names)) + for _, name := range names { + parts = append(parts, name+": "+strings.Join(e[name], "; ")) + } + return strings.Join(parts, "; ") } // HasAdminActionExtensions is implemented by plugins (not admin controllers)