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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user