feat(17-01): menu item type registry and dependent dropdown binding

- pact: HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve,
  ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider,
  DependentOptionSet; Option gains Depth (omitempty)
- signpost (new module): boot-time registry with fail-loud key and pattern
  checks, layered URL patterns incl. PatternFrom for list types, host
  overrides at menu_items.patterns, Resolve and ReferenceOptions
- party: signpost.Activate after postcard, before Boot
- cabana: dependsOn on dropdowns (boot checks, controller-only), GET
  fields/{field}/dependent-options with swag, inventories, conformance case
- companions fixture, TestSignpostSmoke, TestCompanionsDependentSmoke, docs
This commit is contained in:
2026-10-12 15:09:14 +02:00
parent 9016e2a6fd
commit 0cdff4c258
29 changed files with 1752 additions and 14 deletions

View File

@@ -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

View File

@@ -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

View 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())
}
}

View 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})
})
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))
}

View File

@@ -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).

View File

@@ -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},

View File

@@ -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)

View File

@@ -0,0 +1,4 @@
name: New link
form: ~/plugins/acme/companions/models/link/fields.yaml
modelClass: Link
defaultRedirect: acme/companions/links

View 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]

View File

@@ -0,0 +1,6 @@
columns:
title:
label: Title
searchable: true
type:
label: Type

View 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

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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`.

View File

@@ -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
View 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.

View 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)
}
}

View 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
}

View 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)
}
})
}