fix(32): WR-07 close the relation and partial routes of hidden owner fields

Hide removed an owner field from the schema, show and save, but a hidden
relation-manager field's relation routes (panel, link, unlink, child CRUD,
deferred commit) and a hidden partial field's partial stayed reachable.
dropHiddenFields now drops the relation and the partial path too; a partial
the list or preview header or another field still uses keeps rendering.
This commit is contained in:
2026-10-27 15:39:32 +01:00
parent 1583651783
commit 0e187cfb6a
4 changed files with 117 additions and 5 deletions

View File

@@ -158,6 +158,7 @@ func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []
layout = true
}
}
ownerFields := cc.Form.Fields
form := newFormLayout(cc.Form.Fields, requires)
for i, c := range contributions {
ext := &compiledFormExtension{pluginID: c.plugin.ID(), def: c.def, own: map[string]FormField{}}
@@ -193,7 +194,7 @@ func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []
}
}
}
dropHiddenFields(cc, form.hidden)
dropHiddenFields(cc, ownerFields, form.hidden)
return nil
}
@@ -620,11 +621,29 @@ func checkFieldReferences(fields []FormField, field FormField) error {
}
// dropHiddenFields removes the fields extensions hide from the owner's
// bindings, so show and save leave them out too.
func dropHiddenFields(cc *CompiledController, hidden map[string]string) {
// bindings, so show and save leave them out too. A hidden relation-manager
// field takes its relation (panel, link, unlink, child and deferred routes)
// with it, and a hidden partial field its path from the form partials, so
// its endpoints answer 404 like the field's absence from the schema. owner
// is the owner's field list before the extensions laid it out.
func dropHiddenFields(cc *CompiledController, owner []FormField, hidden map[string]string) {
if len(hidden) == 0 {
return
}
// The maps are copied: the owner's compiled maps may be shared.
if len(cc.Relations) > 0 {
relations := make(map[string]*CompiledRelation, len(cc.Relations))
for name, cr := range cc.Relations {
if cr != nil {
if _, gone := hidden[cr.fieldName]; gone {
continue
}
}
relations[name] = cr
}
cc.Relations = relations
}
dropHiddenPartials(cc, owner, hidden)
writable := make([]WritableField, 0, len(cc.Writable))
for _, field := range cc.Writable {
if _, gone := hidden[field.Name]; !gone {
@@ -640,6 +659,51 @@ func dropHiddenFields(cc *CompiledController, hidden map[string]string) {
}
}
// dropHiddenPartials takes the paths of hidden partial fields out of the
// form partials, and out of the partials when nothing else renders them (a
// field still on the form, the list or the preview header partial).
func dropHiddenPartials(cc *CompiledController, owner []FormField, hidden map[string]string) {
gone := map[string]bool{}
for _, field := range owner {
if _, ok := hidden[field.Name]; ok && field.Type == "partial" {
gone[field.Path] = true
}
}
if len(gone) == 0 {
return
}
kept := map[string]bool{}
if cc.Form != nil {
for _, field := range cc.Form.Fields {
if field.Type == "partial" && cc.extensionOf(field.Name) == nil {
kept[field.Path] = true
}
}
}
header := map[string]bool{}
if cc.List != nil && cc.List.HeaderPartial != "" {
header[cc.List.HeaderPartial] = true
}
if cc.Form != nil && cc.Form.preview != nil && cc.Form.preview.HeaderPartial != "" {
kept[cc.Form.preview.HeaderPartial] = true
}
formPartials := make(map[string]bool, len(cc.formPartials))
for name, ok := range cc.formPartials {
if gone[name] && !kept[name] {
continue
}
formPartials[name] = ok
}
partials := make(map[string]*compiledPartial, len(cc.partials))
for name, compiled := range cc.partials {
if gone[name] && !kept[name] && !header[name] {
continue
}
partials[name] = compiled
}
cc.formPartials, cc.partials = formPartials, partials
}
// 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 {

View File

@@ -545,3 +545,49 @@ func TestFormExtensionWidgetsAndPartials(t *testing.T) {
}
})
}
// TestFormExtensionHideEndpoints (WR-07): a hidden relation-manager field
// takes its relation routes with it, and a hidden partial field its partial,
// while the owner's form without Hide keeps both.
func TestFormExtensionHideEndpoints(t *testing.T) {
t.Run("relation-manager", func(t *testing.T) {
hide := feExt("acme.extra", nil, "acme.events.pages", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"notes"} })
reg, err := feCompile(rePlugin{}, hide)
if err != nil {
t.Fatalf("compile: %v", err)
}
cc, _ := reg.Get("acme.events.pages")
if _, err := relationOf(cc, "notes"); err == nil {
t.Fatal("the hidden relation-manager field's relation is still routed")
}
plain, err := feCompile(rePlugin{})
if err != nil {
t.Fatal(err)
}
cc, _ = plain.Get("acme.events.pages")
if _, err := relationOf(cc, "notes"); err != nil {
t.Fatalf("the owner's relation without Hide: %v", err)
}
})
t.Run("partial", func(t *testing.T) {
owner := feOwnerPlugin{id: "acme.blog", fields: feFields("a") + " summary:\n type: partial\n path: summary\n"}
render := func(plugins ...party.Plugin) int {
t.Helper()
reg, err := feCompile(plugins...)
if err != nil {
t.Fatalf("compile: %v", err)
}
rec := httptest.NewRecorder()
(&service{reg: reg}).partial(rec, feRequest(superUser(), http.MethodGet, "/", nil, map[string]string{"name": "summary"}))
return rec.Code
}
if code := render(owner); code != http.StatusOK {
t.Fatalf("owner partial status=%d", code)
}
hide := feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"summary"} })
if code := render(owner, hide); code != http.StatusNotFound {
t.Fatalf("hidden partial status=%d, want 404", code)
}
})
}

View File

@@ -481,7 +481,9 @@ const (
// 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
// schema, show and save, a hidden relation-manager field's relation routes
// and a hidden partial field's record render answer 404, 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