- admin-controllers: placement, values, Load/Save, permissions, widgets, partials and boot errors, with the ExtrasPlugin snippet - Example_formExtension compiles an owner form with the extension - coming-from-wintercms maps extendFormFields, addFields, addTabFields and removeField to the form extension - cabana README feature bullet and Activate row
548 lines
28 KiB
Go
548 lines
28 KiB
Go
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"`
|
|
// Summary, Body and Meta are the columns of Example_formExtension.
|
|
Summary string `gorm:"column:summary"`
|
|
Body string `gorm:"column:body"`
|
|
Meta string `gorm:"column:meta"`
|
|
}
|
|
|
|
// 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("<p>{{ .Data.title }}</p>\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("<p>{{ .Data.title }}</p>\n")},
|
|
"partials/_status.htm": &fstest.MapFile{Data: []byte("<p>{{ .Data.title }}</p>\n")},
|
|
"partials/_leak.htm": &fstest.MapFile{Data: []byte("<p>leak</p>\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)
|
|
}
|
|
})
|
|
}
|