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:
@@ -30,6 +30,8 @@ Use cases:
|
||||
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
||||
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
|
||||
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
|
||||
- Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree.
|
||||
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot.
|
||||
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
|
||||
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
|
||||
- `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package.
|
||||
@@ -156,7 +158,15 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.RelationAfterUpdate` | Optional controller hook run after a relation manager updates a related record, before the commit. |
|
||||
| `pact.RelationBeforeDelete` | Optional controller hook run in the write transaction before a relation manager deletes a related record. |
|
||||
| `pact.RelationAfterDelete` | Optional controller hook run after a relation manager deletes a related record, before the commit. |
|
||||
| `pact.Option` | One dropdown choice (value and label). |
|
||||
| `pact.Option` | One dropdown choice (value and label), with `Depth`, the nesting level of a choice taken from a tree (omitted from JSON when 0). |
|
||||
| `pact.DependentOptionsProvider` | Optional controller hook serving the choices of a dropdown with `dependsOn` from the submitted values of its siblings; asked by the options route and on every save. |
|
||||
| `pact.DependentOptionSet` | `DependentOptions`' answer: `Applies` (false when the sibling's value takes no choice; the field then stores null) and `Options`. |
|
||||
| `pact.HasMenuItemTypes` | Implemented by plugins that register menu item types; collected once at boot by signpost. |
|
||||
| `pact.MenuItemType` | One menu item type: `Key`, `Label`, `DefaultPattern` (required, starts with `/`), `PatternFrom` (a list type's record type), `Placeholders`, `Nesting`, `DynamicItems`, the optional `References` func and the required `Resolve` func. |
|
||||
| `pact.MenuItemReference` | One record a menu item can point at: value, label and nested records. |
|
||||
| `pact.MenuItemResolve` | What `Resolve` receives: type, reference, the layered URL pattern, whether to expand nested items and the unprefixed locale. |
|
||||
| `pact.ResolvedMenuItem` | A resolved item: title, URL (empty for a pure list type), modification time, ordered alternates and nested items. |
|
||||
| `pact.MenuItemAlternate` | The URL and title of a resolved item in one locale. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user