diff --git a/docs/backend/admin-controllers.md b/docs/backend/admin-controllers.md index bc0c342b..6018555f 100644 --- a/docs/backend/admin-controllers.md +++ b/docs/backend/admin-controllers.md @@ -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. diff --git a/modules/cabana/form_extensions.go b/modules/cabana/form_extensions.go index 7ea5677b..71a5a101 100644 --- a/modules/cabana/form_extensions.go +++ b/modules/cabana/form_extensions.go @@ -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 { diff --git a/modules/cabana/form_extensions_smoke_test.go b/modules/cabana/form_extensions_smoke_test.go index beffe5df..0ad6b0b1 100644 --- a/modules/cabana/form_extensions_smoke_test.go +++ b/modules/cabana/form_extensions_smoke_test.go @@ -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) + } + }) +} diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index 34c1ff80..4f0fede0 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -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