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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user