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:
@@ -680,6 +680,24 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldNotice": {
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string"
|
||||
},
|
||||
"locale": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"field",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldPreset": {
|
||||
"properties": {
|
||||
"field": {
|
||||
@@ -695,6 +713,57 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldTypeAssets": {
|
||||
"properties": {
|
||||
"element": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"scripts": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"translatable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"element",
|
||||
"name",
|
||||
"scripts",
|
||||
"styles",
|
||||
"translatable"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FieldTypesResponse": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.FieldTypeAssets"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FileItem": {
|
||||
"properties": {
|
||||
"content_type": {
|
||||
@@ -802,6 +871,10 @@
|
||||
"displayFormat": {
|
||||
"type": "string"
|
||||
},
|
||||
"element": {
|
||||
"description": "Element is the custom-element tag of a field whose type a plugin\nregisters (pact.HasFormFieldTypes); the admin renders it for the field.",
|
||||
"type": "string"
|
||||
},
|
||||
"emptyOption": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -935,6 +1008,10 @@
|
||||
],
|
||||
"description": "ThumbOptions carries the preview thumbnail mode."
|
||||
},
|
||||
"translatable": {
|
||||
"description": "Translatable is true when a plugin field type's value is a map of\ncontent locale to string.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"twelveHour": {
|
||||
"description": "TwelveHour shows a 12-hour clock.",
|
||||
"type": "boolean"
|
||||
@@ -942,6 +1019,11 @@
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"typeOptions": {
|
||||
"additionalProperties": {},
|
||||
"description": "TypeOptions are a plugin field type's own fields.yaml keys and values;\nthe admin hands them to the element as JSON.",
|
||||
"type": "object"
|
||||
},
|
||||
"useCaption": {
|
||||
"description": "UseCaption lets the admin edit each file's title and description.",
|
||||
"type": "boolean"
|
||||
@@ -1643,6 +1725,12 @@
|
||||
"type": "array"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"notices": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.FieldNotice"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2496,6 +2584,62 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/field-types": {
|
||||
"get": {
|
||||
"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.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.FieldTypesResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Plugin field types",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/lang": {
|
||||
"get": {
|
||||
"description": "Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.",
|
||||
@@ -3079,7 +3223,7 @@
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
@@ -3757,7 +3901,7 @@
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
"get": {
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.",
|
||||
"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).",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
@@ -4442,6 +4586,7 @@
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
|
||||
109
admin/src/api/schema.d.ts
vendored
109
admin/src/api/schema.d.ts
vendored
@@ -449,6 +449,72 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/field-types": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.FieldTypesResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/lang": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -991,7 +1057,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
@@ -1514,7 +1580,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Admin form schema
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.
|
||||
* @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).
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
@@ -1965,7 +2031,10 @@ export interface paths {
|
||||
};
|
||||
};
|
||||
};
|
||||
/** Update an admin record */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4694,10 +4763,26 @@ export interface components {
|
||||
"cabana.ErrorEnvelope": {
|
||||
error: components["schemas"]["cabana.ErrorBody"];
|
||||
};
|
||||
"cabana.FieldNotice": {
|
||||
field: string;
|
||||
locale?: string;
|
||||
message: string;
|
||||
};
|
||||
"cabana.FieldPreset": {
|
||||
field: string;
|
||||
type: string;
|
||||
};
|
||||
"cabana.FieldTypeAssets": {
|
||||
element: string;
|
||||
name: string;
|
||||
scripts: string[];
|
||||
styles: string[];
|
||||
translatable: boolean;
|
||||
};
|
||||
"cabana.FieldTypesResponse": {
|
||||
data: components["schemas"]["cabana.FieldTypeAssets"][];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.FileItem": {
|
||||
content_type: string;
|
||||
created_at: string;
|
||||
@@ -4736,6 +4821,11 @@ export interface components {
|
||||
*/
|
||||
deferrable?: boolean;
|
||||
displayFormat?: string;
|
||||
/**
|
||||
* @description 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;
|
||||
emptyOption?: string;
|
||||
/** @description FileTypes are the allowed lower-case extensions of a fileupload field. */
|
||||
fileTypes?: string[];
|
||||
@@ -4812,9 +4902,21 @@ export interface components {
|
||||
tab?: string;
|
||||
/** @description ThumbOptions carries the preview thumbnail mode. */
|
||||
thumbOptions?: components["schemas"]["cabana.ThumbOptions"];
|
||||
/**
|
||||
* @description Translatable is true when a plugin field type's value is a map of
|
||||
* content locale to string.
|
||||
*/
|
||||
translatable?: boolean;
|
||||
/** @description TwelveHour shows a 12-hour clock. */
|
||||
twelveHour?: boolean;
|
||||
type: string;
|
||||
/**
|
||||
* @description TypeOptions are a plugin field type's own fields.yaml keys and values;
|
||||
* the admin hands them to the element as JSON.
|
||||
*/
|
||||
typeOptions?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** @description UseCaption lets the admin edit each file's title and description. */
|
||||
useCaption?: boolean;
|
||||
/** @description Widget is the custom-element tag of a `type: widget` field (D-06). */
|
||||
@@ -5025,6 +5127,7 @@ export interface components {
|
||||
labels: {
|
||||
[key: string]: components["schemas"]["cabana.RelationOption"][];
|
||||
};
|
||||
notices?: components["schemas"]["cabana.FieldNotice"][];
|
||||
};
|
||||
"cabana.RelationColumn": {
|
||||
key: string;
|
||||
|
||||
106
docs/backend/field-types.md
Normal file
106
docs/backend/field-types.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Plugin field types
|
||||
description: Register your own admin form field types from a plugin, with a custom element in the admin and Go code that decodes, loads and formats the value.
|
||||
section: backend
|
||||
order: 25
|
||||
---
|
||||
# Plugin field types
|
||||
|
||||
The built-in field types of a [form](forms.md) cover text, choices, relations, files and dates. When a plugin needs an editor of its own (a block editor, a map picker, a structured list), it registers a field type: a short alias any controller's `fields.yaml` can use as `type:`, a custom element the admin renders for the field, and Go functions that decide what is stored. The plugin ships no framework change and no admin build of the framework: [cabana](../../modules/cabana/README.md) owns the routes, the permissions, the record scope and the transaction, and the type only carries its own logic.
|
||||
|
||||
## Registering a type
|
||||
|
||||
A plugin implements `pact.HasFormFieldTypes` and returns one `pact.FormFieldType` per alias:
|
||||
|
||||
```go src=modules/cabana/example_field_type_test.go#NotesPlugin.FormFieldTypes
|
||||
// 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
|
||||
},
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
`pact.HasFormFieldTypes` is implemented by the plugin itself, like `pact.HasAdminControllers`, not by a controller. A form then uses the alias:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
notes:
|
||||
label: acme.blog::lang.post.notes
|
||||
type: notes
|
||||
rows: 6
|
||||
```
|
||||
|
||||
The admin loads every registered type's scripts once after sign-in, and keeps its stylesheets enabled on every page, so the element works in any controller's form. `GET <prefix>/api/v1/field-types` lists the types with their script and stylesheet URLs; each URL carries a content hash, and the files are served from the plugin's embedded `assets/` tree like [controller assets](partials-and-widgets.md#plugin-javascript-and-css).
|
||||
|
||||
## Aliases and boot checks
|
||||
|
||||
Every rule below stops the start-up with a message naming the plugin and the alias:
|
||||
|
||||
- The alias is lowercase letters and digits starting with a letter (`notes`, `geo2`).
|
||||
- It is not a built-in field type (`text`, `markdown`, `mlmarkdown`, `widget`, `partial` and the others in the [field type table](forms.md#field-types)).
|
||||
- No other plugin registers the same alias; the message names both plugins.
|
||||
- `Element` is a valid custom-element name that starts with the plugin's prefix: `acme.blog` gives `acme-blog-`.
|
||||
- `Decode` is set.
|
||||
- `Scripts` (`.js`, `.mjs`) and `Styles` (`.css`) are clean paths under `assets/` that exist in the plugin's `AdminFS`; a plugin that declares files implements `pact.AdminAssets`.
|
||||
- Each `Options` key is letters and digits starting with a letter, is declared once and is not a general field key such as `label`, `options` or `mode`.
|
||||
|
||||
Plugin field types work in controller forms. A settings form and a relation manager's forms refuse a plugin alias with "plugin field type `<name>` is supported in controller forms only".
|
||||
|
||||
## Options
|
||||
|
||||
A plugin-typed field accepts the general keys `label`, `comment`, `span`, `type`, `required`, `tab`, `context`, `attributes`, `size` and `default`, plus the type's own `Options`. Any other key stops the start-up with "unknown field". The option values are decoded into JSON values (string, boolean, number, list or map) and sent to the admin as the field's `typeOptions`; the element receives them as its `options` attribute.
|
||||
|
||||
`CheckOptions`, when set, checks a field's options at start-up, as the `rows` check in the example above does. An error stops it and names the field and the type.
|
||||
|
||||
The form schema carries a plugin field with `element`, `translatable` (only when true) and `typeOptions` (only when the field sets options). `cabana.CompileFormWithTypes` compiles a form with one plugin's types outside a running application, which is how a plugin tests its `fields.yaml`.
|
||||
|
||||
## Decode, notices and refusals
|
||||
|
||||
A save hands each submitted plugin field value to the type's `Decode`, inside the save transaction, after cabana loaded the record through the controller's `pact.FormExtendQuery` scope and before the record is filled. `pact.FormFieldValue` carries the transaction (`DB`), the loaded record on update or the new model on create (`Record`), the field name, its options and the submitted `Value` (any JSON value). The value `Decode` returns in `pact.FormFieldDecoded` is the value written to the column; the field binds to a model column of the same name like any value field, and `required` works on it.
|
||||
|
||||
`Decode` answers in one of three ways:
|
||||
|
||||
- It accepts the value: `pact.FormFieldDecoded{Value: v}`. The save answers exactly as a save without plugin field types.
|
||||
- It accepts the value but changes it, and tells the administrator: `Notices` carries one `pact.FormFieldNotice` per message, for example what a sanitizer removed. The save goes through, and the create or update response carries the messages in `meta.notices`:
|
||||
|
||||
```json
|
||||
{"data": {"id": 7, "notes": "..."}, "meta": {"labels": {}, "notices": [{"field": "notes", "message": "2 elements were removed: style, iframe"}]}}
|
||||
```
|
||||
|
||||
`Message` is a phrase key or text, localized in the request locale with `Params`; a `Count` above zero selects the plural form of the phrase and sets `:count`. A save whose `Decode` calls return no notice has no `notices` key at all.
|
||||
- It refuses the value: a `*pact.FormFieldError` is a 422 `validation_failed` on the field with the error's messages (phrase keys or text, localized), and nothing is written. Use it for a value the type cannot accept at all, such as a number where it expects text.
|
||||
|
||||
Any other error from `Decode` is logged and answered as the opaque 500 of a failed lifecycle hook; the plugin's error text never reaches the client.
|
||||
|
||||
## Load
|
||||
|
||||
`Load`, when set, turns the stored value into the value the element receives. It runs on the record response of `GET .../{id}` and of every create and update, with the same `pact.FormFieldValue` (the read transaction, the record, the stored `Value`). The save response therefore always carries the stored value, and the admin reloads it into the form after a save, so the administrator sees what `Decode` kept. Without `Load` the stored value is sent as it is. A `Load` error is an opaque 500.
|
||||
|
||||
## The element
|
||||
|
||||
The admin creates the type's element for the field and talks to it through one property, attributes and events, nothing else:
|
||||
|
||||
- The `value` property holds the field's value: any JSON value for a type that is not translatable.
|
||||
- The attributes `field-name`, `record-id` (empty on the create form), `locale` (empty for a type that is not translatable), `ui-locale` (the admin's interface language), `options` (the JSON of `typeOptions`), `disabled` (present when the field is read-only) and `invalid` (present while the field has an error).
|
||||
- The element dispatches a bubbling `summer-change` event with `detail.value` when the administrator edits the value, and only then; the admin takes `detail.value` as the field's new value.
|
||||
- The admin sets `value` only when the form value differs from the element's current `value`, so setting the value the element just emitted never resets it. An element must still accept a `value` it did not emit, such as the stored value after a save.
|
||||
|
||||
The element never receives a token, a cookie, a function or a Vue instance, and it makes no requests of its own. It styles itself only through the admin's public `--c-*` CSS variables, with every selector scoped under its own tag, because its stylesheet is enabled on every admin page.
|
||||
@@ -102,7 +102,7 @@ for _, f := range form.Fields {
|
||||
| `mltextarea` | A multi-line text field with one editor per locale; it takes `size` as `textarea` does. See [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
||||
| `mlmarkdown` | A markdown field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
||||
|
||||
The WinterCMS widgets that are not in this list (the rich editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up.
|
||||
The WinterCMS widgets that are not in this list (the rich editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
|
||||
|
||||
### Field options
|
||||
|
||||
|
||||
@@ -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