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:
@@ -106,7 +106,7 @@ The WinterCMS widgets that are not in this list (the rich editor, the code edito
|
||||
|
||||
### Field options
|
||||
|
||||
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields and `preset` on text and mltext fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused.
|
||||
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields and `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
|
||||
|
||||
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
|
||||
|
||||
@@ -127,6 +127,41 @@ Multilingual pairs follow the locale the administrator works in. An `mltext` tar
|
||||
|
||||
A `preset` on another field type (`mltextarea` and `mlmarkdown` included), an unknown type, or a source that is not a text or mltext field of the same form stops the start-up, and the key is not accepted on settings forms or relation forms. An `mltextarea` field is neither a preset target nor a preset source, like `textarea`.
|
||||
|
||||
## Dependent dropdowns
|
||||
|
||||
A `type: dropdown` field with `dependsOn` takes its options from the value of a sibling field, WinterCMS's dependent dropdown. A menu entry's reference dropdown, for example, lists the posts when its type dropdown says "post" and the categories when it says "category":
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
type:
|
||||
label: acme.menu::lang.entry.type
|
||||
type: dropdown
|
||||
options: typeOptions
|
||||
reference:
|
||||
label: acme.menu::lang.entry.reference
|
||||
type: dropdown
|
||||
dependsOn: type
|
||||
emptyOption: backend::lang.form.none
|
||||
```
|
||||
|
||||
`dependsOn` names another field of the same form (or of the same repeater row) whose value is a scalar. The dropdown declares no `options`: the controller implements `pact.DependentOptionsProvider`, and its `DependentOptions` receives the dropdown's name and the sibling's value (a string, or nil when it is empty) and answers a `pact.DependentOptionSet`. `Applies` false means that value takes no choice at all, and the admin disables the dropdown. Otherwise `Options` are the choices, and an option's `Depth` indents a choice taken from a tree. A `dependsOn` on another type, on a dropdown with options, naming a missing field or the field itself, or on a controller without the provider stops the start-up; settings and relation forms refuse the key.
|
||||
|
||||
The admin SPA fetches the options from `GET <prefix>/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options?values[<sibling>]=<value>` whenever the sibling changes. The route is guarded like the form schema (a backend session with the controller's permissions) and answers `{applies, options: [{value, label, depth}]}` with labels in the request locale.
|
||||
|
||||
Menu item types are the common source of such options. A menu or sitemap controller answers with `signpost.ReferenceOptions`, which turns the chosen type's reference tree into options with their depth and answers `Applies` false for a type without references:
|
||||
|
||||
```go src=modules/signpost/example_test.go#EntriesController.DependentOptions
|
||||
// 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)
|
||||
}
|
||||
```
|
||||
|
||||
See [Menu item types](../plugins/menu-item-types.md) for registering the types.
|
||||
|
||||
## Date pickers
|
||||
|
||||
A `type: datepicker` field edits one model column. Its `mode` decides the column's Go type, and the start-up stops when they do not match:
|
||||
|
||||
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