Files
summercms/modules/signpost/example_test.go
Jakub Zych 0cdff4c258 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
2026-10-12 15:09:14 +02:00

176 lines
5.9 KiB
Go

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)
}
}