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:
128
docs/plugins/menu-item-types.md
Normal file
128
docs/plugins/menu-item-types.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Menu item types
|
||||
description: Register typed menu item types from a content plugin, so menus and sitemaps link to its records through URL patterns the host can override.
|
||||
section: plugins
|
||||
order: 35
|
||||
---
|
||||
# Menu item types
|
||||
|
||||
A menu entry or a sitemap row often points at content another plugin owns: a blog post, a category, "all posts". In WinterCMS the content plugin answered three events for that, `pages.menuitem.listTypes`, `pages.menuitem.getTypeInfo` and `pages.menuitem.resolveItem`. In SummerCMS it registers typed menu item types instead, and the [signpost](../../modules/signpost/README.md) registry collects them once at boot. Menus and sitemaps never import the content plugin: they read the registry.
|
||||
|
||||
## Registering types
|
||||
|
||||
A plugin implements `pact.HasMenuItemTypes` and returns one `pact.MenuItemType` per type. This example registers a post type and a list of every post:
|
||||
|
||||
```go src=modules/signpost/example_test.go#BlogPlugin.MenuItemTypes
|
||||
// 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,
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
The fields:
|
||||
|
||||
- `Key` is unique across all plugins; a key two plugins register stops the start-up with an error that names the key and both plugin IDs. Keep WinterCMS's keys when you port a plugin, so stored menu entries keep working.
|
||||
- `Label` is a translation key or text, shown in the type dropdown.
|
||||
- `DefaultPattern` is the type's URL pattern, required on every type and starting with `/`. The placeholders a pattern understands are listed in `Placeholders`, with WinterCMS's names (`:slug`, `:id`, `:category`, `:year`, `:month`, `:day`), so patterns stored by a PHP site still fill.
|
||||
- `PatternFrom` is set on a list type, whose items are records of another type: it names that record type (see [URL patterns](#url-patterns)). It must name another registered type that does not set `PatternFrom` itself.
|
||||
- `Nesting` reports that an item may expand into its sub-items, `DynamicItems` that its children are generated from records.
|
||||
- `References`, when set, returns the records an item can point at as a `pact.MenuItemReference` tree (value, label, nested records). A type without references, such as "all posts", leaves it nil.
|
||||
- `Resolve` is required. It receives a `pact.MenuItemResolve` and answers a `pact.ResolvedMenuItem`.
|
||||
|
||||
A type that needs the database or another service closes over the plugin's `backpack.App` (stored in `Register`), because types are collected before any plugin boots.
|
||||
|
||||
## Resolving an item
|
||||
|
||||
`Resolve` receives the item's type, its stored reference, the URL pattern already layered by the registry, whether to expand nested items, and the locale whose URLs carry no prefix. It answers:
|
||||
|
||||
- `Title` and `URL`. A pure list type leaves `URL` empty and returns the records it lists in `Items`.
|
||||
- `MTime`, the record's last change, used for a sitemap's `lastmod`; nil when unknown.
|
||||
- `Alternates`, the item's URL and title in each locale, as an ordered `[]pact.MenuItemAlternate` in the site's locale order. It is a slice, never a map, so hreflang output keeps its order from run to run. A single-locale site returns one alternate.
|
||||
- `Items`, the nested items of a type that expands, each resolved the same way.
|
||||
|
||||
A list type builds its children's URLs from `pact.MenuItemResolve.Pattern`, not from its own default: that pattern is what the registry layered for the item, so the host's override of the record type reaches the children too.
|
||||
|
||||
```go src=modules/signpost/example_test.go#BlogPlugin.resolveAllPosts
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
Consumers resolve through the registry, which picks the pattern and calls the type. In an application `party.Activate` builds the registry at boot; the example calls `signpost.Activate` itself. With the host's `acme-post` override set to `/news/:slug`, the children of the list follow it:
|
||||
|
||||
```go src=modules/signpost/example_test.go#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
|
||||
```
|
||||
|
||||
`signpost.Registry.Types` lists every type for a type dropdown, in plugin order and then declaration order, and `signpost.Registry.Type` looks one up.
|
||||
|
||||
## URL patterns
|
||||
|
||||
Headless sites decide their own URLs, so a type's URL comes from a pattern in layers. `signpost.Registry.Pattern` returns the first that is set:
|
||||
|
||||
1. The item's own override, such as a menu entry's or a sitemap row's URL pattern field.
|
||||
2. The host's override of the type, from `menu_items.patterns`.
|
||||
3. For a list type with `PatternFrom`, the host's override of the record type it lists.
|
||||
4. The type's `DefaultPattern`.
|
||||
|
||||
Layer 3 is why a list type declares `PatternFrom`. A host that moves posts to `/news/:slug` sets that once, for `acme-post`, and the children of "all posts" follow, because `acme-all-posts` names `acme-post` and has no override of its own. Give a list type the same `DefaultPattern` as the type it lists, so both agree without any host configuration.
|
||||
|
||||
The host application sets its overrides in `config/menu_items.yaml`:
|
||||
|
||||
```yaml
|
||||
patterns:
|
||||
acme-post: /news/:slug
|
||||
acme-category: /:slug
|
||||
```
|
||||
|
||||
`menu_items.patterns` (`signpost.ConfigKey`) is read once at boot. Type keys contain dashes, so the map can only be set in a YAML file, not through a `SUMMER_` environment variable. An override for a type no plugin registers, or one that does not start with `/`, stops the start-up.
|
||||
|
||||
## Reference dropdowns
|
||||
|
||||
The admin form of a menu entry or a sitemap row has a type dropdown and a reference dropdown that depends on it. The controller serves the reference options with `signpost.ReferenceOptions`, which flattens the chosen type's reference tree into options with their depth and answers `Applies` false for a type without references. See [Dependent dropdowns](../backend/forms.md#dependent-dropdowns).
|
||||
Reference in New Issue
Block a user