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

@@ -600,7 +600,7 @@ Every field and tab of a compiled form has a stable name: the field key, and the
- A new tab is declared under `tabs:` and goes `after:` or `before:` an anchor tab, or after the last tab without one. `after: default` is allowed; `before: default` stops the boot, because the admin always renders the default tab first. A form without tabs that gains one renders its own fields under the default tab ("Basic"), then the new tab.
- `zone:` takes `tabs` (the default) only for now. `outside` and `secondary` (`pact.AdminFormZoneOutside`, `pact.AdminFormZoneSecondary`) are reserved and stop the boot with "is not supported yet".
- Extensions apply in plugin activation order (`Requires()` first). Several extensions anchored at the same point keep that order. An anchor may name a field or tab another plugin's extension added only when that plugin is in the contributor's `Requires()`; the fields of one document may anchor on each other in any order.
- `Hide` lists owner fields to leave out of the form for every administrator: they leave the schema, the record and the save, and a tab that loses all its fields disappears. The owner's model must not require a hidden field.
- `Hide` lists owner fields to leave out of the form for every administrator: they leave the schema, the record and the save, a hidden `relation-manager` field's relation routes (panel, link, unlink, child records) and a hidden `partial` field's partial answer 404 (a partial the list or preview header or another field still uses keeps rendering there), and a tab that loses all its fields disappears. The owner's model must not require a hidden field.
- cabana compiles the order once at boot and sends the fields in that order. A form an extension lays out is regrouped by tab in first-appearance order, the default tab first, exactly as the admin renders tabs; a form no extension lays out (including one that only gets a `Save` hook) is compiled byte for byte as before. The admin renders what it receives and adds no ordering of its own, and `span` (left, right, full) keeps working next to inserted fields.
- Only top-level fields and tabs are placed; fields inside repeaters stay the owner's.

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