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

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