feat(17-01): menu item type registry and dependent dropdown binding
- pact: HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve,
ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider,
DependentOptionSet; Option gains Depth (omitempty)
- signpost (new module): boot-time registry with fail-loud key and pattern
checks, layered URL patterns incl. PatternFrom for list types, host
overrides at menu_items.patterns, Resolve and ReferenceOptions
- party: signpost.Activate after postcard, before Boot
- cabana: dependsOn on dropdowns (boot checks, controller-only), GET
fields/{field}/dependent-options with swag, inventories, conformance case
- companions fixture, TestSignpostSmoke, TestCompanionsDependentSmoke, docs
This commit is contained in:
175
modules/signpost/example_test.go
Normal file
175
modules/signpost/example_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package signpost_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
)
|
||||
|
||||
// BlogPost is the example's stand-in for a blog plugin's post model.
|
||||
type BlogPost struct {
|
||||
Slug string
|
||||
Title string
|
||||
UpdatedAt *time.Time
|
||||
}
|
||||
|
||||
// BlogPlugin is an example content plugin that registers two menu item
|
||||
// types: one post, and the list of every post.
|
||||
type BlogPlugin struct{ posts []BlogPost }
|
||||
|
||||
func (BlogPlugin) ID() string { return "acme.blog" }
|
||||
|
||||
var _ pact.HasMenuItemTypes = BlogPlugin{}
|
||||
|
||||
// MenuItemTypes registers acme-post, which points at one post, and
|
||||
// acme-all-posts, which lists every post. The list type names acme-post in
|
||||
// PatternFrom, so a host override of the post URL reaches its items too.
|
||||
func (p BlogPlugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{{
|
||||
Key: "acme-post",
|
||||
Label: "acme.blog::lang.menuitem.post",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
Placeholders: []string{":slug"},
|
||||
References: p.postReferences,
|
||||
Resolve: p.resolvePost,
|
||||
}, {
|
||||
Key: "acme-all-posts",
|
||||
Label: "acme.blog::lang.menuitem.all_posts",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
PatternFrom: "acme-post",
|
||||
Nesting: true,
|
||||
DynamicItems: true,
|
||||
Resolve: p.resolveAllPosts,
|
||||
}}
|
||||
}
|
||||
|
||||
// postReferences offers every post as a reference.
|
||||
func (p BlogPlugin) postReferences(context.Context) ([]pact.MenuItemReference, error) {
|
||||
refs := make([]pact.MenuItemReference, 0, len(p.posts))
|
||||
for _, post := range p.posts {
|
||||
refs = append(refs, pact.MenuItemReference{Value: post.Slug, Label: post.Title})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// resolvePost resolves one post from its slug.
|
||||
func (p BlogPlugin) resolvePost(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
for _, post := range p.posts {
|
||||
if post.Slug == in.Reference {
|
||||
url := strings.ReplaceAll(in.Pattern, ":slug", post.Slug)
|
||||
return &pact.ResolvedMenuItem{Title: post.Title, URL: url, MTime: post.UpdatedAt,
|
||||
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: url, Title: post.Title}}}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("blog: no post %q", in.Reference)
|
||||
}
|
||||
|
||||
// resolveAllPosts lists every post. It builds the children's URLs from the
|
||||
// layered pattern it receives, never from its own default.
|
||||
func (p BlogPlugin) resolveAllPosts(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
out := &pact.ResolvedMenuItem{}
|
||||
for _, post := range p.posts {
|
||||
url := strings.ReplaceAll(in.Pattern, ":slug", post.Slug)
|
||||
out.Items = append(out.Items, pact.ResolvedMenuItem{
|
||||
Title: post.Title,
|
||||
URL: url,
|
||||
MTime: post.UpdatedAt,
|
||||
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: url, Title: post.Title}},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EntriesController stands for a menu plugin's admin controller whose
|
||||
// reference dropdown depends on its type dropdown.
|
||||
type EntriesController struct{ app *backpack.App }
|
||||
|
||||
// DependentOptions serves the reference dropdown: the references of the
|
||||
// menu item type chosen in the type field, with their depth.
|
||||
func (c EntriesController) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
|
||||
typ, _ := values["type"].(string)
|
||||
reg, _ := c.app.Lookup[*signpost.Registry]()
|
||||
return signpost.ReferenceOptions(ctx, reg, typ)
|
||||
}
|
||||
|
||||
// exampleApp is an application whose config/menu_items.yaml moves posts to
|
||||
// /news.
|
||||
func exampleApp() *backpack.App {
|
||||
dir, err := os.MkdirTemp("", "signpost-example")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "menu_items.yaml"), []byte("patterns:\n acme-post: /news/:slug\n"), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=testing"}})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
func ExampleRegistry_Resolve() {
|
||||
app := exampleApp()
|
||||
blog := BlogPlugin{posts: []BlogPost{{Slug: "hello", Title: "Hello"}, {Slug: "again", Title: "Again"}}}
|
||||
if err := signpost.Activate(app, []BlogPlugin{blog}); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
item, err := reg.Resolve(context.Background(), "acme-all-posts", "", "", true, "")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, child := range item.Items {
|
||||
fmt.Println(child.Title, child.URL)
|
||||
}
|
||||
// Output:
|
||||
// Hello /news/hello
|
||||
// Again /news/again
|
||||
}
|
||||
|
||||
func ExampleReferenceOptions() {
|
||||
app := exampleApp()
|
||||
blog := BlogPlugin{posts: []BlogPost{{Slug: "hello", Title: "Hello"}}}
|
||||
if err := signpost.Activate(app, []BlogPlugin{blog}); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
entries := EntriesController{app: app}
|
||||
for _, typ := range []string{"acme-post", "acme-all-posts"} {
|
||||
set, err := entries.DependentOptions(context.Background(), "reference", map[string]any{"type": typ})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(typ, set.Applies, set.Options)
|
||||
}
|
||||
// Output:
|
||||
// acme-post true [{hello Hello 0}]
|
||||
// acme-all-posts false []
|
||||
}
|
||||
|
||||
// TestBlogPluginResolveAllPosts runs the documented registration and list
|
||||
// resolver directly: the list's items follow the pattern it is handed.
|
||||
func TestBlogPluginResolveAllPosts(t *testing.T) {
|
||||
blog := BlogPlugin{posts: []BlogPost{{Slug: "hello", Title: "Hello"}}}
|
||||
if types := blog.MenuItemTypes(); len(types) != 2 || types[1].PatternFrom != types[0].Key {
|
||||
t.Fatalf("MenuItemTypes = %+v", types)
|
||||
}
|
||||
item, err := blog.resolveAllPosts(context.Background(), pact.MenuItemResolve{Type: "acme-all-posts", Pattern: "/news/:slug"})
|
||||
if err != nil || item.URL != "" || len(item.Items) != 1 || item.Items[0].URL != "/news/hello" || len(item.Items[0].Alternates) != 1 {
|
||||
t.Fatalf("resolveAllPosts = %+v, %v", item, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user