diff --git a/modules/cabana/form_extensions.go b/modules/cabana/form_extensions.go index 159051cd..b6cccefc 100644 --- a/modules/cabana/form_extensions.go +++ b/modules/cabana/form_extensions.go @@ -10,6 +10,7 @@ import ( "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" "github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/parser" "gorm.io/gorm" @@ -606,3 +607,9 @@ func recordIDText(model any) string { } return fmt.Sprint(value) } + +// checkFormExtensionTabLabels refuses two tab keys of an extended form whose +// labels translate to the same text in a locale the phrasebook loads. +func checkFormExtensionTabLabels(reg *Registry, tr *phrasebook.Translator) error { + return nil +} diff --git a/modules/cabana/form_extensions_postgres_test.go b/modules/cabana/form_extensions_postgres_test.go index 47b2e588..fc90a74e 100644 --- a/modules/cabana/form_extensions_postgres_test.go +++ b/modules/cabana/form_extensions_postgres_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io/fs" "net/http" "net/http/httptest" @@ -338,3 +339,197 @@ func TestFormExtensionTracer(t *testing.T) { t.Fatal("the owner table gained a c column") } } + +// feMLType is a translatable field type of acme.extra: Decode trims and +// reports the trim per locale. +func feMLType() pact.FormFieldType { + return pact.FormFieldType{ + Name: "extraml", Element: "acme-extra-ml", Translatable: true, Scripts: []string{"assets/js/extra.js"}, + Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + text, _ := in.Value.(string) + if text == "bad" { + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"bad in " + in.Locale}} + } + out := pact.FormFieldDecoded{Value: strings.TrimSpace(text)} + if out.Value != text { + out.Notices = []pact.FormFieldNotice{{Message: "trimmed " + in.Locale}} + } + return out, nil + }, + Load: func(_ context.Context, in pact.FormFieldValue) (any, error) { + text, _ := in.Value.(string) + return "served:" + in.Locale + ":" + text, nil + }, + } +} + +// TestFormExtensionSave (D-91, T-32-01, T-32-02): Save errors roll the whole +// save back, AdminFormErrors answer 422 field lines, notices reach the save +// response, an invisible extension is inert on save, translatable values +// reach Save as locale maps without touching the owner's translations, and a +// hidden owner field leaves show and save. +func TestFormExtensionSave(t *testing.T) { + store := &feStore{} + var mlSaves []map[string]any + extra := feContributor{ + id: "acme.extra", + fsys: fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte("fields:\n c:\n type: text\n after: a\n")}, "assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")}}, + types: []pact.FormFieldType{feMLType()}, + extensions: []pact.AdminActionExtension{ + {Controller: "acme.blog.posts", Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: store.load, Save: store.save, Hide: []string{"b"}}}, + {Controller: "acme.blog.posts", Form: &pact.AdminFormExtension{ + FieldsYAML: []byte("tabs:\n - name: acme.extra::lang.tab\nfields:\n t:\n type: mltext\n tab: acme.extra::lang.tab\n n:\n type: extraml\n tab: acme.extra::lang.tab\n"), + Permissions: []string{"acme.extra.translate"}, + Load: func(context.Context, pact.AdminFormLoad) (map[string]any, error) { + return map[string]any{"t": map[string]string{"pl": "TP", "en": "TE"}, "n": map[string]string{"pl": "NP", "en": "NE"}}, nil + }, + Save: func(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { + mlSaves = append(mlSaves, in.Values) + return pact.AdminFormSaveResult{}, nil + }, + }}, + }, + } + env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra) + writer := ftWriter() + if err := env.svc.app.Publish[TranslationWriter](writer); err != nil { + t.Fatal(err) + } + seed := fePost{A: "A0", B: "B0"} + if err := env.db.Create(&seed).Error; err != nil { + t.Fatal(err) + } + id := strconv.FormatUint(uint64(seed.ID), 10) + put := func(principal *bouncer.Principal, body map[string]any) *httptest.ResponseRecorder { + t.Helper() + return feCall(t, env.svc, principal, "acme.blog.posts", http.MethodPut, "record", id, body) + } + + t.Run("AdminFormErrors answer 422 and roll back", func(t *testing.T) { + store.fail = pact.AdminFormErrors{"c": {"The c field is required."}} + defer func() { store.fail = nil }() + rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"}) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The c field is required.") { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if got := env.post(t, seed.ID); got.A != "A0" { + t.Fatalf("owner row changed: %+v", got) + } + if value, ok := env.extra(t, seed.ID); ok { + t.Fatalf("contributor row kept after rollback: %q", value) + } + }) + + t.Run("a plain Save error answers 500 and rolls back", func(t *testing.T) { + store.fail = errors.New("disk full") + defer func() { store.fail = nil }() + rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"}) + if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "disk full") { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if got := env.post(t, seed.ID); got.A != "A0" { + t.Fatalf("owner row changed: %+v", got) + } + }) + + t.Run("notices reach the save response, on owner fields too", func(t *testing.T) { + store.notices = map[string][]pact.FormFieldNotice{"a": {{Message: "address changed"}}} + defer func() { store.notices = nil }() + rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"}) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + notices := pgFTNotices(t, pgFTDecode(t, rec)) + if len(notices) != 1 || notices[0].Field != "a" || notices[0].Message != "address changed" { + t.Fatalf("notices = %+v", notices) + } + }) + + t.Run("a hidden owner field leaves show and save", func(t *testing.T) { + rec := put(superUser(), map[string]any{"a": "A2", "b": "B9", "c": "C2"}) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if got := env.post(t, seed.ID); got.A != "A2" || got.B != "B0" { + t.Fatalf("owner row=%+v, want a written and hidden b untouched", got) + } + shown := pgFTDecode(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "record", id, nil)) + if _, ok := shown.Data["b"]; ok { + t.Fatalf("show serves hidden b: %v", shown.Data) + } + names := feSchemaNames(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "schema", "", nil)) + if feJoin(names) != "a c t n" { + t.Fatalf("schema = %v", names) + } + }) + + t.Run("translatable values reach Save as locale maps", func(t *testing.T) { + mlSaves = nil + rec := put(superUser(), map[string]any{"a": "A3", "t": map[string]any{"pl": "P", "en": "E"}, "n": map[string]any{"pl": " X ", "en": "Y"}}) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if len(mlSaves) != 1 { + t.Fatalf("saves = %v", mlSaves) + } + tv, _ := mlSaves[0]["t"].(map[string]string) + nv, _ := mlSaves[0]["n"].(map[string]string) + if tv["pl"] != "P" || tv["en"] != "E" || nv["pl"] != "X" || nv["en"] != "Y" { + t.Fatalf("Save values = %#v", mlSaves[0]) + } + if len(writer.attrs) != 0 { + t.Fatalf("the owner's translations were written: %v", writer.attrs) + } + body := pgFTDecode(t, rec) + notices := pgFTNotices(t, body) + if len(notices) != 1 || notices[0].Field != "n" || notices[0].Locale != "pl" { + t.Fatalf("notices = %+v", notices) + } + served, _ := body.Data["n"].(map[string]any) + if served["pl"] != "served:pl:NP" || served["en"] != "served:en:NE" { + t.Fatalf("n served = %v, want the type's Load after the extension's", body.Data["n"]) + } + refused := put(superUser(), map[string]any{"a": "A3", "n": map[string]any{"pl": "bad"}}) + if refused.Code != http.StatusUnprocessableEntity || !strings.Contains(refused.Body.String(), "bad in pl") { + t.Fatalf("refused status=%d body=%s", refused.Code, refused.Body.String()) + } + }) + + t.Run("an invisible extension is inert on save", func(t *testing.T) { + editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}} + mlSaves = nil + rec := put(editor, map[string]any{"a": "A4", "t": map[string]any{"pl": "Z"}, "c": "C4"}) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if len(mlSaves) != 0 { + t.Fatalf("Save of the invisible extension ran: %v", mlSaves) + } + if value, _ := env.extra(t, seed.ID); value != "C4" { + t.Fatalf("the visible extension did not save: %q", value) + } + data := pgFTDecode(t, rec).Data + if _, ok := data["t"]; ok { + t.Fatalf("save response serves the invisible extension's field: %v", data) + } + shown := pgFTDecode(t, feCall(t, env.svc, editor, "acme.blog.posts", http.MethodGet, "record", id, nil)) + if _, ok := shown.Data["n"]; ok { + t.Fatalf("show serves the invisible extension's field: %v", shown.Data) + } + if names := feSchemaNames(t, feCall(t, env.svc, editor, "acme.blog.posts", http.MethodGet, "schema", "", nil)); feJoin(names) != "a c" { + t.Fatalf("schema = %v", names) + } + }) + + t.Run("create hands Save the new record", func(t *testing.T) { + before := len(store.saves) + rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPost, "record", "", map[string]any{"a": "N", "c": "NC"}) + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + last := store.saves[len(store.saves)-1] + if len(store.saves) != before+1 || !last.Created || last.ID == "" || last.ID == id { + t.Fatalf("create Save = %+v", last) + } + }) +} diff --git a/modules/cabana/form_extensions_smoke_test.go b/modules/cabana/form_extensions_smoke_test.go new file mode 100644 index 00000000..860aea94 --- /dev/null +++ b/modules/cabana/form_extensions_smoke_test.go @@ -0,0 +1,543 @@ +package cabana + +import ( + "bytes" + "context" + "encoding/json" + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" +) + +// Pure compile smoke tests of D-91 form extensions (no database): placement, +// boot errors, plugin order and the unextended-form invariant. The +// exhaustive unit matrix is plan 32-06. + +// feWide is the smoke owners' model: a column for every owner field name +// the smoke tests use. +type feWide struct { + ID uint `gorm:"column:id;primaryKey"` + A string `gorm:"column:a"` + B string `gorm:"column:b"` + X string `gorm:"column:x"` + X2 string `gorm:"column:x2"` + Y string `gorm:"column:y"` + Z string `gorm:"column:z"` + On bool `gorm:"column:on"` + Title string `gorm:"column:title"` + Slug string `gorm:"column:slug"` +} + +// feCtl is a configurable owner controller over the feWide model. +type feCtl struct { + id string + actions []pact.AdminAction +} + +func (c feCtl) ID() string { return c.id } +func (feCtl) ModelName() string { return "Post" } +func (feCtl) ConfigDir() string { return "controllers/posts" } +func (feCtl) NewRecord() any { return &feWide{} } +func (feCtl) RequiredPermissions() []string { return nil } +func (c feCtl) AdminActions() []pact.AdminAction { return c.actions } +func (feCtl) AdminJS() []string { return []string{"assets/js/owner.js"} } +func (feCtl) AdminCSS() []string { return nil } +func (feCtl) PartialData(context.Context, string, any) (any, error) { + return map[string]any{"title": "owner"}, nil +} + +// feOwnerPlugin owns one controller (id.posts unless ctl is set) whose +// fields.yaml is fields. +type feOwnerPlugin struct { + id, ctl, fields string + actions []pact.AdminAction +} + +func (p feOwnerPlugin) ID() string { return p.id } +func (feOwnerPlugin) Requires() []string { return nil } +func (feOwnerPlugin) Register(*backpack.App) error { return nil } +func (feOwnerPlugin) Boot(*backpack.App) error { return nil } +func (p feOwnerPlugin) AdminFS() fs.FS { + dir := strings.ReplaceAll(p.id, ".", "/") + 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("
{{ .title }}
\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")}, + } +} +func (p feOwnerPlugin) AdminControllers() []pact.AdminController { + ctl := p.ctl + if ctl == "" { + ctl = p.id + ".posts" + } + return []pact.AdminController{feCtl{id: ctl, actions: p.actions}} +} + +// feFields builds a fields.yaml from name/tab pairs of text fields +// ("a", "b:T1" for field b in tab T1). +func feFields(specs ...string) string { + var out strings.Builder + out.WriteString("fields:\n") + for _, spec := range specs { + name, tab, _ := strings.Cut(spec, ":") + out.WriteString(" " + name + ":\n type: text\n label: " + strings.ToUpper(name) + "\n") + if tab != "" { + out.WriteString(" tab: " + tab + "\n") + } + } + return out.String() +} + +// feExt is a contributor with one extension of controller ctl built from +// the fields document doc; mods adjust the extension. +func feExt(id string, requires []string, ctl, doc string, mods ...func(*pact.AdminFormExtension)) feContributor { + ext := &pact.AdminFormExtension{FieldsYAML: []byte(doc), Save: feNoopSave} + for _, mod := range mods { + mod(ext) + } + return feContributor{id: id, requires: requires, fsys: fstest.MapFS{}, extensions: []pact.AdminActionExtension{{Controller: ctl, Form: ext}}} +} + +func feNoopSave(context.Context, pact.AdminFormSave) (pact.AdminFormSaveResult, error) { + return pact.AdminFormSaveResult{}, nil +} + +// feBlog is the default owner: acme.blog.posts with fields a and b. +func feBlog(specs ...string) feOwnerPlugin { + if len(specs) == 0 { + specs = []string{"a", "b"} + } + return feOwnerPlugin{id: "acme.blog", fields: feFields(specs...)} +} + +// feLayout compiles plugins and returns the field names and the tab keys +// (first appearance, "" for the default tab) of controller ctl. +func feLayout(t *testing.T, ctl string, plugins ...party.Plugin) ([]string, []string) { + t.Helper() + reg, err := feCompile(plugins...) + if err != nil { + t.Fatalf("compile: %v", err) + } + return feLayoutOf(t, reg, ctl) +} + +func feLayoutOf(t *testing.T, reg *Registry, ctl string) ([]string, []string) { + t.Helper() + cc, ok := reg.Get(ctl) + if !ok || cc.Form == nil { + t.Fatalf("controller %s has no form", ctl) + } + var names, tabs []string + seen := map[string]bool{} + for _, field := range cc.Form.Fields { + names = append(names, field.Name) + if !seen[field.Tab] { + seen[field.Tab] = true + tabs = append(tabs, field.Tab) + } + } + return names, tabs +} + +func feJoin(items []string) string { + out := make([]string, len(items)) + for i, item := range items { + if item == "" { + item = "default" + } + out[i] = item + } + return strings.Join(out, " ") +} + +func TestFormExtensionPlacement(t *testing.T) { + cases := []struct { + name string + owner feOwnerPlugin + doc string + hide []string + wantNames string + wantTabs string + }{ + {"C after A gives A C B", feBlog(), "fields:\n c:\n type: text\n after: a\n", nil, "a c b", "default"}, + {"C before the first field", feBlog(), "fields:\n c:\n type: text\n before: a\n", nil, "c a b", "default"}, + {"no anchor goes to the end of its tab", feBlog(), "fields:\n c:\n type: text\n", nil, "a b c", "default"}, + {"no anchor in a tab goes to the end of that tab", feBlog("a", "x:T1", "y:T1", "z:T2"), "fields:\n c:\n type: text\n tab: T1\n", nil, "a x y c z", "default T1 T2"}, + {"an anchor without tab: takes the anchor's tab", feBlog("a", "x:T1", "y:T1"), "fields:\n c:\n type: text\n after: x\n", nil, "a x c y", "default T1"}, + {"a field anchored on its own extension's field", feBlog(), "fields:\n d:\n type: text\n after: c\n c:\n type: text\n after: a\n", nil, "a c d b", "default"}, + {"two fields after one anchor keep document order", feBlog(), "fields:\n c:\n type: text\n after: a\n d:\n type: text\n after: a\n", nil, "a c d b", "default"}, + {"a new tab between two tabs", feBlog("a", "x:T1", "z:T3"), "tabs:\n - name: T2\n after: T1\n before: T3\nfields:\n d:\n type: text\n tab: T2\n", nil, "a x d z", "default T1 T2 T3"}, + {"a new tab before a tab", feBlog("a", "x:T1", "z:T3"), "tabs:\n - name: T2\n before: T3\nfields:\n d:\n type: text\n tab: T2\n", nil, "a x d z", "default T1 T2 T3"}, + {"a new tab without anchor goes last", feBlog("a", "x:T1"), "tabs:\n - name: T9\nfields:\n d:\n type: text\n tab: T9\n", nil, "a x d", "default T1 T9"}, + {"a tab-less form gains the default tab then the new tab", feBlog(), "tabs:\n - name: N\n after: default\nfields:\n n:\n type: text\n tab: N\n", nil, "a b n", "default N"}, + {"interleaved owner tabs are grouped by first appearance", feBlog("x:T1", "y:T2", "x2:T1", "a"), "fields:\n c:\n type: text\n tab: T2\n", nil, "a x x2 y c", "default T1 T2"}, + {"hide removes an owner field", feBlog(), "fields:\n c:\n type: text\n after: a\n", []string{"b"}, "a c", "default"}, + {"a tab that loses all its fields disappears", feBlog("a", "x:T1", "z:T3"), "", []string{"z"}, "a x", "default T1"}, + {"zone tabs is the default zone", feBlog(), "fields:\n c:\n type: text\n after: a\n zone: tabs\n", nil, "a c b", "default"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ext := feExt("acme.extra", nil, "acme.blog.posts", tc.doc, func(e *pact.AdminFormExtension) { e.Hide = tc.hide }) + names, tabs := feLayout(t, "acme.blog.posts", tc.owner, ext) + if feJoin(names) != tc.wantNames || feJoin(tabs) != tc.wantTabs { + t.Fatalf("fields = %s, tabs = %s; want fields %s, tabs %s", feJoin(names), feJoin(tabs), tc.wantNames, tc.wantTabs) + } + }) + } + + t.Run("span keeps its value next to inserted fields", func(t *testing.T) { + ext := feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n span: left\n after: a\n") + reg, err := feCompile(feBlog(), ext) + if err != nil { + t.Fatal(err) + } + cc, _ := reg.Get("acme.blog.posts") + if cc.Form.Fields[1].Name != "c" || cc.Form.Fields[1].Span != "left" { + t.Fatalf("fields = %+v", cc.Form.Fields) + } + }) + + t.Run("a hidden field leaves the owner's bindings", func(t *testing.T) { + ext := feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"b"} }) + reg, err := feCompile(feBlog(), ext) + if err != nil { + t.Fatal(err) + } + cc, _ := reg.Get("acme.blog.posts") + for _, field := range cc.Writable { + if field.Name == "b" { + t.Fatalf("hidden b is still writable: %+v", cc.Writable) + } + } + }) + + t.Run("a Save-only extension is valid and leaves the layout alone", func(t *testing.T) { + owner := feBlog("x:T1", "a") + ext := feExt("acme.extra", nil, "acme.blog.posts", "") + names, _ := feLayout(t, "acme.blog.posts", owner, ext) + if feJoin(names) != "x a" { + t.Fatalf("fields = %s, want the owner's order x a", feJoin(names)) + } + }) +} + +func TestFormExtensionTwoPluginsSameAnchor(t *testing.T) { + one := feExt("acme.one", nil, "acme.blog.posts", "fields:\n c1:\n type: text\n after: a\n") + two := feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n c2:\n type: text\n after: a\n") + for range 3 { + names, _ := feLayout(t, "acme.blog.posts", feBlog(), one, two) + if feJoin(names) != "a c1 c2 b" { + t.Fatalf("fields = %s, want a c1 c2 b (plugin order kept)", feJoin(names)) + } + } + before1 := feExt("acme.one", nil, "acme.blog.posts", "fields:\n c1:\n type: text\n before: b\n") + before2 := feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n c2:\n type: text\n before: b\n") + if names, _ := feLayout(t, "acme.blog.posts", feBlog(), before1, before2); feJoin(names) != "a c1 c2 b" { + t.Fatalf("before: fields = %s, want a c1 c2 b", feJoin(names)) + } + onOther := feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n c2:\n type: text\n after: c1\n") + if names, _ := feLayout(t, "acme.blog.posts", feBlog(), one, onOther); feJoin(names) != "a c1 c2 b" { + t.Fatalf("anchored on a required plugin's field: fields = %s", feJoin(names)) + } + notRequired := feExt("acme.two", nil, "acme.blog.posts", "fields:\n c2:\n type: text\n after: c1\n") + _, err := feCompile(feBlog(), one, notRequired) + if err == nil || !strings.Contains(err.Error(), "acme.two") || !strings.Contains(err.Error(), "acme.one") || !strings.Contains(err.Error(), "Requires") { + t.Fatalf("err = %v, want the Requires() rule naming acme.two and acme.one", err) + } +} + +func TestFormExtensionOnePluginTwoControllers(t *testing.T) { + news := feOwnerPlugin{id: "acme.news", fields: feFields("x:T1", "y")} + both := feContributor{id: "acme.extra", fsys: fstest.MapFS{}, extensions: []pact.AdminActionExtension{ + {Controller: "acme.blog.posts", Form: &pact.AdminFormExtension{FieldsYAML: []byte("fields:\n c:\n type: text\n after: a\n"), Save: feNoopSave}}, + {Controller: "acme.news.posts", Form: &pact.AdminFormExtension{FieldsYAML: []byte("fields:\n c:\n type: text\n before: x\n"), Save: feNoopSave}}, + }} + other := feExt("acme.more", nil, "acme.blog.posts", "fields:\n m:\n type: text\n before: b\n") + for range 3 { + reg, err := feCompile(feBlog(), news, both, other) + if err != nil { + t.Fatal(err) + } + blog, _ := feLayoutOf(t, reg, "acme.blog.posts") + newsNames, newsTabs := feLayoutOf(t, reg, "acme.news.posts") + if feJoin(blog) != "a c m b" || feJoin(newsNames) != "y c x" || feJoin(newsTabs) != "default T1" { + t.Fatalf("blog = %s, news = %s (%s)", feJoin(blog), feJoin(newsNames), feJoin(newsTabs)) + } + } +} + +func TestFormExtensionUnextendedSchemaUnchanged(t *testing.T) { + // Interleaved tabs: the form would change if it were regrouped. + news := feOwnerPlugin{id: "acme.news", fields: feFields("x:T1", "y:T2", "x2:T1", "a")} + blog := feBlog("x:T1", "a", "y:T1") + canonical := func(plugins ...party.Plugin) (string, string) { + t.Helper() + reg, err := feCompile(plugins...) + if err != nil { + t.Fatal(err) + } + out := make([]string, 0, 2) + for _, id := range []string{"acme.news.posts", "acme.blog.posts"} { + cc, _ := reg.Get(id) + raw, err := cc.Form.CanonicalJSON() + if err != nil { + t.Fatal(err) + } + out = append(out, string(raw)) + } + return out[0], out[1] + } + newsBefore, blogBefore := canonical(news, blog) + extended := feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n after: a\n") + newsAfter, _ := canonical(news, blog, extended) + if newsBefore != newsAfter { + t.Fatalf("a form no extension targets changed:\n%s\n%s", newsBefore, newsAfter) + } + saveOnly := feExt("acme.extra", nil, "acme.blog.posts", "") + _, blogSaveOnly := canonical(news, blog, saveOnly) + if blogBefore != blogSaveOnly { + t.Fatalf("a Save-only extension changed the form:\n%s\n%s", blogBefore, blogSaveOnly) + } +} + +// feTabTranslator has two tab keys that read the same in pl only. +func feTabTranslator(t *testing.T) *phrasebook.Translator { + t.Helper() + cat := phrasebook.NewCatalog() + if err := cat.Load("acme.extra", fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("tabs:\n meta: Meta\n details: Details\n")}, + "lang/pl/lang.yaml": {Data: []byte("tabs:\n meta: Szczegóły\n details: Szczegóły\n")}, + }); err != nil { + t.Fatal(err) + } + return phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) +} + +func TestFormExtensionBootErrors(t *testing.T) { + widgetDoc := "fields:\n w:\n type: widget\n widget: acme-extra-preview\n action: preview\n" + partialDoc := "fields:\n p:\n type: partial\n path: summary\n" + ownerWithPartial := feOwnerPlugin{id: "acme.blog", fields: feFields("a", "b") + " s:\n type: partial\n path: summary\n"} + ownerWithAction := feOwnerPlugin{id: "acme.blog", fields: feFields("a", "b"), actions: []pact.AdminAction{{Name: "preview", Label: "Preview", Run: feActionRun}}} + cases := []struct { + name string + plugins []party.Plugin + want []string + }{ + {"unknown controller", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.nope", "")}, []string{"acme.extra", "acme.blog.nope"}}, + {"unknown field anchor", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n after: zz\n")}, []string{"acme.extra", "acme.blog.posts", "zz", "unknown anchor"}}, + {"unknown tab anchor", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: T2\n after: T7\n")}, []string{"acme.extra", "acme.blog.posts", "T7", "unknown"}}, + {"unknown tab of a field", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n tab: T7\n")}, []string{"acme.extra", "acme.blog.posts", "T7"}}, + {"duplicate field name against the owner", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n b:\n type: text\n")}, []string{"acme.extra", "acme.blog.posts", "field b", "duplicate"}}, + {"duplicate field name against an earlier extension", []party.Plugin{feBlog(), feExt("acme.one", nil, "acme.blog.posts", "fields:\n c:\n type: text\n"), feExt("acme.two", nil, "acme.blog.posts", "fields:\n c:\n type: text\n")}, []string{"acme.two", "acme.one", "acme.blog.posts", "field c", "duplicate"}}, + {"duplicate tab key against the owner", []party.Plugin{feBlog("a", "x:T1"), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: T1\n")}, []string{"acme.extra", "acme.blog.posts", "T1", "duplicate"}}, + {"duplicate tab key against an earlier extension", []party.Plugin{feBlog(), feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\n"), feExt("acme.two", nil, "acme.blog.posts", "tabs:\n - name: N\n")}, []string{"acme.two", "acme.blog.posts", "N", "duplicate"}}, + {"an anchor cycle", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n after: d\n d:\n type: text\n after: c\n")}, []string{"acme.extra", "acme.blog.posts", "cycle", "c", "d"}}, + {"an anchor outside Requires()", []party.Plugin{feBlog(), feExt("acme.one", nil, "acme.blog.posts", "fields:\n c1:\n type: text\n"), feExt("acme.two", nil, "acme.blog.posts", "fields:\n c2:\n type: text\n before: c1\n")}, []string{"acme.two", "acme.blog.posts", "c1", "Requires"}}, + {"before the default tab", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: N\n before: default\n")}, []string{"acme.extra", "acme.blog.posts", "N", "default"}}, + {"zone outside", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n zone: outside\n")}, []string{"acme.extra", "acme.blog.posts", "field c", `zone "outside" is not supported yet`}}, + {"zone secondary on a tab", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: N\n zone: secondary\n")}, []string{"acme.extra", "acme.blog.posts", "N", `zone "secondary" is not supported yet`}}, + {"hide of an unknown field", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"zz"} })}, []string{"acme.extra", "acme.blog.posts", "zz", "Hide"}}, + {"hide of a field a trigger references", []party.Plugin{feOwnerPlugin{id: "acme.blog", fields: "fields:\n on:\n type: switch\n a:\n type: text\n trigger:\n action: hide\n field: on\n condition: checked\n"}, feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"on"} })}, []string{"acme.extra", "acme.blog.posts", "on", "field a"}}, + {"hide of a field a preset references", []party.Plugin{feOwnerPlugin{id: "acme.blog", fields: "fields:\n title:\n type: text\n slug:\n type: text\n preset:\n field: title\n type: slug\n"}, feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"title"} })}, []string{"acme.extra", "acme.blog.posts", "title", "field slug"}}, + {"a fileupload field", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n img:\n type: fileupload\n mode: image\n")}, []string{"acme.extra", "acme.blog.posts", "field img", "fileupload", "Upload"}}, + {"a widget action the owner registers", []party.Plugin{ownerWithAction, feExtWidgetPlugin(widgetDoc)}, []string{"acme.extra", "acme.blog.posts", "preview", "owner"}}, + {"a partial name the owner uses", []party.Plugin{ownerWithPartial, feExtPartialPlugin(partialDoc)}, []string{"acme.extra", "acme.blog.posts", "summary", "owner"}}, + {"fields with values and no Save", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n", func(e *pact.AdminFormExtension) { e.Save = nil })}, []string{"acme.extra", "acme.blog.posts", "Save"}}, + {"both Fields and FieldsYAML", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields: {}\n", func(e *pact.AdminFormExtension) { e.Fields = "x.yaml" })}, []string{"acme.extra", "acme.blog.posts", "FieldsYAML"}}, + {"an unknown document key", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "secondaryTabs: {}\n")}, []string{"acme.extra", "acme.blog.posts", "secondaryTabs"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := feCompile(tc.plugins...) + if err == nil { + t.Fatalf("boot succeeded, want an error naming %v", tc.want) + } + for _, want := range tc.want { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err = %v, want it to name %q", err, want) + } + } + }) + } + + t.Run("two tab keys whose labels translate to the same text", func(t *testing.T) { + ext := feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: acme.extra::lang.tabs.meta\n - name: acme.extra::lang.tabs.details\nfields:\n m:\n type: text\n tab: acme.extra::lang.tabs.meta\n d:\n type: text\n tab: acme.extra::lang.tabs.details\n") + reg, err := feCompile(feBlog(), ext) + if err != nil { + t.Fatal(err) + } + err = checkFormExtensionTabLabels(reg, feTabTranslator(t)) + for _, want := range []string{"acme.extra", "acme.blog.posts", "acme.extra::lang.tabs.details", "pl"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("err = %v, want it to name %q", err, want) + } + } + }) +} + +func feActionRun(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) { + return pact.AdminActionResult{Message: "ran"}, nil +} + +// feWidgetType is a field type of acme.extra whose script defines the +// extension's widget element. +func feWidgetType() pact.FormFieldType { + return pact.FormFieldType{ + Name: "extranote", Element: "acme-extra-note", Scripts: []string{"assets/js/extra.js"}, + Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + return pact.FormFieldDecoded{Value: in.Value}, nil + }, + } +} + +func feExtWidgetPlugin(doc string) feContributor { + ext := feExt("acme.extra", nil, "acme.blog.posts", doc, func(e *pact.AdminFormExtension) { + e.Actions = []pact.AdminAction{{Name: "preview", Label: "acme.extra::lang.preview", Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { + return pact.AdminActionResult{Message: "previewed", Fill: map[string]any{"title": in.Values["title"]}}, nil + }}} + }) + ext.fsys = fstest.MapFS{"assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")}} + ext.types = []pact.FormFieldType{feWidgetType()} + return ext +} + +// feGORMView is a GORM model the partial guard must refuse. +type feGORMView struct { + ID uint `gorm:"primaryKey"` +} + +func feExtPartialPlugin(doc string) feContributor { + ext := feExt("acme.extra", nil, "acme.blog.posts", doc, func(e *pact.AdminFormExtension) { + e.Dir = "partials" + e.PartialData = func(_ context.Context, name string, _ any) (any, error) { + if name == "leak" { + return &feGORMView{ID: 1}, nil + } + return map[string]any{"title": "from " + name}, nil + } + }) + ext.fsys = fstest.MapFS{ + "partials/_summary.htm": &fstest.MapFile{Data: []byte("{{ .title }}
\n")}, + "partials/_status.htm": &fstest.MapFile{Data: []byte("{{ .title }}
\n")}, + "partials/_leak.htm": &fstest.MapFile{Data: []byte("leak
\n")}, + } + return ext +} + +// feRequest is an admin request to controller acme.blog.posts. +func feRequest(principal *bouncer.Principal, method, target string, body any, values map[string]string) *http.Request { + var raw []byte + if body != nil { + raw, _ = json.Marshal(body) + } + req := httptest.NewRequest(method, target, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + for name, value := range map[string]string{"vendor": "acme", "plugin": "blog", "controller": "posts"} { + req.SetPathValue(name, value) + } + for name, value := range values { + req.SetPathValue(name, value) + } + return req.WithContext(bouncer.WithUser(req.Context(), principal)) +} + +func TestFormExtensionWidgetsAndPartials(t *testing.T) { + doc := "fields:\n title:\n type: text\n after: a\n preview:\n type: widget\n widget: acme-extra-preview\n action: preview\n fill: [title]\n after: title\n status:\n type: partial\n path: status\n leak:\n type: partial\n path: leak\n" + widget := feExtWidgetPlugin(doc) + partials := feExtPartialPlugin("") + ext := widget + ext.fsys = fstest.MapFS{} + for name, file := range widget.fsys { + ext.fsys[name] = file + } + for name, file := range partials.fsys { + ext.fsys[name] = file + } + ext.extensions[0].Form.Dir = "partials" + ext.extensions[0].Form.PartialData = partials.extensions[0].Form.PartialData + reg, err := feCompile(feBlog(), ext) + if err != nil { + t.Fatalf("compile: %v", err) + } + svc := &service{reg: reg} + + rec := httptest.NewRecorder() + svc.formSchema(rec, feRequest(superUser(), http.MethodGet, "/", nil, nil)) + if names := feSchemaNames(t, rec); feJoin(names) != "a title preview b status leak" { + t.Fatalf("schema = %v", names) + } + if !strings.Contains(rec.Body.String(), `"actionLabel":"acme.extra::lang.preview"`) { + t.Fatalf("the widget carries no action label: %s", rec.Body.String()) + } + + rec = httptest.NewRecorder() + svc.widgetAction(rec, feRequest(superUser(), http.MethodPost, "/", map[string]any{"values": map[string]any{"title": "T", "a": "x"}}, map[string]string{"field": "preview"})) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"previewed"`) || !strings.Contains(rec.Body.String(), `"title":"T"`) { + t.Fatalf("widget action status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + svc.partial(rec, feRequest(superUser(), http.MethodGet, "/", nil, map[string]string{"name": "status"})) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "from status") { + t.Fatalf("partial status=%d body=%s", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + svc.partial(rec, feRequest(superUser(), http.MethodGet, "/", nil, map[string]string{"name": "leak"})) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("a GORM view model rendered: status=%d body=%s", rec.Code, rec.Body.String()) + } + + t.Run("an invisible extension offers no field, widget or partial", func(t *testing.T) { + hidden := ext + form := *ext.extensions[0].Form + form.Permissions = []string{"acme.extra.manage"} + hidden.extensions = []pact.AdminActionExtension{{Controller: "acme.blog.posts", Form: &form}} + reg, err := feCompile(feBlog(), hidden) + if err != nil { + t.Fatal(err) + } + svc := &service{reg: reg} + editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}} + rec := httptest.NewRecorder() + svc.formSchema(rec, feRequest(editor, http.MethodGet, "/", nil, nil)) + if names := feSchemaNames(t, rec); feJoin(names) != "a b" { + t.Fatalf("schema without the permission = %v", names) + } + rec = httptest.NewRecorder() + svc.widgetAction(rec, feRequest(editor, http.MethodPost, "/", map[string]any{}, map[string]string{"field": "preview"})) + if rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden { + t.Fatalf("widget without the permission status=%d", rec.Code) + } + rec = httptest.NewRecorder() + svc.partial(rec, feRequest(editor, http.MethodGet, "/", nil, map[string]string{"name": "status"})) + if rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden { + t.Fatalf("partial without the permission status=%d", rec.Code) + } + }) + + t.Run("a widget tag outside the contributor's prefix fails boot", func(t *testing.T) { + bad := feExtWidgetPlugin("fields:\n w:\n type: widget\n widget: acme-blog-preview\n action: preview\n") + if _, err := feCompile(feBlog(), bad); err == nil || !strings.Contains(err.Error(), "acme-extra-") { + t.Fatalf("err = %v, want the contributor's prefix", err) + } + }) + + t.Run("a widget filling an owner field fails boot", func(t *testing.T) { + bad := feExtWidgetPlugin("fields:\n w:\n type: widget\n widget: acme-extra-preview\n action: preview\n fill: [a]\n") + if _, err := feCompile(feBlog(), bad); err == nil || !strings.Contains(err.Error(), "fill a") { + t.Fatalf("err = %v, want the fill refusal", err) + } + }) +}