feat(32-01): form extension placement rules, boot errors and owner-aware branches
- placement: tabs before/after tab anchors, anchor cursor for plugin order, own-field anchors sorted with cycle detection, Requires() rule, anchor tab inheritance, Hide with reference checks, zone refusal (D-101) - boot errors name plugin, controller and offending name; fileupload is refused (D-100); localized tab label collisions checked in Activate - extension widgets dispatch to AdminFormExtension.Actions, partials render from the contributor's Dir through PartialData, plugin field types decode for Save and load after Load, translatable values reach Save as locale maps and never the owner's translations - Permissions and Visible apply to schema, show, save, widgets and partials; AdminFormErrors answer 422, Save notices join meta.notices
This commit is contained in:
@@ -40,7 +40,19 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
return
|
||||
}
|
||||
action, ok := cc.Actions[field.Action]
|
||||
// D-91: a form extension's widget runs one of the extension's
|
||||
// Actions, and exists only for an administrator who sees the
|
||||
// extension (its Permissions and Visible).
|
||||
actions := cc.Actions
|
||||
if ext := cc.extensionOf(field.Name); ext != nil {
|
||||
principal, _ := bouncer.User(r.Context())
|
||||
if !extensionVisible(r.Context(), principal, ext) {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
return
|
||||
}
|
||||
actions = ext.actions
|
||||
}
|
||||
action, ok := actions[field.Action]
|
||||
if !ok {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
return
|
||||
|
||||
@@ -262,6 +262,9 @@ type CompiledController struct {
|
||||
// extension.
|
||||
formExtensions []*compiledFormExtension
|
||||
extensionFields map[string]*compiledFormExtension
|
||||
// extensionPartials maps the name of every partial a form extension's
|
||||
// partial field renders to its extension.
|
||||
extensionPartials map[string]*compiledFormExtension
|
||||
}
|
||||
|
||||
// Registry is the immutable controller map keyed by controller ID.
|
||||
|
||||
@@ -538,7 +538,8 @@ 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 {
|
||||
visible := visibleExtensions(ctx, cc, principalOf(ctx))
|
||||
if err := hydrateFormExtensions(withTx(ctx, tx), extensionHydrate{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Op: "update", Visible: visible}, result.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
|
||||
@@ -756,8 +757,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
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)
|
||||
// (Fill, bindings, translations, attachments) ever reads them; the keys
|
||||
// of an extension this administrator does not see are dropped.
|
||||
visible := visibleExtensions(ctx, cc, principalOf(ctx))
|
||||
extensions, err := liftExtensionValues(ctx, extensionLift{CC: cc, Op: op, Visible: visible, Writer: s.writer, Tr: s.tr, DB: s.DB}, in.Body)
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
// 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)
|
||||
@@ -938,7 +944,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
}
|
||||
// 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)
|
||||
extNotices, err := saveFormExtensions(ctx, extensionSave{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: extensions, Visible: visible, Created: !update})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -953,7 +959,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
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)
|
||||
return hydrateFormExtensions(ctx, extensionHydrate{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Op: op, Visible: visible}, result.Data)
|
||||
})
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
|
||||
@@ -71,7 +71,9 @@ func liftMLValues(ctx context.Context, cc *CompiledController, body map[string]a
|
||||
}
|
||||
var names []string
|
||||
for _, field := range cc.Form.Fields {
|
||||
if !isMLField(field) || !contextAllows(cc, field.Name, op) {
|
||||
// A form extension's translatable field is lifted for its own Save
|
||||
// (liftExtensionValues), never into the owner's translations.
|
||||
if !isMLField(field) || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
|
||||
continue
|
||||
}
|
||||
if _, present := body[field.Name]; present {
|
||||
@@ -193,7 +195,8 @@ func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, w
|
||||
}
|
||||
var names []string
|
||||
for _, field := range cc.Form.Fields {
|
||||
if !isMLField(field) || !contextAllows(cc, field.Name, op) {
|
||||
// A form extension serves its own values (hydrateFormExtensions).
|
||||
if !isMLField(field) || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
|
||||
continue
|
||||
}
|
||||
names = append(names, field.Name)
|
||||
|
||||
@@ -384,7 +384,8 @@ func liftPluginValues(cc *CompiledController, body map[string]any, op string) ma
|
||||
}
|
||||
for _, field := range cc.Form.Fields {
|
||||
ft := field.fieldType
|
||||
if ft == nil || ft.def.Translatable || !contextAllows(cc, field.Name, op) {
|
||||
// A form extension's plugin field is decoded for its own Save.
|
||||
if ft == nil || ft.def.Translatable || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
|
||||
continue
|
||||
}
|
||||
value, present := body[field.Name]
|
||||
@@ -451,7 +452,9 @@ func decodePluginFields(ctx context.Context, in pluginDecodeInput) (pluginDecode
|
||||
}
|
||||
for _, field := range cc.Form.Fields {
|
||||
ft := field.fieldType
|
||||
if ft == nil {
|
||||
// A form extension's plugin field never reaches the host column: its
|
||||
// value is decoded for the extension's Save (saveFormExtensions).
|
||||
if ft == nil || cc.extensionOf(field.Name) != nil {
|
||||
continue
|
||||
}
|
||||
if !ft.def.Translatable {
|
||||
@@ -701,7 +704,8 @@ func hydratePluginFields(ctx context.Context, tx *gorm.DB, cc *CompiledControlle
|
||||
defaultKnown := false
|
||||
for _, field := range cc.Form.Fields {
|
||||
ft := field.fieldType
|
||||
if ft == nil || ft.def.Load == nil || !contextAllows(cc, field.Name, op) {
|
||||
// A form extension's plugin field is served by hydrateFormExtensions.
|
||||
if ft == nil || ft.def.Load == nil || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
|
||||
continue
|
||||
}
|
||||
stored, ok := data[field.Name]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,7 @@ func (p feOwnerPlugin) AdminFS() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/" + dir + "/models/post/fields.yaml\nmodelClass: Post\n")},
|
||||
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/" + dir + "/models/post/columns.yaml\nmodelClass: Post\nrecordsPerPage: 20\n")},
|
||||
"controllers/posts/_summary.htm": &fstest.MapFile{Data: []byte("<p>{{ .title }}</p>\n")},
|
||||
"controllers/posts/_summary.htm": &fstest.MapFile{Data: []byte("<p>{{ .Data.title }}</p>\n")},
|
||||
"models/post/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n a:\n label: A\n")},
|
||||
"models/post/fields.yaml": &fstest.MapFile{Data: []byte(p.fields)},
|
||||
"assets/js/owner.js": &fstest.MapFile{Data: []byte("// owner\n")},
|
||||
@@ -429,8 +429,8 @@ func feExtPartialPlugin(doc string) feContributor {
|
||||
}
|
||||
})
|
||||
ext.fsys = fstest.MapFS{
|
||||
"partials/_summary.htm": &fstest.MapFile{Data: []byte("<p>{{ .title }}</p>\n")},
|
||||
"partials/_status.htm": &fstest.MapFile{Data: []byte("<p>{{ .title }}</p>\n")},
|
||||
"partials/_summary.htm": &fstest.MapFile{Data: []byte("<p>{{ .Data.title }}</p>\n")},
|
||||
"partials/_status.htm": &fstest.MapFile{Data: []byte("<p>{{ .Data.title }}</p>\n")},
|
||||
"partials/_leak.htm": &fstest.MapFile{Data: []byte("<p>leak</p>\n")},
|
||||
}
|
||||
return ext
|
||||
|
||||
@@ -149,6 +149,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err := compileContributions(reg, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateFormExtensionPermissions(reg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
screens.reg = reg
|
||||
if err := compileTokenScopes(reg, plugins); err != nil {
|
||||
return nil, err
|
||||
@@ -163,6 +166,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err := validateTokenScopeMessageKeys(reg, tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkFormExtensionTabLabels(reg, tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
guards, ok := app.Lookup[*bouncer.Registry]()
|
||||
if !ok || guards == nil {
|
||||
@@ -813,13 +819,24 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
// localized view must never share its backing array with the cache.
|
||||
principal, _ := bouncer.User(r.Context())
|
||||
hidden := hiddenSchemaFields(r.Context(), cc)
|
||||
// D-91: an extension this administrator does not see (its
|
||||
// Permissions or Visible) contributes no field.
|
||||
visible := visibleExtensions(r.Context(), cc, principal)
|
||||
kept := make([]FormField, 0, len(view.Fields))
|
||||
for _, field := range view.Fields {
|
||||
if hidden[field.Name] {
|
||||
continue
|
||||
}
|
||||
ext := cc.extensionOf(field.Name)
|
||||
if ext != nil && !visible[ext] {
|
||||
continue
|
||||
}
|
||||
if field.Type == "widget" {
|
||||
action, ok := cc.Actions[field.Action]
|
||||
actions := cc.Actions
|
||||
if ext != nil {
|
||||
actions = ext.actions
|
||||
}
|
||||
action, ok := actions[field.Action]
|
||||
if !ok || !Allows(principal, action.Permissions) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
@@ -500,8 +501,23 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
name := r.PathValue("name")
|
||||
compiled, ok := cc.partials[name]
|
||||
provider, isProvider := cc.Controller.(pact.AdminPartialData)
|
||||
if !ok || !isProvider || provider == nil {
|
||||
var data func(ctx context.Context, name string, record any) (any, error)
|
||||
if provider, isProvider := cc.Controller.(pact.AdminPartialData); isProvider && provider != nil {
|
||||
data = provider.PartialData
|
||||
}
|
||||
formPartial := cc.formPartials[name]
|
||||
// D-91: a form extension's partial renders from the contributing
|
||||
// plugin's template through its PartialData, only for an
|
||||
// administrator who sees the extension.
|
||||
if ext := cc.extensionPartials[name]; ext != nil {
|
||||
principal, _ := bouncer.User(r.Context())
|
||||
if !extensionVisible(r.Context(), principal, ext) {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
return
|
||||
}
|
||||
compiled, ok, data, formPartial = ext.partials[name], true, ext.def.PartialData, true
|
||||
}
|
||||
if !ok || data == nil {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
return
|
||||
}
|
||||
@@ -509,7 +525,7 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
if query.Has("id") {
|
||||
id, err := strconv.ParseUint(query.Get("id"), 10, 64)
|
||||
if err != nil || id == 0 || !cc.formPartials[name] {
|
||||
if err != nil || id == 0 || !formPartial {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
return
|
||||
}
|
||||
@@ -530,7 +546,7 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) {
|
||||
moduleLog.Error("cabana: admin partial failed", "controller", controllerID(cc), "partial", name, "reason", reason, "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
}
|
||||
vm, err := provider.PartialData(ctx, name, record)
|
||||
vm, err := data(ctx, name, record)
|
||||
if err != nil {
|
||||
fail("view model", err)
|
||||
return
|
||||
|
||||
@@ -464,6 +464,12 @@ const (
|
||||
// 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.
|
||||
//
|
||||
// An extension adds value fields (text, textarea, number, checkbox, switch,
|
||||
// dropdown with listed options, markdown, mltext, mltextarea, mlmarkdown and
|
||||
// plugin field types), widget fields and partial fields; types that bind to
|
||||
// the owner's model (fileupload, relation, repeater and the like) are
|
||||
// refused, and an extension with value fields needs 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;
|
||||
@@ -535,8 +541,12 @@ type AdminFormLoad struct {
|
||||
// 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.
|
||||
// extension's value fields whose context allows the operation, keyed by field
|
||||
// name; a field the request did not send is absent. A value is as decoded from
|
||||
// the JSON body (numbers as json.Number), a translatable field's a
|
||||
// map[string]string of locale to text, a plugin field type's the value its
|
||||
// Decode returned. Save runs on every create and update of a record whose
|
||||
// administrator sees the extension, with or without values.
|
||||
type AdminFormSave struct {
|
||||
DB *gorm.DB
|
||||
Controller string
|
||||
|
||||
Reference in New Issue
Block a user