feat(16.2-01): translatable plugin field types, untouched skip, writes, list values and uploads

- translatable types lift, store and hydrate through the ML path
  (isMLField); Decode and Load run per locale and notices carry the locale
- on update a value equal to the served value (Load of the stored host
  value or exact translation) is neither decoded nor written
- FieldWrite, FieldWritesFromContext and WithFieldWrites expose what a save
  writes to the Form hooks
- list columns may use a plugin alias and show the type's ListValue
- POST .../{id}/fields/{field}/upload hands the file to the type's Upload
  (guard, X-Requested-With, permissions, field context, record scope, body
  cap); OpenAPI, route inventories, README and docs updated
This commit is contained in:
2026-10-10 11:19:24 +02:00
parent 83fa2f7b2c
commit 8fa92db96d
21 changed files with 1281 additions and 67 deletions

View File

@@ -514,6 +514,21 @@
],
"type": "object"
},
"cabana.Envelope-cabana_FieldUploadResponse": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.FieldUploadResponse"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_FileItem": {
"properties": {
"data": {
@@ -764,6 +779,17 @@
],
"type": "object"
},
"cabana.FieldUploadResponse": {
"properties": {
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"cabana.FileItem": {
"properties": {
"content_type": {
@@ -4586,7 +4612,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.",
"description": "A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. 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",
@@ -4841,6 +4867,149 @@
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": {
"post": {
"description": "Stores one multipart part named file through the Upload of the field's plugin field type (pact.FormFieldType) and answers its URL. id 0 is the create form and passes no record; any other id loads the record through the controller's FormExtendQuery scope. A field that is not in the form, has a built-in type, has a type without Upload or whose context does not allow the operation answers 404, as does a record outside the scope. A body over http.body_limits.upload_bytes answers 413; a malformed body, a second part or a value the type refuses (pact.FormFieldError) answers 422 on the field.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Record id (0 for the create form)",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Field name",
"in": "path",
"name": "field",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"properties": {
"file": {
"description": "The file",
"format": "binary",
"type": "string"
}
},
"required": [
"file"
],
"type": "object"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_FieldUploadResponse"
}
}
},
"description": "Created"
},
"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"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Request Entity Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Upload a file for a plugin field type",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/files/{field}": {
"get": {
"description": "The files attached to the record minus the session's pending removals, plus the session's pending uploads, in sort_order. id 0 is the record being created in the X-Session-Key session (the key is then required). url and thumb_url are set only for a public relation.",

View File

@@ -2033,7 +2033,7 @@ export interface paths {
};
/**
* 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.
* @description A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. 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: {
@@ -2280,6 +2280,111 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload a file for a plugin field type
* @description Stores one multipart part named file through the Upload of the field's plugin field type (pact.FormFieldType) and answers its URL. id 0 is the create form and passes no record; any other id loads the record through the controller's FormExtendQuery scope. A field that is not in the form, has a built-in type, has a type without Upload or whose context does not allow the operation answers 404, as does a record outside the scope. A body over http.body_limits.upload_bytes answers 413; a malformed body, a second part or a value the type refuses (pact.FormFieldError) answers 422 on the field.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Record id (0 for the create form) */
id: number;
/** @description Field name */
field: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/**
* Format: binary
* @description The file
*/
file: string;
};
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FieldUploadResponse"];
};
};
/** @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"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/files/{field}": {
parameters: {
query?: never;
@@ -4717,6 +4822,10 @@ export interface components {
data: components["schemas"]["cabana.BulkResult"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_FieldUploadResponse": {
data: components["schemas"]["cabana.FieldUploadResponse"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_FileItem": {
data: components["schemas"]["cabana.FileItem"];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -4783,6 +4892,9 @@ export interface components {
data: components["schemas"]["cabana.FieldTypeAssets"][];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.FieldUploadResponse": {
url: string;
};
"cabana.FileItem": {
content_type: string;
created_at: string;

View File

@@ -56,6 +56,8 @@ export type AdminActionResult = Schemas['cabana.AdminActionResult']
export type ControllerAssets = Schemas['cabana.ControllerAssets']
/** A plugin field type: alias, element tag, translatable flag and its asset URLs. */
export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
/** Answer of a plugin field type upload: the URL of the stored file. */
export type FieldUploadResponse = Schemas['cabana.FieldUploadResponse']
/** A registered toolbar action the admin may run, with its localized label. */
export type ToolbarAction = Schemas['cabana.ToolbarAction']
/** One node of a rendered partial: an allowlisted element or a text node. */