feat(16.2-01): compile, decode and load plugin field types in cabana
- compileFieldTypes builds a per-boot field type registry (alias, core and cross-plugin collisions, element prefix, assets, option keys) passed down the compile functions; settings and relation forms refuse plugin aliases - fields.yaml accepts a plugin alias with the general keys plus the type's options (typeOptions, element, translatable in the schema); CheckOptions runs at boot; CompileFormWithTypes for plugin tests - save decodes plugin values inside the transaction (FormFieldError is a 422, other errors opaque 500) and answers meta.notices (FieldNotice) only when a type reported something; show and save serve values through Load - GET /field-types lists the types with versioned asset URLs; OpenAPI, schema.d.ts, README and docs/backend/field-types.md updated
This commit is contained in:
@@ -22,6 +22,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
|
||||
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
|
||||
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
|
||||
- Plugin field types: a plugin implementing `pact.HasFormFieldTypes` registers admin form field types under short aliases that any controller's `fields.yaml` uses as `type:`. Boot checks the aliases (no built-in type, no alias claimed by two plugins), the element prefix, the scripts and styles and each field's option keys and `CheckOptions`. A save runs the type's `Decode` inside the save transaction after the record is loaded: the decoded value is stored, a `pact.FormFieldError` is a 422 on the field, any other error an opaque 500, and the notices a type returns are localized into the response's `meta.notices` (`cabana.FieldNotice`), which is absent when no type reported anything. Show and save responses serve the value through the type's `Load`. The form schema carries `element`, `translatable` and `typeOptions` for such a field. Settings and relation forms refuse plugin field types. See [Plugin field types](../../docs/backend/field-types.md).
|
||||
- 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.
|
||||
@@ -50,6 +51,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
|
||||
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Needs any backend session. |
|
||||
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
|
||||
| GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. |
|
||||
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
|
||||
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
|
||||
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
|
||||
@@ -192,6 +194,11 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. |
|
||||
| `cabana.RuntimeCommands` | Returns the `admin:create` and `admin:reset-password` commands. |
|
||||
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
|
||||
| `cabana.CompileFormWithTypes` | `cabana.CompileForm` that also accepts the plugin field types of one plugin (`[]pact.FormFieldType`), with the boot checks of their aliases, option keys and `CheckOptions`. |
|
||||
| `cabana.FieldTypeAssets` / `cabana.FieldTypesResponse` | One entry of GET `/field-types` (`name`, `element`, `translatable`, `scripts`, `styles`) and the route's answer. |
|
||||
| `cabana.AdminFieldTypes` | Swag annotation of the field types route. |
|
||||
| `cabana.RecordMeta` | Meta of a record response: `labels` of relation fields, `actions` (show only) and `notices` (create and update only, omitted when empty). |
|
||||
| `cabana.FieldNotice` | One entry of `meta.notices`: the `field`, the content `locale` of a translatable field type's value, and the localized `message` a field type's `Decode` reported. At most 10 per field and locale, each cut to 500 characters. |
|
||||
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
|
||||
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
|
||||
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. `cabana.Registry.Setting` returns singleton settings only, never a controller link. |
|
||||
|
||||
@@ -261,6 +261,20 @@ func AdminAccountAvatarRemove() {}
|
||||
// @Router /navigation [get]
|
||||
func AdminNavigation() {}
|
||||
|
||||
// AdminFieldTypes documents GET /field-types.
|
||||
//
|
||||
// @Summary Plugin field types
|
||||
// @Description Every admin form field type a plugin registers (pact.HasFormFieldTypes), sorted by name, with the URLs of its scripts and stylesheets (each with a ?v= content hash). The admin loads every script once after sign-in and keeps every stylesheet enabled on all pages; a form field whose `element` is set renders that custom element. An empty list when no plugin registers a type. Any backend session may call it.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} FieldTypesResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /field-types [get]
|
||||
func AdminFieldTypes() {}
|
||||
|
||||
// AdminSettingsList documents GET /settings.
|
||||
//
|
||||
// @Summary List admin settings
|
||||
@@ -356,7 +370,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.
|
||||
// @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).
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
@@ -454,7 +468,7 @@ func AdminList() {}
|
||||
// AdminCreate documents the record create route.
|
||||
//
|
||||
// @Summary Create an admin record
|
||||
// @Description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels.
|
||||
// @Description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels. A plugin field type's value is decoded by its type inside the save; when a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -643,6 +657,7 @@ func AdminShow() {}
|
||||
// AdminUpdate documents the record update route.
|
||||
//
|
||||
// @Summary Update an admin record
|
||||
// @Description A plugin field type's value is decoded by its type inside the save. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
||||
@@ -149,6 +149,9 @@ type Registry struct {
|
||||
// assets are every controller's declared plugin files keyed by URL tail
|
||||
// (vendor/plugin/<path after assets/>); the asset route serves only these.
|
||||
assets map[string]*pluginAsset
|
||||
// fieldTypes are the plugin field types of this boot
|
||||
// (pact.HasFormFieldTypes); nil when no plugin registers one.
|
||||
fieldTypes *fieldTypeRegistry
|
||||
}
|
||||
|
||||
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
|
||||
|
||||
@@ -201,7 +201,7 @@ func BindWritableFields(cc *CompiledController) error {
|
||||
cc.virtual = virtualFieldSet(cc.Controller)
|
||||
bindings := make([]WritableField, 0, len(cc.Form.Fields))
|
||||
for _, field := range cc.Form.Fields {
|
||||
if !scalarFormField(field.Type) || protectedFillKey(field.Name) || cc.virtual[field.Name] {
|
||||
if !valueFormField(field) || protectedFillKey(field.Name) || cc.virtual[field.Name] {
|
||||
continue
|
||||
}
|
||||
if _, known := cols[field.Name]; !known {
|
||||
@@ -444,6 +444,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
|
||||
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hydratePluginFields(ctx, tx, cc, target, result.Data, "update"); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
|
||||
return err
|
||||
})
|
||||
@@ -601,7 +604,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
var result RecordResult
|
||||
// Plugin field values are lifted the same way and decoded by their type
|
||||
// inside the transaction, once the record is loaded.
|
||||
pluginValues := liftPluginValues(cc, in.Body, op)
|
||||
var (
|
||||
result RecordResult
|
||||
notices []pluginNotice
|
||||
)
|
||||
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withVirtualFields(withTx(ctx, tx), virtual)
|
||||
target, err := newWritableModel(cc)
|
||||
@@ -618,6 +627,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
}
|
||||
}
|
||||
projected := projectOperation(cc, in.Body, op)
|
||||
notices, err = decodePluginFields(ctx, tx, s.tr, cc, target, pluginValues, projected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
|
||||
// A value that does not fit its column is the admin's input,
|
||||
// not a missing capability: answer it on the field.
|
||||
@@ -706,11 +719,15 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op)
|
||||
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op); err != nil {
|
||||
return err
|
||||
}
|
||||
return hydratePluginFields(ctx, tx, cc, target, result.Data, op)
|
||||
})
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
result.Meta.Notices = localizeNotices(ctx, s.tr, notices)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1070,7 +1087,7 @@ func mergedRules(ctx context.Context, cc *CompiledController, model any, op stri
|
||||
// Relation fields are not writable columns. required stays on the
|
||||
// schema for the client, but it cannot be checked by Fill. A virtual
|
||||
// field is checked against its submitted value.
|
||||
if field.Required && (scalarFormField(field.Type) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
|
||||
if field.Required && (valueFormField(field) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
|
||||
out[field.Name] = mergeRequired(out[field.Name])
|
||||
}
|
||||
}
|
||||
@@ -1231,6 +1248,12 @@ func scalarFormField(typ string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// valueFormField reports whether a controller form field binds to its model
|
||||
// column: a scalar built-in type or a plugin field type.
|
||||
func valueFormField(field FormField) bool {
|
||||
return scalarFormField(field.Type) || field.fieldType != nil
|
||||
}
|
||||
|
||||
func protectedFillKey(key string) bool {
|
||||
switch strings.ToLower(key) {
|
||||
case "id", "created_at", "updated_at", "deleted_at",
|
||||
|
||||
63
modules/cabana/example_field_type_test.go
Normal file
63
modules/cabana/example_field_type_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// NotesPlugin is the blog plugin with a notes field type.
|
||||
type NotesPlugin struct{ BlogPlugin }
|
||||
|
||||
// FormFieldTypes registers the notes field type: a custom element with one
|
||||
// option, rows, whose Decode trims the submitted text.
|
||||
func (NotesPlugin) FormFieldTypes() []pact.FormFieldType {
|
||||
return []pact.FormFieldType{{
|
||||
Name: "notes",
|
||||
Element: "acme-blog-notes",
|
||||
Scripts: []string{"assets/js/notes.js"},
|
||||
Options: []string{"rows"},
|
||||
CheckOptions: func(options map[string]any) error {
|
||||
if rows, ok := options["rows"].(int); ok && rows > 40 {
|
||||
return errors.New("rows must be at most 40")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Decode: func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
text, ok := in.Value.(string)
|
||||
if !ok {
|
||||
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"acme.blog::lang.notes.invalid"}}
|
||||
}
|
||||
return pact.FormFieldDecoded{Value: strings.TrimSpace(text)}, nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// notesController is an admin controller whose form uses the notes type.
|
||||
type notesController struct{}
|
||||
|
||||
func (notesController) ID() string { return "acme.blog.posts" }
|
||||
func (notesController) ModelName() string { return "Post" }
|
||||
func (notesController) ConfigDir() string { return "controllers/posts" }
|
||||
|
||||
func ExampleCompileFormWithTypes() {
|
||||
fsys := fstest.MapFS{
|
||||
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/acme/blog/models/post/fields.yaml\nmodelClass: Post\n")},
|
||||
"models/post/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n notes:\n label: Notes\n type: notes\n rows: 6\n")},
|
||||
}
|
||||
form, err := cabana.CompileFormWithTypes("acme.blog", notesController{}, fsys, NotesPlugin{}.FormFieldTypes())
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, f := range form.Fields {
|
||||
fmt.Println(f.Name, f.Type, f.Element, f.TypeOptions["rows"])
|
||||
}
|
||||
// Output:
|
||||
// notes notes acme-blog-notes 6
|
||||
}
|
||||
@@ -256,42 +256,14 @@ func compileClientAssets(pluginID string, cc *CompiledController, fsys fs.FS) er
|
||||
return nil
|
||||
}
|
||||
id := cc.Controller.ID()
|
||||
vendor, plugin, found := strings.Cut(pluginID, ".")
|
||||
if !found || !assetSegment.MatchString(vendor) || !assetSegment.MatchString(plugin) {
|
||||
if _, _, ok := assetPluginSegments(pluginID); !ok {
|
||||
return fmt.Errorf("cabana: admin controller %s/%s: plugin ID must be vendor.plugin to serve admin assets", pluginID, id)
|
||||
}
|
||||
read := func(files []string, exts ...string) ([]*pluginAsset, error) {
|
||||
out := make([]*pluginAsset, 0, len(files))
|
||||
seen := map[string]bool{}
|
||||
for _, name := range files {
|
||||
if err := checkAssetPath(name, exts); err != nil {
|
||||
return nil, bootErr(pluginID, id, name, err)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, bootErr(pluginID, id, name, fmt.Errorf("asset declared twice"))
|
||||
}
|
||||
seen[name] = true
|
||||
body, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, id, name, fmt.Errorf("asset is not in the plugin's embedded files: %w", err))
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
out = append(out, &pluginAsset{
|
||||
key: vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/"),
|
||||
body: body,
|
||||
contentType: boardwalk.ContentType(name),
|
||||
etag: `"` + digest + `"`,
|
||||
version: digest[:12],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
scripts, err := read(src.AdminJS(), ".js", ".mjs")
|
||||
scripts, err := readClientAssets(pluginID, id, fsys, src.AdminJS(), ".js", ".mjs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
styles, err := read(src.AdminCSS(), ".css")
|
||||
styles, err := readClientAssets(pluginID, id, fsys, src.AdminCSS(), ".css")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -299,6 +271,53 @@ func compileClientAssets(pluginID string, cc *CompiledController, fsys fs.FS) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// assetPluginSegments splits a vendor.plugin ID into the two URL segments of
|
||||
// its asset paths; ok is false when the ID cannot serve admin assets.
|
||||
func assetPluginSegments(pluginID string) (vendor, plugin string, ok bool) {
|
||||
vendor, plugin, found := strings.Cut(pluginID, ".")
|
||||
if !found || !assetSegment.MatchString(vendor) || !assetSegment.MatchString(plugin) {
|
||||
return "", "", false
|
||||
}
|
||||
return vendor, plugin, true
|
||||
}
|
||||
|
||||
// readClientAssets reads and hashes the declared files of one owner (an
|
||||
// admin controller ID or "field type <name>") from the plugin's embedded
|
||||
// tree. Each path must be clean, live under assets/, end in one of exts and
|
||||
// exist; a file declared twice fails. Errors are boot errors naming the
|
||||
// plugin, the owner and the file.
|
||||
func readClientAssets(pluginID, owner string, fsys fs.FS, files []string, exts ...string) ([]*pluginAsset, error) {
|
||||
vendor, plugin, ok := assetPluginSegments(pluginID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cabana: %s %s: plugin ID must be vendor.plugin to serve admin assets", pluginID, owner)
|
||||
}
|
||||
out := make([]*pluginAsset, 0, len(files))
|
||||
seen := map[string]bool{}
|
||||
for _, name := range files {
|
||||
if err := checkAssetPath(name, exts); err != nil {
|
||||
return nil, bootErr(pluginID, owner, name, err)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, bootErr(pluginID, owner, name, fmt.Errorf("asset declared twice"))
|
||||
}
|
||||
seen[name] = true
|
||||
body, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, owner, name, fmt.Errorf("asset is not in the plugin's embedded files: %w", err))
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
out = append(out, &pluginAsset{
|
||||
key: vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/"),
|
||||
body: body,
|
||||
contentType: boardwalk.ContentType(name),
|
||||
etag: `"` + digest + `"`,
|
||||
version: digest[:12],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func checkAssetPath(name string, exts []string) error {
|
||||
if name != path.Clean(name) || !strings.HasPrefix(name, "assets/") || len(name) == len("assets/") {
|
||||
return fmt.Errorf("asset path must be a clean path under assets/")
|
||||
|
||||
612
modules/cabana/field_type.go
Normal file
612
modules/cabana/field_type.go
Normal file
@@ -0,0 +1,612 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"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/towel"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
// fieldTypeAliasPattern is the shape of a plugin field type alias.
|
||||
fieldTypeAliasPattern = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
|
||||
// fieldTypeOptionPattern is the shape of a plugin field type option key.
|
||||
fieldTypeOptionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
|
||||
// pluginFieldKeys are the general fields.yaml keys a plugin-typed field
|
||||
// accepts next to its type's own options.
|
||||
pluginFieldKeys = map[string]struct{}{
|
||||
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
|
||||
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
// maxFieldNotices is how many notices one field keeps per locale.
|
||||
maxFieldNotices = 10
|
||||
// maxNoticeRunes caps one localized notice message.
|
||||
maxNoticeRunes = 500
|
||||
)
|
||||
|
||||
// compiledFieldType is one plugin field type after the boot checks.
|
||||
type compiledFieldType struct {
|
||||
owner string
|
||||
def pact.FormFieldType
|
||||
options map[string]bool
|
||||
scripts []*pluginAsset
|
||||
styles []*pluginAsset
|
||||
}
|
||||
|
||||
// fieldTypeRegistry is the plugin field types of one boot keyed by alias. It
|
||||
// is built per Activate by compileFieldTypes and passed down the compile
|
||||
// functions; cabana keeps no package-level registry. A refusing registry
|
||||
// knows the aliases but fails a form that uses one: settings and relation
|
||||
// forms support plugin field types in controller forms only.
|
||||
type fieldTypeRegistry struct {
|
||||
byName map[string]*compiledFieldType
|
||||
refuse bool
|
||||
}
|
||||
|
||||
// refusing returns the registry as one that refuses its aliases; nil stays nil.
|
||||
func (r *fieldTypeRegistry) refusing() *fieldTypeRegistry {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &fieldTypeRegistry{byName: r.byName, refuse: true}
|
||||
}
|
||||
|
||||
// names are the registered aliases in sorted order.
|
||||
func (r *fieldTypeRegistry) names() []string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(r.byName))
|
||||
for name := range r.byName {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// assets are every type's scripts and styles, in alias order.
|
||||
func (r *fieldTypeRegistry) assets() []*pluginAsset {
|
||||
var out []*pluginAsset
|
||||
for _, name := range r.names() {
|
||||
ft := r.byName[name]
|
||||
out = append(out, ft.scripts...)
|
||||
out = append(out, ft.styles...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fieldTypeErr(pluginID, name string, err error) error {
|
||||
return fmt.Errorf("cabana: plugin %s field type %s: %w", pluginID, name, err)
|
||||
}
|
||||
|
||||
// checkFieldTypeDef runs the checks that need no plugin files: the alias, the
|
||||
// required Decode and the option keys.
|
||||
func checkFieldTypeDef(def pact.FormFieldType) (map[string]bool, error) {
|
||||
if !fieldTypeAliasPattern.MatchString(def.Name) {
|
||||
return nil, fmt.Errorf("alias must match ^[a-z][a-z0-9]*$")
|
||||
}
|
||||
if _, core := formFieldTypes[def.Name]; core {
|
||||
return nil, fmt.Errorf("alias is a built-in field type")
|
||||
}
|
||||
if def.Decode == nil {
|
||||
return nil, fmt.Errorf("Decode is required")
|
||||
}
|
||||
options := make(map[string]bool, len(def.Options))
|
||||
for _, key := range def.Options {
|
||||
if !fieldTypeOptionPattern.MatchString(key) {
|
||||
return nil, fmt.Errorf("option %q must match ^[A-Za-z][A-Za-z0-9]*$", key)
|
||||
}
|
||||
if _, general := formFieldKeys[key]; general {
|
||||
return nil, fmt.Errorf("option %s is a general field key", key)
|
||||
}
|
||||
if options[key] {
|
||||
return nil, fmt.Errorf("option %s is declared twice", key)
|
||||
}
|
||||
options[key] = true
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// newFieldTypeRegistry builds a registry from one plugin's types with the
|
||||
// alias and option checks (CompileFormWithTypes). The element prefix and the
|
||||
// type's files are checked by compileFieldTypes at activation.
|
||||
func newFieldTypeRegistry(pluginID string, types []pact.FormFieldType) (*fieldTypeRegistry, error) {
|
||||
reg := &fieldTypeRegistry{byName: map[string]*compiledFieldType{}}
|
||||
for _, def := range types {
|
||||
options, err := checkFieldTypeDef(def)
|
||||
if err != nil {
|
||||
return nil, fieldTypeErr(pluginID, def.Name, err)
|
||||
}
|
||||
if _, dup := reg.byName[def.Name]; dup {
|
||||
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("alias is registered twice"))
|
||||
}
|
||||
reg.byName[def.Name] = &compiledFieldType{owner: pluginID, def: def, options: options}
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// compileFieldTypes collects every pact.HasFormFieldTypes plugin's field
|
||||
// types for one boot. Each failure stops boot with a message naming the
|
||||
// plugin and the alias; an alias two plugins claim names both. The result is
|
||||
// nil when no plugin registers a type.
|
||||
func compileFieldTypes(plugins []party.Plugin) (*fieldTypeRegistry, error) {
|
||||
reg := &fieldTypeRegistry{byName: map[string]*compiledFieldType{}}
|
||||
for _, p := range plugins {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
src, ok := p.(pact.HasFormFieldTypes)
|
||||
if !ok || src == nil {
|
||||
continue
|
||||
}
|
||||
pluginID := p.ID()
|
||||
for _, def := range src.FormFieldTypes() {
|
||||
options, err := checkFieldTypeDef(def)
|
||||
if err != nil {
|
||||
return nil, fieldTypeErr(pluginID, def.Name, err)
|
||||
}
|
||||
if prev, dup := reg.byName[def.Name]; dup {
|
||||
if prev.owner == pluginID {
|
||||
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("alias is registered twice"))
|
||||
}
|
||||
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("alias is already registered by plugin %s", prev.owner))
|
||||
}
|
||||
if err := checkWidgetTag(def.Element, widgetTagPrefix(pluginID)); err != nil {
|
||||
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("element: %w", err))
|
||||
}
|
||||
ft := &compiledFieldType{owner: pluginID, def: def, options: options}
|
||||
if len(def.Scripts)+len(def.Styles) > 0 {
|
||||
assets, ok := p.(pact.AdminAssets)
|
||||
if !ok || assets == nil || assets.AdminFS() == nil {
|
||||
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("scripts and styles need the plugin to implement pact.AdminAssets"))
|
||||
}
|
||||
owner := "field type " + def.Name
|
||||
if ft.scripts, err = readClientAssets(pluginID, owner, assets.AdminFS(), def.Scripts, ".js", ".mjs"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ft.styles, err = readClientAssets(pluginID, owner, assets.AdminFS(), def.Styles, ".css"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
reg.byName[def.Name] = ft
|
||||
}
|
||||
}
|
||||
if len(reg.byName) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// pluginFieldTypeOf resolves the `type:` of a fields.yaml entry to a plugin
|
||||
// field type. It is nil for a built-in type, a missing type and an alias no
|
||||
// plugin registers (compileFieldNode reports those). A refusing registry
|
||||
// fails a plugin alias.
|
||||
func pluginFieldTypeOf(mapping *ast.MappingNode, types *fieldTypeRegistry) (*compiledFieldType, error) {
|
||||
if types == nil {
|
||||
return nil, nil
|
||||
}
|
||||
for _, entry := range mapping.Values {
|
||||
key, err := nodeString(unwrapNode(entry.Key))
|
||||
if err != nil || key != "type" {
|
||||
continue
|
||||
}
|
||||
typ, err := nodeString(unwrapNode(entry.Value))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if _, core := formFieldTypes[typ]; core {
|
||||
return nil, nil
|
||||
}
|
||||
ft := types.byName[typ]
|
||||
if ft == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if types.refuse {
|
||||
return nil, fmt.Errorf("plugin field type %s is supported in controller forms only", typ)
|
||||
}
|
||||
return ft, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// fieldKeyAllowed reports whether key is a valid fields.yaml key for a field
|
||||
// of the plugin type ft, or of a built-in type when ft is nil.
|
||||
func fieldKeyAllowed(ft *compiledFieldType, key string) bool {
|
||||
if ft == nil {
|
||||
_, known := formFieldKeys[key]
|
||||
return known
|
||||
}
|
||||
if _, general := pluginFieldKeys[key]; general {
|
||||
return true
|
||||
}
|
||||
return ft.options[key]
|
||||
}
|
||||
|
||||
// compilePluginField sets the plugin type's schema keys on field: the
|
||||
// element, the translatable flag and the decoded options, then runs the
|
||||
// type's CheckOptions.
|
||||
func compilePluginField(ft *compiledFieldType, values map[string]ast.Node, field *FormField) error {
|
||||
field.Element = ft.def.Element
|
||||
field.Translatable = ft.def.Translatable
|
||||
field.fieldType = ft
|
||||
options := map[string]any{}
|
||||
for _, key := range ft.def.Options {
|
||||
node, ok := values[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, err := nodeJSONValue(node)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
options[key] = value
|
||||
}
|
||||
if len(options) > 0 {
|
||||
field.TypeOptions = options
|
||||
}
|
||||
if ft.def.CheckOptions != nil {
|
||||
if err := ft.def.CheckOptions(copyJSONMap(options)); err != nil {
|
||||
return fmt.Errorf("type %s: %w", field.Type, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeJSONValue decodes a YAML option value into a JSON-compatible value:
|
||||
// nil, string, bool, int, float64, []any or map[string]any.
|
||||
func nodeJSONValue(node ast.Node) (any, error) {
|
||||
switch n := unwrapNode(node).(type) {
|
||||
case nil, *ast.NullNode:
|
||||
return nil, nil
|
||||
case *ast.StringNode:
|
||||
return n.Value, nil
|
||||
case *ast.LiteralNode:
|
||||
if n.Value == nil {
|
||||
return "", nil
|
||||
}
|
||||
return n.Value.Value, nil
|
||||
case *ast.BoolNode:
|
||||
return n.Value, nil
|
||||
case *ast.IntegerNode:
|
||||
switch v := n.Value.(type) {
|
||||
case int64:
|
||||
return int(v), nil
|
||||
case uint64:
|
||||
if v <= uint64(^uint(0)>>1) {
|
||||
return int(v), nil
|
||||
}
|
||||
return float64(v), nil
|
||||
case int:
|
||||
return v, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("want an integer, got %T", v)
|
||||
}
|
||||
case *ast.FloatNode:
|
||||
return n.Value, nil
|
||||
case *ast.SequenceNode:
|
||||
items := sequenceValues(n)
|
||||
out := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
value, err := nodeJSONValue(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out, nil
|
||||
case *ast.MappingNode:
|
||||
return mappingJSONValue(n.Values)
|
||||
case *ast.MappingValueNode:
|
||||
return mappingJSONValue([]*ast.MappingValueNode{n})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported value %T", n)
|
||||
}
|
||||
}
|
||||
|
||||
func mappingJSONValue(entries []*ast.MappingValueNode) (map[string]any, error) {
|
||||
out := make(map[string]any, len(entries))
|
||||
for _, entry := range entries {
|
||||
key, err := nodeString(unwrapNode(entry.Key))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("key: %w", err)
|
||||
}
|
||||
if _, dup := out[key]; dup {
|
||||
return nil, fmt.Errorf("duplicate key %s", key)
|
||||
}
|
||||
value, err := nodeJSONValue(entry.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// copyJSONMap deep-copies decoded JSON options so plugin code never shares
|
||||
// the cached schema's maps. The result is never nil.
|
||||
func copyJSONMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = copyJSONValue(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyJSONValue(v any) any {
|
||||
switch value := v.(type) {
|
||||
case map[string]any:
|
||||
return copyJSONMap(value)
|
||||
case []any:
|
||||
out := make([]any, len(value))
|
||||
for i, item := range value {
|
||||
out[i] = copyJSONValue(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// pluginNotice is one notice a Decode returned, with its field and locale.
|
||||
type pluginNotice struct {
|
||||
field string
|
||||
locale string
|
||||
notice pact.FormFieldNotice
|
||||
}
|
||||
|
||||
// liftPluginValues takes the values of plugin-typed fields that are not
|
||||
// translatable out of a save body before scalar projection, which drops
|
||||
// nested values. Only fields present in the body whose context allows op are
|
||||
// lifted; the values are kept as decoded from the JSON body. The result is
|
||||
// never nil.
|
||||
func liftPluginValues(cc *CompiledController, body map[string]any, op string) map[string]any {
|
||||
out := map[string]any{}
|
||||
if cc == nil || cc.Form == nil || body == nil {
|
||||
return out
|
||||
}
|
||||
for _, field := range cc.Form.Fields {
|
||||
ft := field.fieldType
|
||||
if ft == nil || ft.def.Translatable || !contextAllows(cc, field.Name, op) {
|
||||
continue
|
||||
}
|
||||
value, present := body[field.Name]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
delete(body, field.Name)
|
||||
out[field.Name] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decodePluginFields runs each submitted plugin field value through its
|
||||
// type's Decode inside the save transaction, after the record is loaded and
|
||||
// before Fill, and stores the decoded value in projected. It returns the
|
||||
// notices the types reported. A *pact.FormFieldError is validation_failed on
|
||||
// the field; any other error is the opaque lifecycle error.
|
||||
func decodePluginFields(ctx context.Context, tx *gorm.DB, tr *phrasebook.Translator, cc *CompiledController, target any, values, projected map[string]any) ([]pluginNotice, error) {
|
||||
if cc == nil || cc.Form == nil || len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var notices []pluginNotice
|
||||
for _, field := range cc.Form.Fields {
|
||||
ft := field.fieldType
|
||||
if ft == nil {
|
||||
continue
|
||||
}
|
||||
value, ok := values[field.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out, err := ft.def.Decode(ctx, pact.FormFieldValue{
|
||||
DB: tx,
|
||||
Record: target,
|
||||
Field: field.Name,
|
||||
Options: copyJSONMap(field.TypeOptions),
|
||||
Default: true,
|
||||
Value: value,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fieldTypeFailure(ctx, tr, cc, field, "decode", err)
|
||||
}
|
||||
projected[field.Name] = out.Value
|
||||
for _, notice := range out.Notices {
|
||||
notices = append(notices, pluginNotice{field: field.Name, notice: notice})
|
||||
}
|
||||
}
|
||||
return notices, nil
|
||||
}
|
||||
|
||||
// fieldTypeFailure classifies a plugin field type's error: a
|
||||
// *pact.FormFieldError is validation_failed on the field with its messages
|
||||
// localized; anything else is logged and becomes the opaque lifecycle error
|
||||
// (500), so the plugin's text never reaches the client.
|
||||
func fieldTypeFailure(ctx context.Context, tr *phrasebook.Translator, cc *CompiledController, field FormField, stage string, err error) error {
|
||||
var refused *pact.FormFieldError
|
||||
if errors.As(err, &refused) && refused != nil {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
|
||||
messages := make([]string, 0, len(refused.Messages))
|
||||
for _, text := range refused.Messages {
|
||||
messages = append(messages, translateKey(ctx, tr, text))
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
messages = []string{"The " + field.Name + " field has an invalid value."}
|
||||
}
|
||||
return &ValidationError{Details: map[string]any{field.Name: messages}}
|
||||
}
|
||||
return opaqueFieldTypeFailure(cc, field, stage, err)
|
||||
}
|
||||
|
||||
// opaqueFieldTypeFailure logs a plugin field type's error and returns the
|
||||
// opaque lifecycle error (500 without the plugin's text).
|
||||
func opaqueFieldTypeFailure(cc *CompiledController, field FormField, stage string, err error) error {
|
||||
slog.Error("cabana: field type "+stage+" failed", "controller", controllerID(cc), "field", field.Name, "type", field.Type, "error", err)
|
||||
return &lifecycleError{ControllerID: controllerID(cc)}
|
||||
}
|
||||
|
||||
// hydratePluginFields serves each plugin field of a Show or save response
|
||||
// through its type's Load. A type without Load serves the stored value.
|
||||
func hydratePluginFields(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, data map[string]any, op string) error {
|
||||
if cc == nil || cc.Form == nil || data == nil {
|
||||
return nil
|
||||
}
|
||||
for _, field := range cc.Form.Fields {
|
||||
ft := field.fieldType
|
||||
if ft == nil || ft.def.Load == nil || !contextAllows(cc, field.Name, op) {
|
||||
continue
|
||||
}
|
||||
stored, ok := data[field.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, err := ft.def.Load(ctx, pact.FormFieldValue{
|
||||
DB: tx,
|
||||
Record: target,
|
||||
Field: field.Name,
|
||||
Options: copyJSONMap(field.TypeOptions),
|
||||
Default: true,
|
||||
Value: derefValue(stored),
|
||||
})
|
||||
if err != nil {
|
||||
// A Load error is never a 422, even a pact.FormFieldError:
|
||||
// nothing was submitted.
|
||||
return opaqueFieldTypeFailure(cc, field, "load", err)
|
||||
}
|
||||
data[field.Name] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// derefValue dereferences a projected column value; a nil pointer is nil.
|
||||
func derefValue(v any) any {
|
||||
rv := reflect.ValueOf(v)
|
||||
for rv.IsValid() && rv.Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
rv = rv.Elem()
|
||||
}
|
||||
if !rv.IsValid() || !rv.CanInterface() {
|
||||
return v
|
||||
}
|
||||
return rv.Interface()
|
||||
}
|
||||
|
||||
// localizeNotices turns the notices of a save into the response's
|
||||
// meta.notices in the request locale, the way localizeForbidden localizes a
|
||||
// refusal: Message is a phrase key or text, resolved with Choice when Count
|
||||
// is above zero and Get otherwise; without a translator the text is kept. At
|
||||
// most maxFieldNotices are kept per field and locale and each message is cut
|
||||
// to maxNoticeRunes. The result is nil when there is no notice, so the
|
||||
// response carries no notices key.
|
||||
func localizeNotices(ctx context.Context, tr *phrasebook.Translator, notices []pluginNotice) []FieldNotice {
|
||||
if len(notices) == 0 {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
|
||||
kept := map[[2]string]int{}
|
||||
var out []FieldNotice
|
||||
for _, item := range notices {
|
||||
if strings.TrimSpace(item.notice.Message) == "" {
|
||||
continue
|
||||
}
|
||||
key := [2]string{item.field, item.locale}
|
||||
if kept[key] >= maxFieldNotices {
|
||||
continue
|
||||
}
|
||||
kept[key]++
|
||||
message := item.notice.Message
|
||||
switch {
|
||||
case tr == nil:
|
||||
case item.notice.Count > 0:
|
||||
message = tr.Choice(ctx, message, item.notice.Count, item.notice.Params)
|
||||
default:
|
||||
message = tr.Get(ctx, message, item.notice.Params)
|
||||
}
|
||||
out = append(out, FieldNotice{Field: item.field, Locale: item.locale, Message: capRunes(message, maxNoticeRunes)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// capRunes cuts s to at most n runes, ending a cut text with an ellipsis.
|
||||
func capRunes(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:n-1]) + "…"
|
||||
}
|
||||
|
||||
// FieldTypeAssets is one plugin field type the admin loads on every page:
|
||||
// its alias, its custom-element tag, whether its value is a locale map, and
|
||||
// the same-origin URLs of its scripts and stylesheets, each with a ?v=
|
||||
// content hash.
|
||||
type FieldTypeAssets struct {
|
||||
Name string `json:"name"`
|
||||
Element string `json:"element"`
|
||||
Translatable bool `json:"translatable"`
|
||||
Scripts []string `json:"scripts"`
|
||||
Styles []string `json:"styles"`
|
||||
}
|
||||
|
||||
// FieldTypesResponse is the GET /field-types answer: every registered field
|
||||
// type sorted by name, an empty list when no plugin registers one.
|
||||
type FieldTypesResponse struct {
|
||||
Data []FieldTypeAssets `json:"data"`
|
||||
Meta SuccessMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// fieldTypeAssets lists the registered field types for the admin SPA.
|
||||
func (s *service) fieldTypeAssets() []FieldTypeAssets {
|
||||
out := []FieldTypeAssets{}
|
||||
if s == nil || s.reg == nil {
|
||||
return out
|
||||
}
|
||||
base := s.adminPrefix() + "/assets/"
|
||||
for _, name := range s.reg.fieldTypes.names() {
|
||||
ft := s.reg.fieldTypes.byName[name]
|
||||
entry := FieldTypeAssets{Name: name, Element: ft.def.Element, Translatable: ft.def.Translatable, Scripts: []string{}, Styles: []string{}}
|
||||
for _, file := range ft.scripts {
|
||||
entry.Scripts = append(entry.Scripts, base+file.key+"?v="+file.version)
|
||||
}
|
||||
for _, file := range ft.styles {
|
||||
entry.Styles = append(entry.Styles, base+file.key+"?v="+file.version)
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fieldTypesList answers GET {api}/field-types for any backend session: the
|
||||
// SPA loads every type's scripts once and enables its styles globally.
|
||||
func (s *service) fieldTypesList(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, s.fieldTypeAssets(), nil)
|
||||
}
|
||||
350
modules/cabana/field_type_smoke_test.go
Normal file
350
modules/cabana/field_type_smoke_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// ftNote is the model of the field type smoke controller.
|
||||
type ftNote struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Notes string `gorm:"column:notes"`
|
||||
Body string `gorm:"column:body"`
|
||||
}
|
||||
|
||||
func (*ftNote) Fillable() []string { return []string{"title", "notes", "body"} }
|
||||
func (*ftNote) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// ftController is an admin controller whose form uses plugin field types.
|
||||
type ftController struct{}
|
||||
|
||||
func (ftController) ID() string { return "acme.demo.notes" }
|
||||
func (ftController) ModelName() string { return "Note" }
|
||||
func (ftController) ConfigDir() string { return "controllers/notes" }
|
||||
func (ftController) NewRecord() any { return &ftNote{} }
|
||||
|
||||
// ftPlugin registers field types and serves an AdminFS.
|
||||
type ftPlugin struct {
|
||||
id string
|
||||
fsys fs.FS
|
||||
types []pact.FormFieldType
|
||||
}
|
||||
|
||||
func (p ftPlugin) ID() string { return p.id }
|
||||
func (ftPlugin) Requires() []string { return nil }
|
||||
func (ftPlugin) Register(*backpack.App) error { return nil }
|
||||
func (ftPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p ftPlugin) AdminFS() fs.FS { return p.fsys }
|
||||
func (p ftPlugin) FormFieldTypes() []pact.FormFieldType { return p.types }
|
||||
func (ftPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{ftController{}}
|
||||
}
|
||||
|
||||
var _ party.Plugin = ftPlugin{}
|
||||
|
||||
const ftFormConfig = `name: Note
|
||||
form: ~/plugins/acme/demo/models/note/fields.yaml
|
||||
modelClass: Note
|
||||
`
|
||||
|
||||
const ftListConfig = `list: ~/plugins/acme/demo/models/note/columns.yaml
|
||||
modelClass: Note
|
||||
recordsPerPage: 20
|
||||
`
|
||||
|
||||
// ftFS is the plugin tree: the notes script, the controller's list and form,
|
||||
// and fields.yaml with the given notes field body.
|
||||
func ftFS(notesField string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"assets/js/notes.js": &fstest.MapFile{Data: []byte("customElements.define('acme-demo-notes', class extends HTMLElement {})\n")},
|
||||
"controllers/notes/config_form.yaml": &fstest.MapFile{Data: []byte(ftFormConfig)},
|
||||
"controllers/notes/config_list.yaml": &fstest.MapFile{Data: []byte(ftListConfig)},
|
||||
"models/note/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
|
||||
"models/note/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n title:\n type: text\n label: Title\n notes:\n" + notesField)},
|
||||
}
|
||||
}
|
||||
|
||||
const ftNotesField = " type: notes\n label: Notes\n rows: 4\n"
|
||||
|
||||
// ftNotesType is the tracer type: Decode refuses "bad", trims surrounding
|
||||
// spaces and reports the trim as a notice; Load prefixes "served:".
|
||||
func ftNotesType() pact.FormFieldType {
|
||||
return pact.FormFieldType{
|
||||
Name: "notes",
|
||||
Element: "acme-demo-notes",
|
||||
Scripts: []string{"assets/js/notes.js"},
|
||||
Options: []string{"rows"},
|
||||
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
text, _ := in.Value.(string)
|
||||
if text == "bad" {
|
||||
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"acme.demo::lang.bad"}}
|
||||
}
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed != text {
|
||||
return pact.FormFieldDecoded{Value: trimmed, Notices: []pact.FormFieldNotice{{Message: "acme.demo::lang.trimmed", Count: 1}}}, nil
|
||||
}
|
||||
return pact.FormFieldDecoded{Value: text}, nil
|
||||
},
|
||||
Load: func(_ context.Context, in pact.FormFieldValue) (any, error) {
|
||||
text, _ := in.Value.(string)
|
||||
return "served:" + text, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ftCompile runs the boot path for plugins: compileFieldTypes, then the
|
||||
// registry of their controllers with that field type registry.
|
||||
func ftCompile(plugins ...party.Plugin) (*Registry, error) {
|
||||
types, err := compileFieldTypes(plugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := collectControllers(plugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compileRegistryWithTypes(items, types)
|
||||
}
|
||||
|
||||
func ftDemo(notesField string, types ...pact.FormFieldType) ftPlugin {
|
||||
if len(types) == 0 {
|
||||
types = []pact.FormFieldType{ftNotesType()}
|
||||
}
|
||||
return ftPlugin{id: "acme.demo", fsys: ftFS(notesField), types: types}
|
||||
}
|
||||
|
||||
func ftRequest(method, target string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
return req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}))
|
||||
}
|
||||
|
||||
func TestFieldTypeTracerSmoke(t *testing.T) {
|
||||
t.Run("alias collision between two plugins fails boot naming both", func(t *testing.T) {
|
||||
other := ftPlugin{id: "acme.other", fsys: fstest.MapFS{"assets/js/notes.js": &fstest.MapFile{Data: []byte("x")}}, types: []pact.FormFieldType{{
|
||||
Name: "notes", Element: "acme-other-notes", Scripts: []string{"assets/js/notes.js"},
|
||||
Decode: func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
return pact.FormFieldDecoded{}, nil
|
||||
},
|
||||
}}}
|
||||
_, err := compileFieldTypes([]party.Plugin{ftDemo(ftNotesField), other})
|
||||
if err == nil || !strings.Contains(err.Error(), "acme.other") || !strings.Contains(err.Error(), "acme.demo") || !strings.Contains(err.Error(), "notes") {
|
||||
t.Fatalf("err=%v, want a collision naming acme.demo and acme.other", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias equal to a core type fails boot", func(t *testing.T) {
|
||||
core := ftNotesType()
|
||||
core.Name = "markdown"
|
||||
_, err := compileFieldTypes([]party.Plugin{ftDemo(ftNotesField, core)})
|
||||
if err == nil || !strings.Contains(err.Error(), "markdown") || !strings.Contains(err.Error(), "built-in") {
|
||||
t.Fatalf("err=%v, want a built-in alias refusal", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("element outside the plugin prefix fails boot", func(t *testing.T) {
|
||||
bad := ftNotesType()
|
||||
bad.Element = "acme-other-notes"
|
||||
if _, err := compileFieldTypes([]party.Plugin{ftDemo(ftNotesField, bad)}); err == nil || !strings.Contains(err.Error(), "acme-demo-") {
|
||||
t.Fatalf("err=%v, want the plugin prefix", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown key fails boot naming field and key", func(t *testing.T) {
|
||||
_, err := ftCompile(ftDemo(ftNotesField + " widget: acme-demo-x\n"))
|
||||
if err == nil || !strings.Contains(err.Error(), "field notes") || !strings.Contains(err.Error(), "unknown field widget") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CheckOptions error fails boot naming field and type", func(t *testing.T) {
|
||||
typ := ftNotesType()
|
||||
typ.CheckOptions = func(options map[string]any) error {
|
||||
if rows, _ := options["rows"].(int); rows > 3 {
|
||||
return errors.New("rows must be at most 3")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := ftCompile(ftDemo(ftNotesField, typ))
|
||||
if err == nil || !strings.Contains(err.Error(), "field notes") || !strings.Contains(err.Error(), "type notes") || !strings.Contains(err.Error(), "rows must be at most 3") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings and relation forms refuse plugin field types", func(t *testing.T) {
|
||||
reg, err := ftCompile(ftDemo(ftNotesField))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
_, err = decodeFields([]byte("fields:\n notes:\n type: notes\n"), reg.fieldTypes.refusing())
|
||||
if err == nil || !strings.Contains(err.Error(), "supported in controller forms only") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
reg, err := ftCompile(ftDemo(ftNotesField))
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
svc := &service{reg: reg}
|
||||
cc, _ := reg.Get("acme.demo.notes")
|
||||
|
||||
t.Run("field-types route lists the type with a versioned script", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
svc.fieldTypesList(rec, ftRequest(http.MethodGet, adminAPI("/field-types")))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body FieldTypesResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body.Data) != 1 || body.Data[0].Name != "notes" || body.Data[0].Element != "acme-demo-notes" || body.Data[0].Translatable {
|
||||
t.Fatalf("data=%+v", body.Data)
|
||||
}
|
||||
scripts := body.Data[0].Scripts
|
||||
if len(scripts) != 1 || !strings.HasPrefix(scripts[0], DefaultAdminPrefix+"/assets/acme/demo/js/notes.js?v=") || len(strings.TrimPrefix(scripts[0], DefaultAdminPrefix+"/assets/acme/demo/js/notes.js?v=")) != 12 {
|
||||
t.Fatalf("scripts=%v", scripts)
|
||||
}
|
||||
if reg.assets["acme/demo/js/notes.js"] == nil {
|
||||
t.Fatal("the asset route does not serve the field type script")
|
||||
}
|
||||
empty := httptest.NewRecorder()
|
||||
(&service{reg: &Registry{}}).fieldTypesList(empty, ftRequest(http.MethodGet, adminAPI("/field-types")))
|
||||
if !strings.Contains(empty.Body.String(), `"data":[]`) {
|
||||
t.Fatalf("no types answers %s", empty.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("form schema carries element and typeOptions", func(t *testing.T) {
|
||||
req := ftRequest(http.MethodGet, adminAPI("/acme/demo/notes/schema/form"))
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "notes")
|
||||
rec := httptest.NewRecorder()
|
||||
svc.formSchema(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Fields []map[string]any `json:"fields"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var notes map[string]any
|
||||
for _, field := range body.Data.Fields {
|
||||
if field["name"] == "notes" {
|
||||
notes = field
|
||||
}
|
||||
}
|
||||
if notes["type"] != "notes" || notes["element"] != "acme-demo-notes" {
|
||||
t.Fatalf("notes field=%v", notes)
|
||||
}
|
||||
if opts, _ := notes["typeOptions"].(map[string]any); opts["rows"] != float64(4) {
|
||||
t.Fatalf("typeOptions=%v", notes["typeOptions"])
|
||||
}
|
||||
if _, ok := notes["translatable"]; ok {
|
||||
t.Fatalf("a plain type sends translatable: %v", notes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode stores the type's value", func(t *testing.T) {
|
||||
projected := map[string]any{}
|
||||
notices, err := decodePluginFields(context.Background(), nil, nil, cc, &ftNote{}, map[string]any{"notes": "plain"}, projected)
|
||||
if err != nil || projected["notes"] != "plain" || len(notices) != 0 {
|
||||
t.Fatalf("projected=%v notices=%v err=%v", projected, notices, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode notice becomes a FieldNotice", func(t *testing.T) {
|
||||
projected := map[string]any{}
|
||||
notices, err := decodePluginFields(context.Background(), nil, nil, cc, &ftNote{}, map[string]any{"notes": " x "}, projected)
|
||||
if err != nil || projected["notes"] != "x" {
|
||||
t.Fatalf("projected=%v err=%v", projected, err)
|
||||
}
|
||||
got := localizeNotices(context.Background(), nil, notices)
|
||||
if len(got) != 1 || got[0] != (FieldNotice{Field: "notes", Message: "acme.demo::lang.trimmed"}) {
|
||||
t.Fatalf("notices=%+v", got)
|
||||
}
|
||||
raw, _ := json.Marshal(RecordMeta{Notices: got})
|
||||
if !strings.Contains(string(raw), `"notices":[{"field":"notes","message":"acme.demo::lang.trimmed"}]`) {
|
||||
t.Fatalf("meta=%s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no notices key without notices", func(t *testing.T) {
|
||||
raw, err := json.Marshal(RecordMeta{Labels: map[string][]RelationOption{}, Notices: localizeNotices(context.Background(), nil, nil)})
|
||||
if err != nil || strings.Contains(string(raw), "notices") || string(raw) != `{"labels":{}}` {
|
||||
t.Fatalf("meta=%s err=%v", raw, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FormFieldError is validation_failed on the field", func(t *testing.T) {
|
||||
_, err := decodePluginFields(context.Background(), nil, nil, cc, &ftNote{}, map[string]any{"notes": "bad"}, map[string]any{})
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Fatalf("err=%v, want a ValidationError", err)
|
||||
}
|
||||
if msgs, _ := invalid.Details["notes"].([]string); len(msgs) != 1 || msgs[0] != "acme.demo::lang.bad" {
|
||||
t.Fatalf("details=%v", invalid.Details)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("any other decode error is opaque", func(t *testing.T) {
|
||||
typ := ftNotesType()
|
||||
typ.Decode = func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
return pact.FormFieldDecoded{}, errors.New("secret plugin detail")
|
||||
}
|
||||
other, err := ftCompile(ftDemo(ftNotesField, typ))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
occ, _ := other.Get("acme.demo.notes")
|
||||
_, err = decodePluginFields(context.Background(), nil, nil, occ, &ftNote{}, map[string]any{"notes": "x"}, map[string]any{})
|
||||
var life *lifecycleError
|
||||
if !errors.As(err, &life) || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatalf("err=%v, want the opaque lifecycle error", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
writeCRUDError(rec, err)
|
||||
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "secret") {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("show serves the value through Load", func(t *testing.T) {
|
||||
data := projectRecord(cc, &ftNote{ID: 7, Notes: "stored"})
|
||||
if err := hydratePluginFields(context.Background(), nil, cc, &ftNote{}, data, "update"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data["notes"] != "served:stored" || data["title"] != "" {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lift takes plugin values out of the body", func(t *testing.T) {
|
||||
body := map[string]any{"title": "T", "notes": map[string]any{"nested": true}}
|
||||
values := liftPluginValues(cc, body, "update")
|
||||
if _, still := body["notes"]; still || len(values) != 1 {
|
||||
t.Fatalf("body=%v values=%v", body, values)
|
||||
}
|
||||
if _, ok := values["notes"].(map[string]any); !ok {
|
||||
t.Fatalf("nested value lost: %v", values)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -149,13 +149,34 @@ type formFieldsFile struct {
|
||||
Fields fieldMap `yaml:"fields"`
|
||||
}
|
||||
|
||||
// fieldMap is a fields mapping. types, set before decoding, resolves plugin
|
||||
// field type aliases; nil accepts the built-in types only.
|
||||
type fieldMap struct {
|
||||
items []FormField
|
||||
types *fieldTypeRegistry
|
||||
}
|
||||
|
||||
// CompileForm compiles config_form.yaml and the model fields.yaml it names.
|
||||
// The returned schema is safe to cache: labels stay as source keys.
|
||||
// The returned schema is safe to cache: labels stay as source keys. Only the
|
||||
// built-in field types are accepted; CompileFormWithTypes also accepts plugin
|
||||
// field types.
|
||||
func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
|
||||
return compileForm(pluginID, ctl, fsys, nil)
|
||||
}
|
||||
|
||||
// CompileFormWithTypes is CompileForm with the plugin field types registered
|
||||
// by plugin pluginID (pact.HasFormFieldTypes) accepted as field types. The
|
||||
// aliases, option keys and CheckOptions run as at boot; the element prefix and
|
||||
// the type's scripts and styles are checked when the admin is activated.
|
||||
func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*FormSchema, error) {
|
||||
reg, err := newFieldTypeRegistry(pluginID, types)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compileForm(pluginID, ctl, fsys, reg)
|
||||
}
|
||||
|
||||
func compileForm(pluginID string, ctl pact.AdminController, fsys fs.FS, types *fieldTypeRegistry) (*FormSchema, error) {
|
||||
if ctl == nil {
|
||||
return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID)
|
||||
}
|
||||
@@ -191,7 +212,7 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
||||
}
|
||||
fields, err := decodeFields(fieldsRaw)
|
||||
fields, err := decodeFields(fieldsRaw, types)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
||||
}
|
||||
@@ -396,9 +417,11 @@ func localizeOptions(ctx context.Context, tr *phrasebook.Translator, src FormFie
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func decodeFields(raw []byte) ([]FormField, error) {
|
||||
// decodeFields compiles a fields.yaml. types resolves plugin field type
|
||||
// aliases; nil accepts the built-in types only.
|
||||
func decodeFields(raw []byte, types *fieldTypeRegistry) ([]FormField, error) {
|
||||
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||
var doc formFieldsFile
|
||||
doc := formFieldsFile{Fields: fieldMap{types: types}}
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return nil, normalizeYAMLError(err)
|
||||
}
|
||||
@@ -409,7 +432,7 @@ func decodeFields(raw []byte) ([]FormField, error) {
|
||||
}
|
||||
|
||||
func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
|
||||
items, err := decodeFieldMapping(node, nil)
|
||||
items, err := decodeFieldMapping(node, nil, m.types)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -419,8 +442,9 @@ func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
|
||||
|
||||
// decodeFieldMapping compiles a fields mapping in source order. rename, when
|
||||
// set, maps a raw field name (such as WinterCMS's pivot[role]) to the name
|
||||
// that must be an identifier.
|
||||
func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField, error) {
|
||||
// that must be an identifier. types resolves plugin field type aliases; nil
|
||||
// accepts the built-in types only.
|
||||
func decodeFieldMapping(node ast.Node, rename func(string) string, types *fieldTypeRegistry) ([]FormField, error) {
|
||||
node = unwrapNode(node)
|
||||
if _, ok := node.(*ast.NullNode); ok || node == nil {
|
||||
return []FormField{}, nil
|
||||
@@ -443,7 +467,7 @@ func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField,
|
||||
return nil, fmt.Errorf("duplicate field %s", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
field, err := compileFieldNode(name, unwrapNode(entry.Value))
|
||||
field, err := compileFieldNode(name, unwrapNode(entry.Value), types)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %s: %w", name, err)
|
||||
}
|
||||
@@ -452,18 +476,25 @@ func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
||||
func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (FormField, error) {
|
||||
mapping, ok := node.(*ast.MappingNode)
|
||||
if !ok {
|
||||
return FormField{}, fmt.Errorf("must be a mapping")
|
||||
}
|
||||
// A plugin field type accepts the general keys plus its own options, so
|
||||
// the type is resolved before the keys are checked. A built-in type keeps
|
||||
// the check against formFieldKeys.
|
||||
plugin, err := pluginFieldTypeOf(mapping, types)
|
||||
if err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
values := map[string]ast.Node{}
|
||||
for _, entry := range mapping.Values {
|
||||
key, err := nodeString(unwrapNode(entry.Key))
|
||||
if err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if _, known := formFieldKeys[key]; !known {
|
||||
if !fieldKeyAllowed(plugin, key) {
|
||||
return FormField{}, fmt.Errorf("unknown field %s", key)
|
||||
}
|
||||
if _, dup := values[key]; dup {
|
||||
@@ -475,7 +506,7 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
||||
if err != nil || typ == "" {
|
||||
return FormField{}, fmt.Errorf("type is required")
|
||||
}
|
||||
if _, ok := formFieldTypes[typ]; !ok {
|
||||
if _, ok := formFieldTypes[typ]; !ok && plugin == nil {
|
||||
return FormField{}, fmt.Errorf("unsupported type %s", typ)
|
||||
}
|
||||
field := FormField{Name: name, Type: typ}
|
||||
@@ -586,6 +617,11 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
||||
return FormField{}, fmt.Errorf("options: %w", err)
|
||||
}
|
||||
}
|
||||
if plugin != nil {
|
||||
if err := compilePluginField(plugin, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
@@ -915,7 +951,7 @@ func normalizeYAMLError(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
|
||||
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS, types *fieldTypeRegistry) (*FormSchema, error) {
|
||||
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
|
||||
if dir == "." || strings.HasPrefix(dir, "..") {
|
||||
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
|
||||
@@ -927,5 +963,5 @@ func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS)
|
||||
}
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||
}
|
||||
return CompileForm(pluginID, ctl, fsys)
|
||||
return compileForm(pluginID, ctl, fsys, types)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,13 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg, err := compileRegistry(items)
|
||||
// Plugin field types are compiled first and passed down: every form of
|
||||
// this boot resolves its `type:` aliases through this registry.
|
||||
fieldTypes, err := compileFieldTypes(plugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg, err := compileRegistryWithTypes(items, fieldTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -241,6 +247,9 @@ func (s *service) mount(r pact.Router) {
|
||||
g.Post("/account/avatar", requireAjax(s.avatarUpload))
|
||||
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
|
||||
g.Get("/navigation", s.navigation)
|
||||
// Plugin field types (pact.HasFormFieldTypes): the SPA loads their
|
||||
// scripts and styles once after sign-in.
|
||||
g.Get("/field-types", s.fieldTypesList)
|
||||
// Form previews render through RenderMarkdown; nothing is stored.
|
||||
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
|
||||
g.Get("/settings", s.settingsList)
|
||||
|
||||
@@ -55,8 +55,21 @@ func checkReservedSegments(items []controllerRef) error {
|
||||
}
|
||||
|
||||
func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
return compileRegistryWithTypes(items, nil)
|
||||
}
|
||||
|
||||
// compileRegistryWithTypes compiles every controller with the plugin field
|
||||
// types built by compileFieldTypes for this boot. types is passed down, never
|
||||
// stored in a package variable, so two activations never share a registry.
|
||||
func compileRegistryWithTypes(items []controllerRef, types *fieldTypeRegistry) (*Registry, error) {
|
||||
byID := make(map[string]*CompiledController, len(items))
|
||||
assets := map[string]*pluginAsset{}
|
||||
// Field type scripts and styles are served by the same asset route.
|
||||
for _, file := range types.assets() {
|
||||
if _, exists := assets[file.key]; !exists {
|
||||
assets[file.key] = file
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
id := item.ctl.ID()
|
||||
if _, exists := byID[id]; exists {
|
||||
@@ -70,11 +83,11 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, fsys.AdminFS())
|
||||
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, fsys.AdminFS(), types)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relations, err := compileRelations(item.plugin.ID(), item.ctl, fsys.AdminFS(), form)
|
||||
relations, err := compileRelationsWithTypes(item.plugin.ID(), item.ctl, fsys.AdminFS(), form, types)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -117,7 +130,7 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
}
|
||||
byID[id] = compiled
|
||||
}
|
||||
return &Registry{byID: byID, assets: assets}, nil
|
||||
return &Registry{byID: byID, assets: assets, fieldTypes: types}, nil
|
||||
}
|
||||
|
||||
// operationDeclared reports whether the controller's compiled list and form
|
||||
@@ -209,7 +222,7 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
|
||||
if !hasAssets || assets == nil || assets.AdminFS() == nil {
|
||||
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
|
||||
}
|
||||
compiled, err := compileSetting(plugin.ID(), item, assets.AdminFS())
|
||||
compiled, err := compileSettingWithTypes(plugin.ID(), item, assets.AdminFS(), reg.fieldTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -295,6 +295,13 @@ type relationColumnDocument struct {
|
||||
}
|
||||
|
||||
func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) {
|
||||
return compileRelationsWithTypes(pluginID, ctl, fsys, form, nil)
|
||||
}
|
||||
|
||||
// compileRelationsWithTypes is compileRelations with the plugin field types
|
||||
// of the boot: relation forms refuse them (supported in controller forms
|
||||
// only).
|
||||
func compileRelationsWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema, types *fieldTypeRegistry) (map[string]*CompiledRelation, error) {
|
||||
// fields maps each relation-manager relation to its form field index.
|
||||
fields := map[string]int{}
|
||||
if form != nil {
|
||||
@@ -381,7 +388,7 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for
|
||||
fieldName: form.Fields[fields[name]].Name,
|
||||
}
|
||||
schema.Deferrable = cr.deferrable
|
||||
if err := compileRelationForms(pluginID, ctl, fsys, file, doc, cr); err != nil {
|
||||
if err := compileRelationForms(pluginID, ctl, fsys, file, doc, cr, types); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form.Fields[fields[name]].Deferrable = cr.deferrable
|
||||
|
||||
@@ -88,9 +88,23 @@ type RelationOption struct {
|
||||
// actions the requesting admin may run and that apply to the record, in
|
||||
// declared order; only the show response fills it, and it is omitted when no
|
||||
// action is offered.
|
||||
//
|
||||
// Notices are the messages plugin field types returned while saving the
|
||||
// record (pact.FormFieldDecoded), localized in the request locale; only the
|
||||
// create and update responses carry them, and the key is omitted when no type
|
||||
// reported anything.
|
||||
type RecordMeta struct {
|
||||
Labels map[string][]RelationOption `json:"labels"`
|
||||
Actions []RecordAction `json:"actions,omitempty"`
|
||||
Notices []FieldNotice `json:"notices,omitempty"`
|
||||
}
|
||||
|
||||
// FieldNotice is one save notice: the field it is about, the content locale
|
||||
// of the value for a translatable field type, and the localized message.
|
||||
type FieldNotice struct {
|
||||
Field string `json:"field"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// RecordEnvelope is the show, create and update response.
|
||||
|
||||
@@ -57,7 +57,7 @@ func (c relationModelController) NewRecord() any { return c.newRecord() }
|
||||
// to preview them, and the pivot form (pivot.form, belongsToMany only). It
|
||||
// also enforces the toolbar rules that depend on them: create and update
|
||||
// need a manage form, and unlink on a hasMany needs a nullable ForeignKey.
|
||||
func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS, file string, doc relationDocument, cr *CompiledRelation) error {
|
||||
func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS, file string, doc relationDocument, cr *CompiledRelation, types *fieldTypeRegistry) error {
|
||||
name := cr.Contract.Name
|
||||
fail := func(err error) error {
|
||||
return bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s: %w", name, err))
|
||||
@@ -80,7 +80,7 @@ func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
}
|
||||
cr.Schema.relatedModel = cr.Contract.NewRelated
|
||||
if manageRef != "" {
|
||||
child, err := compileRelationForm(pluginID, ctl, fsys, file, manageRef, cr, "manage", writes)
|
||||
child, err := compileRelationForm(pluginID, ctl, fsys, file, manageRef, cr, "manage", writes, types)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
if viewRef != "" {
|
||||
view := cr.child
|
||||
if viewRef != manageRef || view == nil {
|
||||
compiled, err := compileRelationForm(pluginID, ctl, fsys, file, viewRef, cr, "view", false)
|
||||
compiled, err := compileRelationForm(pluginID, ctl, fsys, file, viewRef, cr, "view", false, types)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
if cr.hasMany() {
|
||||
return fail(errors.New("pivot.form is only valid on a belongsToMany relation"))
|
||||
}
|
||||
pivot, err := compileRelationForm(pluginID, ctl, fsys, file, ref, cr, "pivot", false)
|
||||
pivot, err := compileRelationForm(pluginID, ctl, fsys, file, ref, cr, "pivot", false, types)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
// model: the related model for the manage and view forms, the pivot model
|
||||
// for the pivot form. writable additionally requires the model to implement
|
||||
// lagoon.HasFillable and Rules (create and update fill and validate it).
|
||||
func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS, cfgFile, ref string, cr *CompiledRelation, purpose string, writable bool) (*CompiledController, error) {
|
||||
func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS, cfgFile, ref string, cr *CompiledRelation, purpose string, writable bool, types *fieldTypeRegistry) (*CompiledController, error) {
|
||||
relation := cr.Contract.Name
|
||||
path, err := assetPath(pluginID, ref)
|
||||
if err != nil {
|
||||
@@ -140,9 +140,9 @@ func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
}
|
||||
var fields []FormField
|
||||
if purpose == "pivot" {
|
||||
fields, err = decodePivotFields(raw)
|
||||
fields, err = decodePivotFields(raw, types.refusing())
|
||||
} else {
|
||||
fields, err = decodeFields(raw)
|
||||
fields, err = decodeFields(raw, types.refusing())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fail(err)
|
||||
@@ -240,10 +240,11 @@ type pivotFieldsFile struct {
|
||||
|
||||
type pivotFieldMap struct {
|
||||
items []FormField
|
||||
types *fieldTypeRegistry
|
||||
}
|
||||
|
||||
func (m *pivotFieldMap) UnmarshalYAML(node ast.Node) error {
|
||||
items, err := decodeFieldMapping(node, pivotFieldName)
|
||||
items, err := decodeFieldMapping(node, pivotFieldName, m.types)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -251,9 +252,9 @@ func (m *pivotFieldMap) UnmarshalYAML(node ast.Node) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodePivotFields(raw []byte) ([]FormField, error) {
|
||||
func decodePivotFields(raw []byte, types *fieldTypeRegistry) ([]FormField, error) {
|
||||
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||
var doc pivotFieldsFile
|
||||
doc := pivotFieldsFile{Fields: pivotFieldMap{types: types}}
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return nil, normalizeYAMLError(err)
|
||||
}
|
||||
|
||||
@@ -318,8 +318,19 @@ type FormField struct {
|
||||
// offers the requesting administrator, in display order. They are filled
|
||||
// per request by the form schema route.
|
||||
PermissionOptions []PermissionOption `json:"permissionOptions,omitempty"`
|
||||
// Element is the custom-element tag of a field whose type a plugin
|
||||
// registers (pact.HasFormFieldTypes); the admin renders it for the field.
|
||||
Element string `json:"element,omitempty"`
|
||||
// Translatable is true when a plugin field type's value is a map of
|
||||
// content locale to string.
|
||||
Translatable bool `json:"translatable,omitempty"`
|
||||
// TypeOptions are a plugin field type's own fields.yaml keys and values;
|
||||
// the admin hands them to the element as JSON.
|
||||
TypeOptions map[string]any `json:"typeOptions,omitempty"`
|
||||
|
||||
optionsMethod string
|
||||
// fieldType is the plugin field type of the field; nil for a built-in type.
|
||||
fieldType *compiledFieldType `json:"-"`
|
||||
}
|
||||
|
||||
// FieldPreset is a text or mltext field's `preset`: Field names the text or
|
||||
|
||||
@@ -43,6 +43,7 @@ var phase09Routes = []adminRoute{
|
||||
{key: "POST /account/avatar"},
|
||||
{key: "DELETE /account/avatar"},
|
||||
{key: "GET /navigation"},
|
||||
{key: "GET /field-types"},
|
||||
{key: "GET /settings"},
|
||||
{key: "GET /settings/{code}/schema"},
|
||||
{key: "GET /settings/{code}"},
|
||||
|
||||
@@ -49,6 +49,12 @@ func compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetti
|
||||
}
|
||||
|
||||
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
|
||||
return compileSettingWithTypes(pluginID, item, fsys, nil)
|
||||
}
|
||||
|
||||
// compileSettingWithTypes is compileSetting with the plugin field types of the
|
||||
// boot: a settings form refuses them (supported in controller forms only).
|
||||
func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS, types *fieldTypeRegistry) (*CompiledSetting, error) {
|
||||
if item.NewModel == nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)
|
||||
}
|
||||
@@ -71,7 +77,7 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
|
||||
}
|
||||
fields, err := decodeFields(raw)
|
||||
fields, err := decodeFields(raw, types.refusing())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user