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:
2026-10-12 15:09:14 +02:00
parent 9016e2a6fd
commit 0cdff4c258
29 changed files with 1752 additions and 14 deletions

View File

@@ -565,9 +565,12 @@ type HasSettings interface {
}
// Option is one dropdown choice. Label may be a phrase key until request time.
// Depth is the nesting level of a choice taken from a tree (0 at the top); a
// flat list leaves it 0, so its JSON carries no depth key.
type Option struct {
Value string `json:"value"`
Label string `json:"label"`
Depth int `json:"depth,omitempty"`
}
// DropdownOptionsProvider serves method-backed dropdown options.
@@ -575,6 +578,106 @@ type DropdownOptionsProvider interface {
DropdownOptions(field string) []Option
}
// DependentOptionSet is the answer of a dropdown whose choices depend on a
// sibling field. Applies is false when the sibling's value takes no choice at
// all (a menu item type without references, for example): the field then
// holds no value. Options are the choices when Applies is true, possibly none
// yet.
type DependentOptionSet struct {
Applies bool
Options []Option
}
// DependentOptionsProvider is implemented by an admin controller whose form
// has a dropdown with `dependsOn` (WinterCMS's dependent dropdown). field is
// the dropdown's name and values holds the submitted values of the fields it
// depends on, keyed by field name: the sibling fields of the same form, or of
// the same repeater row for a dropdown inside a repeater. The admin framework
// calls it for the options route and again on every save, where a value that
// is not among the options is refused.
type DependentOptionsProvider interface {
DependentOptions(ctx context.Context, field string, values map[string]any) (DependentOptionSet, error)
}
// MenuItemType is one menu item type a plugin registers through
// HasMenuItemTypes, the typed successor of WinterCMS's
// pages.menuitem.listTypes, getTypeInfo and resolveItem events. Menus and
// sitemaps offer it as an item type and resolve their items through it.
//
// Key is unique across plugins (for example "acme-post"); Label is a phrase
// key or text. DefaultPattern is the type's URL pattern, such as
// "/blog/:slug": it is required and starts with "/". PatternFrom is set on a
// list type, whose items are records of another type, to that type's key: the
// host's pattern override of the record type then applies to the list's
// items when the list type has no override of its own. Placeholders names the
// placeholders the pattern understands (":slug", ":id"). Nesting reports that
// an item of the type may expand into its sub-items, and DynamicItems that an
// item generates its children from records. References, when set, returns the
// records an item of the type can point at as a tree; nil means the type takes
// no reference. Resolve is required and turns one item into its URL, title,
// alternates and children.
type MenuItemType struct {
Key string
Label string
DefaultPattern string
PatternFrom string
Placeholders []string
Nesting bool
DynamicItems bool
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"`
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
}
// MenuItemReference is one record a menu item can point at: the stored
// Value, the Label an administrator picks it by, and the records nested under
// it.
type MenuItemReference struct {
Value string
Label string
Items []MenuItemReference
}
// MenuItemResolve is what a type's Resolve receives. Type is the type key and
// Reference the item's stored reference ("" for a type that takes none).
// Pattern is the URL pattern already layered by the registry: the item's own
// override, else the host's override, else the type's default. Nesting asks
// for the item's sub-items. UnprefixedLocale names the locale whose URLs carry
// no locale prefix; "" leaves the choice to the type.
type MenuItemResolve struct {
Type string
Reference string
Pattern string
Nesting bool
UnprefixedLocale string
}
// ResolvedMenuItem is a resolved menu item. URL is empty for a pure list type,
// whose Items are the records it lists. MTime is the record's last change,
// nil when unknown. Alternates lists the item's URL per locale in the locale
// order of the site, never as a map, so hreflang output is stable; a site with
// one locale resolves to one alternate.
type ResolvedMenuItem struct {
Title string
URL string
MTime *time.Time
Alternates []MenuItemAlternate
Items []ResolvedMenuItem
}
// MenuItemAlternate is the URL and title of a resolved item in one locale.
type MenuItemAlternate struct {
Locale string
URL string
Title string
}
// HasMenuItemTypes is implemented by plugins that register menu item types.
// The framework collects every plugin's types once at boot, after Register
// and before Boot, and fails the boot on a duplicate key.
type HasMenuItemTypes interface {
MenuItemTypes() []MenuItemType
}
// ListExtendQuery optionally narrows the admin list query.
type ListExtendQuery interface {
ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB