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:
@@ -109,6 +109,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. | What you pack to speak the local language on holiday; it holds the translations. |
|
||||
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. | What you send home in summer; it delivers the application's mail. |
|
||||
| [sieve](modules/sieve/README.md) | Generic, dependency-free collection helpers that the standard library slices and maps packages lack: mapping, filtering, reducing, keying, grouping, deduplicating, partitioning and finding, plus a small set type. | A sand sieve: you shake a collection through it and keep what you need; map, filter and reduce for slices and maps. |
|
||||
| [signpost](modules/signpost/README.md) | Menu item type registry that collects the typed menu item types plugins register at boot, layers their URL patterns with host overrides and resolves menu and sitemap items through the owning plugin. | A beach signpost points the way to places; the registry tells menus and sitemaps where each item leads. |
|
||||
| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, in one command, embedded so it is versioned with the framework. | The start of a summer day; it starts a new application, or updates one, in one command. |
|
||||
| [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. | Protects you from exposure; it keeps secrets out of the logs. |
|
||||
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. | Catching the incoming waves; it routes incoming HTTP requests to their handlers. |
|
||||
|
||||
@@ -223,6 +223,43 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminDependentOption": {
|
||||
"properties": {
|
||||
"depth": {
|
||||
"type": "integer"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"depth",
|
||||
"label",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminDependentOptionsResult": {
|
||||
"properties": {
|
||||
"applies": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"options": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminDependentOption"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"applies",
|
||||
"options"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminFileCaptionRequest": {
|
||||
"properties": {
|
||||
"description": {
|
||||
@@ -620,6 +657,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminDependentOptionsResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminDependentOptionsResult"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminLoginData": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -1105,6 +1157,10 @@
|
||||
"description": "Deferrable is true on a relation-manager field whose relation can be\nmanaged before the record is first saved (RelationSchema.Deferrable):\nthe SPA shows it on the create screen.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dependsOn": {
|
||||
"description": "DependsOn names the sibling field (same form, or same repeater row)\nwhose value selects a dropdown's options; the options come from the\ndependent-options route (D-08).",
|
||||
"type": "string"
|
||||
},
|
||||
"displayFormat": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4046,6 +4102,100 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options": {
|
||||
"get": {
|
||||
"description": "Choices of a `type: dropdown` field with `dependsOn`, from the controller's pact.DependentOptionsProvider, for the value of the sibling field the dropdown depends on. The sibling's value is passed as the query parameter `values[\u003csibling\u003e]` (for example `values[type]=acme-post`); a missing or empty value is null. `applies` is false when that value takes no choice at all (the field then stores null) and `options` is then empty; with `applies` true an empty `options` means there is nothing to choose yet. `depth` is the nesting level of a choice taken from a tree (0 at the top); labels are localized. A field that is not a dependsOn dropdown of the controller's form answers 404.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
"in": "path",
|
||||
"name": "vendor",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Plugin",
|
||||
"in": "path",
|
||||
"name": "plugin",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Controller",
|
||||
"in": "path",
|
||||
"name": "controller",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Dropdown field name",
|
||||
"in": "path",
|
||||
"name": "field",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminDependentOptionsResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Dependent dropdown options",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||
"get": {
|
||||
"description": "Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.",
|
||||
@@ -4372,7 +4522,7 @@
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
"get": {
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).",
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
|
||||
96
admin/src/api/schema.d.ts
vendored
96
admin/src/api/schema.d.ts
vendored
@@ -1568,6 +1568,81 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Dependent dropdown options
|
||||
* @description Choices of a `type: dropdown` field with `dependsOn`, from the controller's pact.DependentOptionsProvider, for the value of the sibling field the dropdown depends on. The sibling's value is passed as the query parameter `values[<sibling>]` (for example `values[type]=acme-post`); a missing or empty value is null. `applies` is false when that value takes no choice at all (the field then stores null) and `options` is then empty; with `applies` true an empty `options` means there is nothing to choose yet. `depth` is the nesting level of a choice taken from a tree (0 at the top); labels are localized. A field that is not a dependsOn dropdown of the controller's form answers 404.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
/** @description Dropdown field name */
|
||||
field: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminDependentOptionsResult"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1821,7 +1896,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Admin form schema
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
@@ -4981,6 +5056,15 @@ export interface components {
|
||||
thumb_url?: string;
|
||||
url: string;
|
||||
};
|
||||
"cabana.AdminDependentOption": {
|
||||
depth: number;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
"cabana.AdminDependentOptionsResult": {
|
||||
applies: boolean;
|
||||
options: components["schemas"]["cabana.AdminDependentOption"][];
|
||||
};
|
||||
"cabana.AdminFileCaptionRequest": {
|
||||
description?: string;
|
||||
title?: string;
|
||||
@@ -5086,6 +5170,10 @@ export interface components {
|
||||
data: components["schemas"]["cabana.AdminActionResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminDependentOptionsResult": {
|
||||
data: components["schemas"]["cabana.AdminDependentOptionsResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminLoginData": {
|
||||
data: components["schemas"]["cabana.AdminLoginData"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
@@ -5224,6 +5312,12 @@ export interface components {
|
||||
* the SPA shows it on the create screen.
|
||||
*/
|
||||
deferrable?: boolean;
|
||||
/**
|
||||
* @description DependsOn names the sibling field (same form, or same repeater row)
|
||||
* whose value selects a dropdown's options; the options come from the
|
||||
* dependent-options route (D-08).
|
||||
*/
|
||||
dependsOn?: string;
|
||||
displayFormat?: string;
|
||||
/**
|
||||
* @description Element is the custom-element tag of a field whose type a plugin
|
||||
|
||||
@@ -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).
|
||||
@@ -38,6 +38,7 @@ Use cases:
|
||||
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
|
||||
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
|
||||
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext`, `type: mltextarea` and `type: mlmarkdown` take a JSON object of locale code to string; `mltextarea` is the multi-line variant and takes `size` like `textarea`. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order, so every ML control opens on the locale stored in the host columns; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
|
||||
- Dependent dropdowns: `dependsOn: <field>` on a `type: dropdown` field names a sibling with a scalar value (the same form, or the same repeater row); the dropdown takes no `options` and its controller implements `pact.DependentOptionsProvider`, else boot fails. GET `.../fields/{field}/dependent-options?values[<sibling>]=<value>` answers `cabana.AdminDependentOptionsResult` (`applies` and the localized `options` with their `depth`); the form schema carries `dependsOn`. A menu or sitemap controller returns `signpost.ReferenceOptions` here, the one admin binding of the [signpost](../signpost/README.md) menu item type registry. Settings and relation forms refuse `dependsOn` (supported in controller forms only).
|
||||
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
|
||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||
@@ -81,6 +82,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. |
|
||||
| GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
|
||||
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
|
||||
| GET `.../fields/{field}/dependent-options` | Choices of a dropdown with `dependsOn` for the sibling value passed as `values[<sibling>]`, from the controller's `pact.DependentOptionsProvider`: `{applies, options: [{value, label, depth}]}` (`cabana.AdminDependentOptionsResult`). A field that is not a dependsOn dropdown answers 404. |
|
||||
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
||||
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. A link body may carry `pivot` values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key. |
|
||||
| POST `.../{id}/relations/{name}/records` | Create a related record through the relation's manage form and attach it to the record (a hasMany foreign key is set by the server; a belongsToMany gets its pivot row). Needs `create` in the view panel's `toolbarButtons` (403 otherwise). |
|
||||
@@ -228,6 +230,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
|
||||
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |
|
||||
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
|
||||
| `cabana.AdminDependentOptionsResult` / `cabana.AdminDependentOption` | Answer of the dependent-options route: `applies` and the options, each with `value`, localized `label` and `depth`. |
|
||||
| `cabana.AdminDependentOptions` | Swag annotation of the dependent-options route. |
|
||||
| `cabana.FormPreview` | The `preview` object of a form schema: present when the form has a preview screen; `headerPartial` names its status hint partial. |
|
||||
| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label and optional confirm text. |
|
||||
| `cabana.CRUDService.RecordAction` | Runs a declared record action on one scoped, locked record. |
|
||||
@@ -334,7 +338,7 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [towel](../towel/README.md).
|
||||
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk).
|
||||
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package, and [signpost](../signpost/README.md) for the dependent dropdown fixture.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -428,7 +428,7 @@ func AdminListSchema() {}
|
||||
// AdminFormSchema documents the form schema route.
|
||||
//
|
||||
// @Summary Admin form schema
|
||||
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).
|
||||
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
@@ -481,6 +481,24 @@ func AdminRelationSchema() {}
|
||||
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
|
||||
func AdminFieldOptions() {}
|
||||
|
||||
// AdminDependentOptions documents the dependent dropdown options route (D-08).
|
||||
//
|
||||
// @Summary Dependent dropdown options
|
||||
// @Description Choices of a `type: dropdown` field with `dependsOn`, from the controller's pact.DependentOptionsProvider, for the value of the sibling field the dropdown depends on. The sibling's value is passed as the query parameter `values[<sibling>]` (for example `values[type]=acme-post`); a missing or empty value is null. `applies` is false when that value takes no choice at all (the field then stores null) and `options` is then empty; with `applies` true an empty `options` means there is nothing to choose yet. `depth` is the nesting level of a choice taken from a tree (0 at the top); labels are localized. A field that is not a dependsOn dropdown of the controller's form answers 404.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param field path string true "Dropdown field name"
|
||||
// @Success 200 {object} Envelope[AdminDependentOptionsResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options [get]
|
||||
func AdminDependentOptions() {}
|
||||
|
||||
// AdminFilterOptions documents the model-backed filter options route (D-27).
|
||||
//
|
||||
// @Summary Filter scope options
|
||||
|
||||
225
modules/cabana/companions_smoke_test.go
Normal file
225
modules/cabana/companions_smoke_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// companionsDir is the neutral acme.companions fixture plugin tree of the
|
||||
// Phase 17 cabana features: a dependent reference dropdown fed by the menu
|
||||
// item type registry.
|
||||
const companionsDir = "testdata/companions"
|
||||
|
||||
// companionsLink is the fixture model of the links controller.
|
||||
type companionsLink struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Type *string `gorm:"column:type"`
|
||||
Reference *string `gorm:"column:reference"`
|
||||
}
|
||||
|
||||
func (companionsLink) TableName() string { return "cabana_companions_links" }
|
||||
func (companionsLink) Fillable() []string { return []string{"title", "type", "reference"} }
|
||||
func (companionsLink) Rules() map[string]string { return map[string]string{"title": "required"} }
|
||||
|
||||
// companionsModels are the fixture tables, dropped and recreated per env.
|
||||
func companionsModels() []any { return []any{&companionsLink{}} }
|
||||
|
||||
// companionsEcho resolves an item to its layered pattern.
|
||||
func companionsEcho(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return &pact.ResolvedMenuItem{Title: in.Type, URL: in.Pattern, Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: in.Pattern}}}, nil
|
||||
}
|
||||
|
||||
// companionsPlugin registers two menu item types (acme-page with a two-level
|
||||
// reference tree, acme-list without references) and the links controller.
|
||||
type companionsPlugin struct{ app *backpack.App }
|
||||
|
||||
func (companionsPlugin) ID() string { return "acme.companions" }
|
||||
func (companionsPlugin) Requires() []string { return nil }
|
||||
func (companionsPlugin) Register(*backpack.App) error { return nil }
|
||||
func (companionsPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (companionsPlugin) AdminFS() fs.FS { return os.DirFS(companionsDir) }
|
||||
func (p companionsPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{companionsLinks{app: p.app}}
|
||||
}
|
||||
func (companionsPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{Code: "acme.companions.access", Roles: []string{"developer"}}}
|
||||
}
|
||||
func (companionsPlugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{
|
||||
{Key: "acme-page", Label: "Page", DefaultPattern: "/:slug", Resolve: companionsEcho,
|
||||
References: func(context.Context) ([]pact.MenuItemReference, error) {
|
||||
return []pact.MenuItemReference{{Value: "1", Label: "Home", Items: []pact.MenuItemReference{{Value: "2", Label: "About"}}}}, nil
|
||||
}},
|
||||
{Key: "acme-list", Label: "All pages", DefaultPattern: "/:slug", PatternFrom: "acme-page", Resolve: companionsEcho},
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.HasMenuItemTypes = companionsPlugin{}
|
||||
_ pact.DependentOptionsProvider = companionsLinks{}
|
||||
_ pact.DropdownOptionsProvider = companionsLinks{}
|
||||
)
|
||||
|
||||
// companionsLinks is the links controller: its type dropdown offers the
|
||||
// built-in url plus every registered menu item type, and its reference
|
||||
// dropdown depends on type through signpost.ReferenceOptions.
|
||||
type companionsLinks struct{ app *backpack.App }
|
||||
|
||||
func (companionsLinks) ID() string { return "acme.companions.links" }
|
||||
func (companionsLinks) ModelName() string { return "Link" }
|
||||
func (companionsLinks) ConfigDir() string { return "controllers/links" }
|
||||
func (companionsLinks) RequiredPermissions() []string { return []string{"acme.companions.access"} }
|
||||
func (companionsLinks) NewRecord() any { return &companionsLink{} }
|
||||
|
||||
func (c companionsLinks) registry() *signpost.Registry {
|
||||
if c.app == nil {
|
||||
return nil
|
||||
}
|
||||
reg, _ := c.app.Lookup[*signpost.Registry]()
|
||||
return reg
|
||||
}
|
||||
|
||||
func (c companionsLinks) DropdownOptions(string) []pact.Option {
|
||||
out := []pact.Option{{Value: "url", Label: "URL"}}
|
||||
for _, t := range c.registry().Types() {
|
||||
out = append(out, pact.Option{Value: t.Key, Label: t.Label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c companionsLinks) DependentOptions(ctx context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) {
|
||||
typ, _ := values["type"].(string)
|
||||
return signpost.ReferenceOptions(ctx, c.registry(), typ)
|
||||
}
|
||||
|
||||
// newCompanionsEnv boots the acme.companions fixture alone on Postgres, with
|
||||
// the menu item type registry activated, and signs an administrator in.
|
||||
func newCompanionsEnv(t *testing.T) *conformEnv {
|
||||
t.Helper()
|
||||
gdb := adminGorm(t)
|
||||
resetCompanions(t, gdb)
|
||||
login := "companions-" + filepath.Base(t.TempDir())
|
||||
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-companions\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugins := []party.Plugin{companionsPlugin{app: app}}
|
||||
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := signpost.Activate(app, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &conformEnv{h: h, db: gdb, login: login}
|
||||
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// resetCompanions drops and recreates the fixture tables.
|
||||
func resetCompanions(t *testing.T, gdb *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := gdb.Migrator().DropTable(companionsModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(companionsModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// dependentOptionsPath is the dependent-options route of a links field for
|
||||
// one value of its type sibling.
|
||||
func dependentOptionsPath(field, typ string) string {
|
||||
path := "/acme/companions/links/fields/" + field + "/dependent-options"
|
||||
if typ != "" {
|
||||
path += "?" + url.Values{"values[type]": {typ}}.Encode()
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestCompanionsDependentSmoke checks the D-08 binding end to end: the
|
||||
// reference dropdown of the links form lists the references of the menu item
|
||||
// type chosen in its type sibling, served from the registry with depths.
|
||||
func TestCompanionsDependentSmoke(t *testing.T) {
|
||||
e := newCompanionsEnv(t)
|
||||
decode := func(t *testing.T, field, typ string) cabana.AdminDependentOptionsResult {
|
||||
t.Helper()
|
||||
rec := e.send(t, http.MethodGet, dependentOptionsPath(field, typ), nil, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body cabana.Envelope[cabana.AdminDependentOptionsResult]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body.Data
|
||||
}
|
||||
page := decode(t, "reference", "acme-page")
|
||||
if !page.Applies || len(page.Options) != 2 || page.Options[0] != (cabana.AdminDependentOption{Value: "1", Label: "Home", Depth: 0}) || page.Options[1] != (cabana.AdminDependentOption{Value: "2", Label: "About", Depth: 1}) {
|
||||
t.Fatalf("acme-page options = %+v", page)
|
||||
}
|
||||
list := decode(t, "reference", "acme-list")
|
||||
if list.Applies || list.Options == nil || len(list.Options) != 0 {
|
||||
t.Fatalf("acme-list options = %+v", list)
|
||||
}
|
||||
if none := decode(t, "reference", ""); none.Applies {
|
||||
t.Fatalf("no type options = %+v", none)
|
||||
}
|
||||
if rec := e.send(t, http.MethodGet, dependentOptionsPath("title", "acme-page"), nil, true); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("non-dependent field status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
schema := e.send(t, http.MethodGet, "/acme/companions/links/schema/form", nil, true)
|
||||
var form cabana.Envelope[cabana.FormView]
|
||||
if err := json.Unmarshal(schema.Body.Bytes(), &form); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, field := range form.Data.Fields {
|
||||
if field.Name == "reference" {
|
||||
found = field.DependsOn == "type" && len(field.Options) == 0
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("form schema does not carry dependsOn: %s", schema.Body.String())
|
||||
}
|
||||
}
|
||||
218
modules/cabana/field_dependent.go
Normal file
218
modules/cabana/field_dependent.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
)
|
||||
|
||||
// compileDependsOn decodes a field's `dependsOn` (D-08): the name of the
|
||||
// sibling field whose value selects the dropdown's options. It is valid on
|
||||
// type dropdown only, and such a dropdown takes no `options`: its choices
|
||||
// come from the controller's pact.DependentOptionsProvider. The sibling is
|
||||
// checked against the form in checkDependsOn.
|
||||
func compileDependsOn(typ string, values map[string]ast.Node, field *FormField) error {
|
||||
node, ok := values["dependsOn"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if typ != "dropdown" {
|
||||
return fmt.Errorf("dependsOn is only valid on type: dropdown")
|
||||
}
|
||||
name, err := nodeString(node)
|
||||
if err != nil || !identifier(name) {
|
||||
return fmt.Errorf("dependsOn %q is not a field name", nodeText(node))
|
||||
}
|
||||
if _, has := values["options"]; has {
|
||||
return fmt.Errorf("a dropdown with dependsOn takes no options (its controller's DependentOptions supplies them)")
|
||||
}
|
||||
field.DependsOn = name
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDependsOn checks every dependsOn of fields (one form, or one repeater
|
||||
// row) against its siblings: the named field must be another field of the
|
||||
// same fields with a scalar value. owner, when set, prefixes the field names
|
||||
// of the error (a repeater's nested fields).
|
||||
func checkDependsOn(fields []FormField, owner string) error {
|
||||
byName := make(map[string]FormField, len(fields))
|
||||
for _, field := range fields {
|
||||
byName[field.Name] = field
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.DependsOn == "" {
|
||||
continue
|
||||
}
|
||||
name := field.Name
|
||||
if owner != "" {
|
||||
name = owner + "." + field.Name
|
||||
}
|
||||
if field.DependsOn == field.Name {
|
||||
return fmt.Errorf("field %s: dependsOn names the field itself", name)
|
||||
}
|
||||
sibling, ok := byName[field.DependsOn]
|
||||
if !ok {
|
||||
where := "this form"
|
||||
if owner != "" {
|
||||
where = "the same repeater row"
|
||||
}
|
||||
return fmt.Errorf("field %s: dependsOn %s is not a field of %s", name, field.DependsOn, where)
|
||||
}
|
||||
if !scalarFormField(sibling.Type) {
|
||||
return fmt.Errorf("field %s: dependsOn %s must be a field with a scalar value, not type %s", name, field.DependsOn, sibling.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usesDependsOn reports whether any field of the form, top level or nested,
|
||||
// declares dependsOn.
|
||||
func usesDependsOn(fields []FormField) bool {
|
||||
for _, field := range fields {
|
||||
if field.DependsOn != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requireDependentProvider fails the boot when the form declares dependsOn
|
||||
// and the controller does not implement pact.DependentOptionsProvider.
|
||||
func requireDependentProvider(ctl pact.AdminController, fields []FormField) error {
|
||||
if err := checkDependsOn(fields, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if !usesDependsOn(fields) {
|
||||
return nil
|
||||
}
|
||||
if _, ok := ctl.(pact.DependentOptionsProvider); ok {
|
||||
return nil
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.DependsOn != "" {
|
||||
return fmt.Errorf("field %s: dependsOn needs controller %s to implement pact.DependentOptionsProvider", field.Name, ctl.ID())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// controllerFormOnly refuses, on a settings form or a relation form, a field
|
||||
// that only an admin controller's save path supports: its checks run in the
|
||||
// controller save and nowhere else.
|
||||
func controllerFormOnly(field FormField) error {
|
||||
if field.DependsOn != "" {
|
||||
return fmt.Errorf("dependsOn is supported in controller forms only")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dependentField is the dependsOn dropdown name of the controller's form, or
|
||||
// false.
|
||||
func dependentField(cc *CompiledController, name string) (FormField, bool) {
|
||||
if cc == nil || cc.Form == nil {
|
||||
return FormField{}, false
|
||||
}
|
||||
for _, field := range cc.Form.Fields {
|
||||
if field.Name == name && field.DependsOn != "" {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
return FormField{}, false
|
||||
}
|
||||
|
||||
// dependentValue normalizes a sibling value for pact.DependentOptionsProvider:
|
||||
// a scalar becomes its text and an empty value nil, so the options route
|
||||
// (query strings) and a save (JSON values) hand the provider the same thing.
|
||||
func dependentValue(v any) any {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case uint:
|
||||
return strconv.FormatUint(uint64(t), 10)
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
// dependentOptions asks the controller's provider for the options of field
|
||||
// given the sibling's value.
|
||||
func dependentOptions(ctx context.Context, cc *CompiledController, path, sibling string, value any) (pact.DependentOptionSet, error) {
|
||||
provider, ok := cc.Controller.(pact.DependentOptionsProvider)
|
||||
if !ok || provider == nil {
|
||||
return pact.DependentOptionSet{}, fmt.Errorf("cabana: controller %s has no DependentOptions", controllerID(cc))
|
||||
}
|
||||
return provider.DependentOptions(ctx, path, map[string]any{sibling: dependentValue(value)})
|
||||
}
|
||||
|
||||
// AdminDependentOption is one choice of a dependent dropdown: the value, the
|
||||
// label in the request locale and the nesting level (0 at the top) of a
|
||||
// choice taken from a tree.
|
||||
type AdminDependentOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
|
||||
// AdminDependentOptionsResult is the data of the dependent-options route.
|
||||
// Applies is false when the sibling's value takes no choice at all (the field
|
||||
// then stores null); Options is then empty. With Applies true, an empty
|
||||
// Options means there is nothing to choose yet.
|
||||
type AdminDependentOptionsResult struct {
|
||||
Applies bool `json:"applies"`
|
||||
Options []AdminDependentOption `json:"options"`
|
||||
}
|
||||
|
||||
// dependentOptionsRoute serves GET {vendor}/{plugin}/{controller}/fields/{field}/dependent-options
|
||||
// (D-08): the options of a dropdown with dependsOn, for the sibling value the
|
||||
// query passes as values[<sibling>]=<value>. It is guarded like the form
|
||||
// schema: a backend principal with the controller's permissions. A field that
|
||||
// is not a dependsOn dropdown of the controller's form answers 404.
|
||||
func (s *service) dependentOptionsRoute(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
field, ok := dependentField(cc, r.PathValue("field"))
|
||||
if !ok {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
var value any
|
||||
if raw, present := q["values["+field.DependsOn+"]"]; present && len(raw) > 0 {
|
||||
value = raw[0]
|
||||
}
|
||||
set, err := dependentOptions(r.Context(), cc, field.Name, field.DependsOn, value)
|
||||
if err != nil {
|
||||
slog.Error("cabana: dependent options failed", "controller", controllerID(cc), "field", field.Name, "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
tr := s.translator()
|
||||
locale := schemaLocale(r.Context(), tr)
|
||||
ctx := towel.WithLocale(r.Context(), locale)
|
||||
out := AdminDependentOptionsResult{Applies: set.Applies, Options: []AdminDependentOption{}}
|
||||
if set.Applies {
|
||||
for _, opt := range set.Options {
|
||||
out.Options = append(out.Options, AdminDependentOption{Value: opt.Value, Label: translateKey(ctx, tr, opt.Label), Depth: max(opt.Depth, 0)})
|
||||
}
|
||||
}
|
||||
WriteData(w, http.StatusOK, out, map[string]any{"locale": locale})
|
||||
})
|
||||
}
|
||||
@@ -44,7 +44,7 @@ var (
|
||||
"thumbOptions": {}, "useCaption": {}, "prompt": {},
|
||||
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
|
||||
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
|
||||
"preset": {},
|
||||
"preset": {}, "dependsOn": {},
|
||||
}
|
||||
// widgetKeys are valid only on `type: widget` (D-06).
|
||||
widgetKeys = []string{"widget", "action", "fill"}
|
||||
@@ -222,6 +222,9 @@ func compileForm(pluginID string, ctl pact.AdminController, fsys fs.FS, types *f
|
||||
if err := requireDropdownProvider(ctl, fields); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
||||
}
|
||||
if err := requireDependentProvider(ctl, fields); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
||||
}
|
||||
schema := &FormSchema{
|
||||
Name: doc.Name,
|
||||
ModelClass: doc.ModelClass,
|
||||
@@ -592,6 +595,9 @@ func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (For
|
||||
if err := compilePresetKey(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if err := compileDependsOn(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if node, ok := values["required"]; ok {
|
||||
field.Required, err = nodeBool(node)
|
||||
if err != nil {
|
||||
|
||||
@@ -438,7 +438,9 @@ func constrainFileID(g pact.Router) {
|
||||
func constrainNested(g pact.Router) {
|
||||
constrainController(g)
|
||||
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
// name admits a hyphen for the literal dependent-options segment; a
|
||||
// relation or file field name with one matches nothing and answers 404.
|
||||
g.Where("name", "[A-Za-z_][A-Za-z0-9_-]*")
|
||||
}
|
||||
|
||||
// nestedGet serves the logical routes
|
||||
@@ -446,6 +448,7 @@ func constrainNested(g pact.Router) {
|
||||
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
|
||||
// GET /{vendor}/{plugin}/{controller}/{id}/files/{field}
|
||||
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
|
||||
// GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options
|
||||
// GET /{vendor}/{plugin}/{controller}/filters/{scope}/options
|
||||
//
|
||||
// A numeric id never equals a literal segment, so the dispatch is unambiguous.
|
||||
@@ -456,6 +459,9 @@ func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) {
|
||||
case id == "fields" && name == "options":
|
||||
r.SetPathValue("field", segment)
|
||||
s.fieldOptions(w, r)
|
||||
case id == "fields" && name == "dependent-options":
|
||||
r.SetPathValue("field", segment)
|
||||
s.dependentOptionsRoute(w, r)
|
||||
case id == "filters" && name == "options":
|
||||
r.SetPathValue("scope", segment)
|
||||
s.filterOptions(w, r)
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"gocloud.dev/blob"
|
||||
"gocloud.dev/blob/memblob"
|
||||
@@ -286,6 +287,9 @@ func conformCases() []conformCase {
|
||||
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/options", 200, "cabana.ListEnvelope-array_cabana_RelationOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/fields/group/options?search="+e.stamp, nil, true)
|
||||
}, into[cabana.ListEnvelope[[]cabana.RelationOption]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 200, "cabana.Envelope-cabana_AdminDependentOptionsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, true)
|
||||
}, into[cabana.Envelope[cabana.AdminDependentOptionsResult]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
|
||||
}, into[cabana.Envelope[[]cabana.FilterOption]](), nil},
|
||||
@@ -794,6 +798,7 @@ func newConformEnv(t *testing.T) *conformEnv {
|
||||
if err := gdb.AutoMigrate(models...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resetCompanions(t, gdb)
|
||||
// Gadget ids restart with the recreated table: drop the files and
|
||||
// bindings an earlier run left for them.
|
||||
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type IN ('acme.conform.gadget', 'acme.conform.part') OR attachment_id IS NULL OR attachment_id = ''`).Error; err != nil {
|
||||
@@ -836,10 +841,15 @@ func newConformEnv(t *testing.T) *conformEnv {
|
||||
if err := attach.Publish(app, bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugins := []party.Plugin{conformPlugin{stamp: stamp}}
|
||||
// The acme.companions fixture serves the Phase 17 routes (dependent
|
||||
// options) next to the gadgets.
|
||||
plugins := []party.Plugin{conformPlugin{stamp: stamp}, companionsPlugin{app: app}}
|
||||
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := signpost.Activate(app, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -159,6 +159,9 @@ func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
if field.Preset != nil {
|
||||
return nil, fail(fmt.Errorf("field %s: preset is not supported in a relation form (%s)", field.Name, purpose))
|
||||
}
|
||||
if err := controllerFormOnly(field); err != nil {
|
||||
return nil, fail(fmt.Errorf("field %s: %w", field.Name, err))
|
||||
}
|
||||
if cr.hasMany() && field.Name == cr.Contract.ForeignKey {
|
||||
return nil, fail(fmt.Errorf("field %s is the relation's ForeignKey; the server sets it", field.Name))
|
||||
}
|
||||
|
||||
@@ -257,6 +257,10 @@ type FormField struct {
|
||||
Default *jsonScalar `json:"default,omitempty"`
|
||||
Attributes map[string]jsonScalar `json:"attributes,omitempty"`
|
||||
Options []FormOption `json:"options,omitempty"`
|
||||
// DependsOn names the sibling field (same form, or same repeater row)
|
||||
// whose value selects a dropdown's options; the options come from the
|
||||
// dependent-options route (D-08).
|
||||
DependsOn string `json:"dependsOn,omitempty"`
|
||||
// Widget is the custom-element tag of a `type: widget` field (D-06).
|
||||
Widget string `json:"widget,omitempty"`
|
||||
// Action names the controller action the widget runs (pact.HasAdminActions).
|
||||
|
||||
@@ -58,6 +58,7 @@ var phase09Routes = []adminRoute{
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/partials/{name}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", mounted: nestedGetRoute},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", mounted: nestedGetRoute},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}"},
|
||||
@@ -316,6 +317,7 @@ func phase09ProtectedCalls() []phase09Call {
|
||||
{"relation-schema", (*service).relationSchema},
|
||||
{"relation-linked", (*service).relationLinked},
|
||||
{"field-options", (*service).fieldOptions},
|
||||
{"dependent-options", (*service).dependentOptionsRoute},
|
||||
{"filter-options", (*service).filterOptions},
|
||||
{"nested-get", (*service).nestedGet},
|
||||
{"relation-candidates", (*service).relationCandidates},
|
||||
|
||||
@@ -89,6 +89,9 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
|
||||
if field.Preset != nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s: preset is not supported on a settings form", item.Code, field.Name)
|
||||
}
|
||||
if err := controllerFormOnly(field); err != nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s: %w", item.Code, field.Name, err)
|
||||
}
|
||||
}
|
||||
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
|
||||
columns := modelColumns(model)
|
||||
|
||||
4
modules/cabana/testdata/companions/controllers/links/config_form.yaml
vendored
Normal file
4
modules/cabana/testdata/companions/controllers/links/config_form.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
name: New link
|
||||
form: ~/plugins/acme/companions/models/link/fields.yaml
|
||||
modelClass: Link
|
||||
defaultRedirect: acme/companions/links
|
||||
7
modules/cabana/testdata/companions/controllers/links/config_list.yaml
vendored
Normal file
7
modules/cabana/testdata/companions/controllers/links/config_list.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
list: ~/plugins/acme/companions/models/link/columns.yaml
|
||||
modelClass: Link
|
||||
title: Links
|
||||
recordUrl: acme/companions/links/update/:id
|
||||
recordsPerPage: 20
|
||||
toolbar:
|
||||
buttons: [create]
|
||||
6
modules/cabana/testdata/companions/models/link/columns.yaml
vendored
Normal file
6
modules/cabana/testdata/companions/models/link/columns.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
columns:
|
||||
title:
|
||||
label: Title
|
||||
searchable: true
|
||||
type:
|
||||
label: Type
|
||||
14
modules/cabana/testdata/companions/models/link/fields.yaml
vendored
Normal file
14
modules/cabana/testdata/companions/models/link/fields.yaml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
fields:
|
||||
title:
|
||||
label: Title
|
||||
type: text
|
||||
required: true
|
||||
type:
|
||||
label: Type
|
||||
type: dropdown
|
||||
options: typeOptions
|
||||
reference:
|
||||
label: Reference
|
||||
type: dropdown
|
||||
dependsOn: type
|
||||
emptyOption: backend::lang.form.none
|
||||
@@ -55,6 +55,7 @@ var tokenRouteClasses = map[string]tokenRouteClass{
|
||||
"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}": classAllowsProtect,
|
||||
"GET /{vendor}/{plugin}/{controller}/partials/{name}": classAllowsProtect,
|
||||
"GET /{vendor}/{plugin}/{controller}/fields/{field}/options": classAllowsProtect,
|
||||
"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options": classAllowsProtect,
|
||||
"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options": classAllowsProtect,
|
||||
"GET /{vendor}/{plugin}/{controller}": classAllowsProtect,
|
||||
"POST /{vendor}/{plugin}/{controller}": classAllowsProtect,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,11 +8,11 @@ Compiled plugin registry that orders plugins by their dependencies and runs thei
|
||||
|
||||
## Overview
|
||||
|
||||
`party` is the WinterCMS PluginBase and PluginManager counterpart for plugins compiled into the binary. Each plugin package calls `party.Register` from its `init` function, and the application's generated `main` calls `party.Activate` with the plugin IDs listed in its manifest. Activation validates the selection, sorts it so every plugin comes after the plugins it requires, merges plugin config, runs every Register before any Boot, and wires translations and mail templates in between. Capabilities beyond the lifecycle are declared through the interfaces in [pact](../pact/README.md).
|
||||
`party` is the WinterCMS PluginBase and PluginManager counterpart for plugins compiled into the binary. Each plugin package calls `party.Register` from its `init` function, and the application's generated `main` calls `party.Activate` with the plugin IDs listed in its manifest. Activation validates the selection, sorts it so every plugin comes after the plugins it requires, merges plugin config, runs every Register before any Boot, and wires translations, mail templates and menu item types in between. Capabilities beyond the lifecycle are declared through the interfaces in [pact](../pact/README.md).
|
||||
|
||||
## Role in the framework
|
||||
|
||||
The plugin lifecycle, on top of `backpack`, `pact`, `phrasebook` and `postcard`. `lagoon`, `conga`, `cabana` and `surf` ask it which plugins are active and in what order. It is the counterpart of WinterCMS's plugin manager, without autoloading: plugins are registered at build time.
|
||||
The plugin lifecycle, on top of `backpack`, `pact`, `phrasebook`, `postcard` and `signpost`. `lagoon`, `conga`, `cabana` and `surf` ask it which plugins are active and in what order. It is the counterpart of WinterCMS's plugin manager, without autoloading: plugins are registered at build time.
|
||||
|
||||
Use cases:
|
||||
|
||||
@@ -26,7 +26,7 @@ Use cases:
|
||||
- A process-wide, concurrency-safe registry filled by `party.Register` (nil plugins are ignored).
|
||||
- `party.Activate` selects plugins by manifest ID and fails on an empty ID, a duplicate ID, an unregistered plugin, a missing requirement or a dependency cycle.
|
||||
- Stable topological ordering: plugins without a dependency relation keep their manifest order.
|
||||
- Activation sequence: records the ordered IDs on the `backpack.App`, publishes `party.Activated` (the ordered plugin list for commands that need plugin scopes at Run time), merges each `pact.HasConfig` tree into the app config under the plugin ID, runs every Register, publishes the translator ([phrasebook](../phrasebook/README.md)) and mailer ([postcard](../postcard/README.md)), then registers each plugin's mail templates and runs its Boot.
|
||||
- Activation sequence: records the ordered IDs on the `backpack.App`, publishes `party.Activated` (the ordered plugin list for commands that need plugin scopes at Run time), merges each `pact.HasConfig` tree into the app config under the plugin ID, runs every Register, publishes the translator ([phrasebook](../phrasebook/README.md)), the mailer ([postcard](../postcard/README.md)) and the menu item type registry ([signpost](../signpost/README.md), collected from every `pact.HasMenuItemTypes` plugin; a duplicate type key fails the activation), then registers each plugin's mail templates and runs its Boot.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -72,12 +72,12 @@ if err != nil {
|
||||
|------------|-------------|
|
||||
| `party.Plugin` | Interface every compiled plugin implements: ID, required plugin IDs, Register and Boot. |
|
||||
| `party.Register` | Adds a plugin to the process-wide registry; called from the plugin's `init`. |
|
||||
| `party.Activate` | Selects registered plugins by ID, orders them by `party.Plugin.Requires` and runs the config, Register, translation, mail and Boot steps; returns the ordered plugins. |
|
||||
| `party.Activate` | Selects registered plugins by ID, orders them by `party.Plugin.Requires` and runs the config, Register, translation, mail, menu item type and Boot steps; returns the ordered plugins. |
|
||||
| `party.Activated` | The ordered `[]party.Plugin` published by `party.Activate`; commands look it up at Run time with `app.Lookup[party.Activated]()`. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [pact](../pact/README.md), [phrasebook](../phrasebook/README.md), [postcard](../postcard/README.md).
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [pact](../pact/README.md), [phrasebook](../phrasebook/README.md), [postcard](../postcard/README.md), [signpost](../signpost/README.md).
|
||||
- Third-party: none.
|
||||
- Standard library: `fmt`, `strings`, `sync`.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
)
|
||||
|
||||
// Plugin is the required descriptor every compiled plugin implements.
|
||||
@@ -131,6 +132,12 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e
|
||||
if err := postcard.Activate(app, ordered); err != nil {
|
||||
return nil, fmt.Errorf("party: mail: %w", err)
|
||||
}
|
||||
// Menu item types (pact.HasMenuItemTypes) are collected after every
|
||||
// Register and before the first Boot, so a plugin's Boot can read the
|
||||
// registry.
|
||||
if err := signpost.Activate(app, ordered); err != nil {
|
||||
return nil, fmt.Errorf("party: menu item types: %w", err)
|
||||
}
|
||||
for _, p := range ordered {
|
||||
if err := postcard.BootPlugin(app, p); err != nil {
|
||||
return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err)
|
||||
|
||||
125
modules/signpost/README.md
Normal file
125
modules/signpost/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# signpost
|
||||
|
||||
Menu item type registry that collects the typed menu item types plugins register at boot, layers their URL patterns with host overrides and resolves menu and sitemap items through the owning plugin.
|
||||
|
||||
`import "git.golem15.com/golem15/summercms/modules/signpost"`
|
||||
|
||||
**Name:** A beach signpost points the way to places; the registry tells menus and sitemaps where each item leads.
|
||||
|
||||
## Overview
|
||||
|
||||
`signpost` is the typed successor of WinterCMS's `pages.menuitem.listTypes`, `pages.menuitem.getTypeInfo` and `pages.menuitem.resolveItem` events. A content plugin implements `pact.HasMenuItemTypes` and returns `pact.MenuItemType` values: each carries its key, label, default URL pattern, the records an item can point at (`References`) and a `Resolve` function. [party](../party/README.md) calls `signpost.Activate` once at boot, after every Register and before the first Boot, and the registry is published on the application. Menu and sitemap plugins read it with `app.Lookup[*signpost.Registry]()`, offer its types in their admin forms and resolve their items through it. No string event carries the list, the type info or the resolve call.
|
||||
|
||||
## Role in the framework
|
||||
|
||||
A small registry between content plugins (which own records and their URLs) and consumer plugins (menus, sitemaps). It depends on [backpack](../backpack/README.md) and [pact](../pact/README.md) only, so the framework never imports a plugin. Its one admin binding is `signpost.ReferenceOptions`, which a controller's `pact.DependentOptionsProvider` returns for a reference dropdown that depends on a type dropdown (see [Forms](../../docs/backend/forms.md#dependent-dropdowns)).
|
||||
|
||||
Use cases:
|
||||
|
||||
- Let a blog plugin offer "post", "category" and "all posts" as menu item types without the menu plugin importing it.
|
||||
- Build headless URLs from patterns such as `/blog/:slug`, and let the host application change them per type.
|
||||
- Fill the reference dropdown of a menu entry or a sitemap row with the records of the chosen type.
|
||||
|
||||
## Features
|
||||
|
||||
- Boot-time collection in plugin order, then declaration order. Every error is prefixed `signpost:` and fails the boot: an empty key, a key two plugins register (the message names the key and both plugin IDs), a nil `Resolve`, a `DefaultPattern` that is empty or does not start with `/`, a `PatternFrom` that names an unknown type, the type itself or a type that sets `PatternFrom` too, and a host override for an unknown type or one that does not start with `/`.
|
||||
- URL patterns in layers (`signpost.Registry.Pattern`): the item's own override, else the host override of the type, else, for a list type that sets `PatternFrom`, the host override of the record type it lists, else the type's `DefaultPattern`. A host that changes the post URL once therefore also changes the children of every list of posts, unless the list type itself is overridden.
|
||||
- `signpost.Registry.Resolve` hands the layered pattern to the type's `Resolve` in `pact.MenuItemResolve.Pattern`. The result, `pact.ResolvedMenuItem`, carries the title, URL, modification time, nested items, and the alternates per locale as an ordered `[]pact.MenuItemAlternate`, never a map.
|
||||
- `signpost.ReferenceOptions` flattens a type's reference tree depth first into dropdown options with a `Depth`, and answers `Applies` false for a type that takes no reference or that no plugin registers.
|
||||
|
||||
## Usage
|
||||
|
||||
A content plugin registers its types:
|
||||
|
||||
```go
|
||||
package blog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasMenuItemTypes = (*Plugin)(nil)
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{{
|
||||
Key: "acme-post",
|
||||
Label: "acme.blog::lang.menuitem.post",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
Placeholders: []string{":slug"},
|
||||
References: func(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
return []pact.MenuItemReference{{Value: "hello-world", Label: "Hello world"}}, nil
|
||||
},
|
||||
Resolve: func(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
url := strings.ReplaceAll(in.Pattern, ":slug", in.Reference)
|
||||
return &pact.ResolvedMenuItem{
|
||||
Title: "Hello world",
|
||||
URL: url,
|
||||
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: url, Title: "Hello world"}},
|
||||
}, nil
|
||||
},
|
||||
}, {
|
||||
Key: "acme-all-posts",
|
||||
Label: "acme.blog::lang.menuitem.all_posts",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
PatternFrom: "acme-post",
|
||||
Nesting: true,
|
||||
Resolve: resolveAllPosts,
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
A menu plugin resolves an entry and serves the reference dropdown of its form:
|
||||
|
||||
```go
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
item, err := reg.Resolve(ctx, entry.Type, entry.Reference, entry.URLPattern, true, "")
|
||||
|
||||
func (c entriesController) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
|
||||
typ, _ := values["type"].(string)
|
||||
return signpost.ReferenceOptions(ctx, c.registry(), typ)
|
||||
}
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `signpost.Activate` | Collects the `pact.HasMenuItemTypes` types of the activated plugins, reads the host overrides and publishes one `*signpost.Registry`; party calls it after Register and before Boot. |
|
||||
| `signpost.Registry` | The read-only registry of menu item types and host pattern overrides. |
|
||||
| `signpost.Registry.Types` | Every registered type, in plugin order and then declaration order. |
|
||||
| `signpost.Registry.Type` | The type registered under a key. |
|
||||
| `signpost.Registry.Pattern` | The layered URL pattern of an item: item override, host override, the record type's host override for a list type, default. |
|
||||
| `signpost.Registry.Resolve` | Resolves one item through its type with the layered pattern; an unknown type is an error. |
|
||||
| `signpost.ReferenceOptions` | The dependent dropdown options of a type's references, flattened with their depth; `Applies` false for a type without references. |
|
||||
| `signpost.ConfigKey` | `menu_items.patterns`, the host configuration key of the pattern overrides. |
|
||||
|
||||
## Configuration
|
||||
|
||||
The host application overrides a type's URL pattern in `config/menu_items.yaml`:
|
||||
|
||||
```yaml
|
||||
patterns:
|
||||
acme-post: /news/:slug
|
||||
acme-category: /:slug
|
||||
```
|
||||
|
||||
`menu_items.patterns` is a map from type key to pattern, read once at boot. Type keys contain dashes, so the map can only be set in a YAML file: there is no `SUMMER_` environment override for it. An override for a type no plugin registers, or one that does not start with `/`, fails the boot.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [pact](../pact/README.md).
|
||||
- Third-party: none.
|
||||
- Standard library: `context`, `fmt`, `strings`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
go test ./modules/signpost/...
|
||||
```
|
||||
|
||||
The tests use an in-memory configuration and need no external services.
|
||||
175
modules/signpost/example_test.go
Normal file
175
modules/signpost/example_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
226
modules/signpost/signpost.go
Normal file
226
modules/signpost/signpost.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Package signpost is the menu item type registry: it collects the typed menu
|
||||
// item types plugins register through pact.HasMenuItemTypes once at boot,
|
||||
// layers their URL patterns with the host's overrides and resolves items
|
||||
// through the owning plugin.
|
||||
package signpost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// ConfigKey is the host configuration key of the URL pattern overrides: a
|
||||
// map from menu item type key to pattern, such as
|
||||
// menu_items.patterns.acme-post: /news/:slug. It is read once at boot.
|
||||
const ConfigKey = "menu_items.patterns"
|
||||
|
||||
// Registry holds every registered menu item type and the host's pattern
|
||||
// overrides. It is built once by Activate and read-only afterwards, so it is
|
||||
// safe for concurrent use.
|
||||
type Registry struct {
|
||||
types []pact.MenuItemType
|
||||
byKey map[string]int
|
||||
owners map[string]string
|
||||
host map[string]string
|
||||
}
|
||||
|
||||
// Activate collects the menu item types of every plugin that implements
|
||||
// pact.HasMenuItemTypes, in plugin order and then declaration order, reads the
|
||||
// host pattern overrides at ConfigKey and publishes one *Registry on app.
|
||||
// Consumers read it with app.Lookup[*signpost.Registry](). Every error is
|
||||
// prefixed "signpost: " and fails the boot: an empty key, a key two plugins
|
||||
// register (the message names both plugin IDs), a nil Resolve, a
|
||||
// DefaultPattern that is empty or does not start with "/", a PatternFrom that
|
||||
// names an unknown type, the type itself or a type that sets PatternFrom, and
|
||||
// a host override for an unknown type or not starting with "/".
|
||||
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("signpost: app is nil")
|
||||
}
|
||||
reg := &Registry{byKey: map[string]int{}, owners: map[string]string{}, host: map[string]string{}}
|
||||
for _, p := range plugins {
|
||||
src, ok := any(p).(pact.HasMenuItemTypes)
|
||||
if !ok || src == nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range src.MenuItemTypes() {
|
||||
if err := reg.add(p.ID(), t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := reg.checkPatternFrom(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reg.loadHost(app); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Publish(reg); err != nil {
|
||||
return fmt.Errorf("signpost: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) add(owner string, t pact.MenuItemType) error {
|
||||
if strings.TrimSpace(t.Key) == "" {
|
||||
return fmt.Errorf("signpost: plugin %s registers a menu item type with an empty key", owner)
|
||||
}
|
||||
if first, dup := r.owners[t.Key]; dup {
|
||||
return fmt.Errorf("signpost: menu item type %q is registered by both %s and %s", t.Key, first, owner)
|
||||
}
|
||||
if t.Resolve == nil {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s has no Resolve", t.Key, owner)
|
||||
}
|
||||
if !strings.HasPrefix(t.DefaultPattern, "/") {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s needs a DefaultPattern starting with \"/\", got %q", t.Key, owner, t.DefaultPattern)
|
||||
}
|
||||
t.Placeholders = append([]string(nil), t.Placeholders...)
|
||||
r.owners[t.Key] = owner
|
||||
r.byKey[t.Key] = len(r.types)
|
||||
r.types = append(r.types, t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPatternFrom runs after every type is collected: a list type may name
|
||||
// only another registered type that is not itself a list type.
|
||||
func (r *Registry) checkPatternFrom() error {
|
||||
for _, t := range r.types {
|
||||
if t.PatternFrom == "" {
|
||||
continue
|
||||
}
|
||||
if t.PatternFrom == t.Key {
|
||||
return fmt.Errorf("signpost: menu item type %q names itself in PatternFrom", t.Key)
|
||||
}
|
||||
target, ok := r.byKey[t.PatternFrom]
|
||||
if !ok {
|
||||
return fmt.Errorf("signpost: menu item type %q has PatternFrom %q, which no plugin registers", t.Key, t.PatternFrom)
|
||||
}
|
||||
if r.types[target].PatternFrom != "" {
|
||||
return fmt.Errorf("signpost: menu item type %q has PatternFrom %q, which sets PatternFrom itself (one level only)", t.Key, t.PatternFrom)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadHost reads the host pattern overrides. An absent key means none.
|
||||
func (r *Registry) loadHost(app *backpack.App) error {
|
||||
if app.Config == nil || !app.Config.Has(ConfigKey) {
|
||||
return nil
|
||||
}
|
||||
patterns := map[string]string{}
|
||||
if err := app.Config.LoadSection(ConfigKey, &patterns); err != nil {
|
||||
return fmt.Errorf("signpost: %s must be a map of menu item type to URL pattern: %w", ConfigKey, err)
|
||||
}
|
||||
for key, pattern := range patterns {
|
||||
if _, ok := r.byKey[key]; !ok {
|
||||
return fmt.Errorf("signpost: %s overrides %q, which no plugin registers", ConfigKey, key)
|
||||
}
|
||||
if !strings.HasPrefix(pattern, "/") {
|
||||
return fmt.Errorf("signpost: %s.%s must start with \"/\", got %q", ConfigKey, key, pattern)
|
||||
}
|
||||
r.host[key] = pattern
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Types returns every registered type in plugin activation order, then
|
||||
// declaration order. The slice is a copy.
|
||||
func (r *Registry) Types() []pact.MenuItemType {
|
||||
if r == nil {
|
||||
return []pact.MenuItemType{}
|
||||
}
|
||||
return append([]pact.MenuItemType{}, r.types...)
|
||||
}
|
||||
|
||||
// Type returns the type registered under key.
|
||||
func (r *Registry) Type(key string) (pact.MenuItemType, bool) {
|
||||
if r == nil {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
i, ok := r.byKey[key]
|
||||
if !ok {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
return r.types[i], true
|
||||
}
|
||||
|
||||
// Pattern returns the URL pattern of an item of type key, in layers: the
|
||||
// item's own override when it is not empty, else the host override of key,
|
||||
// else, for a list type that sets PatternFrom, the host override of that
|
||||
// record type, else the type's DefaultPattern. An unknown key without an item
|
||||
// override answers "".
|
||||
func (r *Registry) Pattern(key, itemOverride string) string {
|
||||
if p := strings.TrimSpace(itemOverride); p != "" {
|
||||
return p
|
||||
}
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if p, ok := r.host[key]; ok {
|
||||
return p
|
||||
}
|
||||
t, ok := r.Type(key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if t.PatternFrom != "" {
|
||||
if p, ok := r.host[t.PatternFrom]; ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return t.DefaultPattern
|
||||
}
|
||||
|
||||
// Resolve resolves one item of type key through the type's Resolve, handing
|
||||
// it the layered pattern (Pattern(key, itemPattern)). An unknown key is an
|
||||
// error.
|
||||
func (r *Registry) Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error) {
|
||||
t, ok := r.Type(key)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("signpost: unknown menu item type %q", key)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return t.Resolve(ctx, pact.MenuItemResolve{
|
||||
Type: key,
|
||||
Reference: reference,
|
||||
Pattern: r.Pattern(key, itemPattern),
|
||||
Nesting: nesting,
|
||||
UnprefixedLocale: unprefixedLocale,
|
||||
})
|
||||
}
|
||||
|
||||
// ReferenceOptions is the one admin binding of the registry: the options of a
|
||||
// reference dropdown that depends on a type dropdown. A type the registry
|
||||
// does not know, or one without References, answers Applies false. Otherwise
|
||||
// the reference tree is flattened depth first into options whose Depth is the
|
||||
// nesting level (0 at the top). Labels are passed through as the type returns
|
||||
// them.
|
||||
func ReferenceOptions(ctx context.Context, r *Registry, typeKey string) (pact.DependentOptionSet, error) {
|
||||
t, ok := r.Type(typeKey)
|
||||
if !ok || t.References == nil {
|
||||
return pact.DependentOptionSet{Applies: false, Options: []pact.Option{}}, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
refs, err := t.References(ctx)
|
||||
if err != nil {
|
||||
return pact.DependentOptionSet{}, fmt.Errorf("signpost: references of %q: %w", typeKey, err)
|
||||
}
|
||||
options := []pact.Option{}
|
||||
var walk func(items []pact.MenuItemReference, depth int)
|
||||
walk = func(items []pact.MenuItemReference, depth int) {
|
||||
for _, item := range items {
|
||||
options = append(options, pact.Option{Value: item.Value, Label: item.Label, Depth: depth})
|
||||
walk(item.Items, depth+1)
|
||||
}
|
||||
}
|
||||
walk(refs, 0)
|
||||
return pact.DependentOptionSet{Applies: true, Options: options}, nil
|
||||
}
|
||||
153
modules/signpost/signpost_smoke_test.go
Normal file
153
modules/signpost/signpost_smoke_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package signpost_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type typesPlugin struct {
|
||||
id string
|
||||
types []pact.MenuItemType
|
||||
}
|
||||
|
||||
func (p typesPlugin) ID() string { return p.id }
|
||||
func (p typesPlugin) MenuItemTypes() []pact.MenuItemType { return p.types }
|
||||
|
||||
// echo resolves an item to its layered pattern, so a test sees the layer the
|
||||
// registry picked.
|
||||
func echo(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return &pact.ResolvedMenuItem{Title: in.Type, URL: in.Pattern, Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: in.Pattern}}}, nil
|
||||
}
|
||||
|
||||
func smokeApp(t *testing.T, menuItems string) *backpack.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if menuItems != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "menu_items.yaml"), []byte(menuItems), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=testing"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
func acmeTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{
|
||||
{Key: "acme-post", Label: "Post", DefaultPattern: "/blog/:slug", Resolve: echo,
|
||||
References: func(context.Context) ([]pact.MenuItemReference, error) {
|
||||
return []pact.MenuItemReference{{Value: "1", Label: "First", Items: []pact.MenuItemReference{{Value: "2", Label: "Child"}}}, {Value: "3", Label: "Second"}}, nil
|
||||
}},
|
||||
{Key: "acme-posts", Label: "All posts", DefaultPattern: "/blog/:slug", PatternFrom: "acme-post", Resolve: echo},
|
||||
{Key: "acme-page", Label: "Page", DefaultPattern: "/:slug", Resolve: echo},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignpostSmoke checks the registry's boot refusals and the D-02 pattern
|
||||
// layers, including a list type that follows its record type's host override.
|
||||
func TestSignpostSmoke(t *testing.T) {
|
||||
t.Run("duplicate key names both plugins", func(t *testing.T) {
|
||||
app := smokeApp(t, "")
|
||||
err := signpost.Activate(app, []typesPlugin{
|
||||
{id: "acme.blog", types: []pact.MenuItemType{{Key: "acme-post", DefaultPattern: "/a", Resolve: echo}}},
|
||||
{id: "acme.news", types: []pact.MenuItemType{{Key: "acme-post", DefaultPattern: "/b", Resolve: echo}}},
|
||||
})
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "signpost: ") || !strings.Contains(err.Error(), "acme.blog") || !strings.Contains(err.Error(), "acme.news") || !strings.Contains(err.Error(), "acme-post") {
|
||||
t.Fatalf("duplicate key error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("boot refusals", func(t *testing.T) {
|
||||
for name, types := range map[string][]pact.MenuItemType{
|
||||
"empty key": {{Key: "", DefaultPattern: "/a", Resolve: echo}},
|
||||
"nil resolve": {{Key: "acme-a", DefaultPattern: "/a"}},
|
||||
"empty pattern": {{Key: "acme-a", Resolve: echo}},
|
||||
"relative pattern": {{Key: "acme-a", DefaultPattern: "a/:slug", Resolve: echo}},
|
||||
"dangling PatternFrom": {{Key: "acme-a", DefaultPattern: "/a", PatternFrom: "acme-missing", Resolve: echo}},
|
||||
"self PatternFrom": {{Key: "acme-a", DefaultPattern: "/a", PatternFrom: "acme-a", Resolve: echo}},
|
||||
"chained PatternFrom": {
|
||||
{Key: "acme-a", DefaultPattern: "/a", Resolve: echo},
|
||||
{Key: "acme-b", DefaultPattern: "/b", PatternFrom: "acme-a", Resolve: echo},
|
||||
{Key: "acme-c", DefaultPattern: "/c", PatternFrom: "acme-b", Resolve: echo},
|
||||
},
|
||||
} {
|
||||
err := signpost.Activate(smokeApp(t, ""), []typesPlugin{{id: "acme.blog", types: types}})
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "signpost: ") {
|
||||
t.Fatalf("%s: error = %v", name, err)
|
||||
}
|
||||
}
|
||||
err := signpost.Activate(smokeApp(t, "patterns:\n acme-unknown: /x\n"), []typesPlugin{{id: "acme.blog", types: acmeTypes()}})
|
||||
if err == nil || !strings.Contains(err.Error(), "acme-unknown") {
|
||||
t.Fatalf("unknown host override error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("pattern layers", func(t *testing.T) {
|
||||
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-page: /p/:slug\n")
|
||||
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, ok := app.Lookup[*signpost.Registry]()
|
||||
if !ok || len(reg.Types()) != 3 {
|
||||
t.Fatalf("registry not published: %v", ok)
|
||||
}
|
||||
for _, tc := range []struct{ key, item, want string }{
|
||||
{"acme-page", "", "/p/:slug"}, // host override
|
||||
{"acme-page", "/custom/:slug", "/custom/:slug"}, // item override beats host
|
||||
{"acme-post", "", "/news/:slug"}, // host override
|
||||
{"acme-posts", "", "/news/:slug"}, // list type follows its record type's host override
|
||||
{"acme-posts", "/mine/:slug", "/mine/:slug"}, // item override beats both
|
||||
{"acme-unknown", "", ""},
|
||||
} {
|
||||
if got := reg.Pattern(tc.key, tc.item); got != tc.want {
|
||||
t.Fatalf("Pattern(%q, %q) = %q, want %q", tc.key, tc.item, got, tc.want)
|
||||
}
|
||||
}
|
||||
resolved, err := reg.Resolve(context.Background(), "acme-posts", "", "", false, "")
|
||||
if err != nil || resolved.URL != "/news/:slug" {
|
||||
t.Fatalf("Resolve = %+v, %v", resolved, err)
|
||||
}
|
||||
if _, err := reg.Resolve(context.Background(), "acme-unknown", "", "", false, ""); err == nil {
|
||||
t.Fatal("unknown type resolved")
|
||||
}
|
||||
set, err := signpost.ReferenceOptions(context.Background(), reg, "acme-post")
|
||||
if err != nil || !set.Applies || len(set.Options) != 3 || set.Options[1].Depth != 1 || set.Options[2].Depth != 0 {
|
||||
t.Fatalf("ReferenceOptions = %+v, %v", set, err)
|
||||
}
|
||||
none, err := signpost.ReferenceOptions(context.Background(), reg, "acme-page")
|
||||
if err != nil || none.Applies {
|
||||
t.Fatalf("type without references = %+v, %v", none, err)
|
||||
}
|
||||
})
|
||||
t.Run("host override of the list type beats its record type", func(t *testing.T) {
|
||||
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-posts: /all/:slug\n")
|
||||
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
if got := reg.Pattern("acme-posts", ""); got != "/all/:slug" {
|
||||
t.Fatalf("list type host override = %q", got)
|
||||
}
|
||||
if got := reg.Pattern("acme-posts", "/item/:slug"); got != "/item/:slug" {
|
||||
t.Fatalf("item override = %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("default pattern without host config", func(t *testing.T) {
|
||||
app := smokeApp(t, "")
|
||||
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
if got := reg.Pattern("acme-posts", ""); got != "/blog/:slug" {
|
||||
t.Fatalf("default = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user