- 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
176 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|