feat(10.1-01): run registered widget actions through a cabana-owned route
- pact: AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult,
HasAdminActions and AdminPartialData contracts
- fields.yaml type: widget with widget, action and fill keys; boot checks the
plugin tag prefix, the registered action and writable scalar fill fields
- POST .../widgets/{field} behind requireAjax, controller and action
permissions, scoped non-locking record read and a server-side fill filter
- typed OpenAPI operation, inventories and an acme conformance case
This commit is contained in:
@@ -1,6 +1,34 @@
|
|||||||
{
|
{
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"cabana.AdminActionRequest": {
|
||||||
|
"properties": {
|
||||||
|
"record_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.AdminActionResult": {
|
||||||
|
"properties": {
|
||||||
|
"fill": {
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fill",
|
||||||
|
"message"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.AdminIDsRequest": {
|
"cabana.AdminIDsRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"ids": {
|
"ids": {
|
||||||
@@ -196,6 +224,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.Envelope-cabana_AdminActionResult": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminActionResult"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.Envelope-cabana_AdminLoginData": {
|
"cabana.Envelope-cabana_AdminLoginData": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -394,6 +437,14 @@
|
|||||||
},
|
},
|
||||||
"cabana.FormField": {
|
"cabana.FormField": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"description": "Action names the controller action the widget runs (pact.HasAdminActions).",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"actionLabel": {
|
||||||
|
"description": "ActionLabel is the action's Label, localized per request.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/components/schemas/cabana.jsonScalar"
|
"$ref": "#/components/schemas/cabana.jsonScalar"
|
||||||
@@ -412,6 +463,13 @@
|
|||||||
"emptyOption": {
|
"emptyOption": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"fill": {
|
||||||
|
"description": "Fill lists the fields of the same form the action writes back (D-07).",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -450,6 +508,10 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"description": "Widget is the custom-element tag of a `type: widget` field (D-06).",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -2668,6 +2730,121 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/{vendor}/{plugin}/{controller}/widgets/{field}": {
|
||||||
|
"post": {
|
||||||
|
"description": "Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response.",
|
||||||
|
"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": "Widget field name",
|
||||||
|
"in": "path",
|
||||||
|
"name": "field",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminActionRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Record id and fill snapshot",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminActionResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BackendBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Run a widget action",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/{vendor}/{plugin}/{controller}/{id}": {
|
"/{vendor}/{plugin}/{controller}/{id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
|||||||
113
admin/src/api/schema.d.ts
vendored
113
admin/src/api/schema.d.ts
vendored
@@ -1237,6 +1237,95 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/{vendor}/{plugin}/{controller}/widgets/{field}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Run a widget action
|
||||||
|
* @description Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Vendor */
|
||||||
|
vendor: string;
|
||||||
|
/** @description Plugin */
|
||||||
|
plugin: string;
|
||||||
|
/** @description Controller */
|
||||||
|
controller: string;
|
||||||
|
/** @description Widget field name */
|
||||||
|
field: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** @description Record id and fill snapshot */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminActionRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminActionResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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 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}": {
|
"/{vendor}/{plugin}/{controller}/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1812,6 +1901,18 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
"cabana.AdminActionRequest": {
|
||||||
|
record_id?: number;
|
||||||
|
values?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"cabana.AdminActionResult": {
|
||||||
|
fill: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
"cabana.AdminIDsRequest": {
|
"cabana.AdminIDsRequest": {
|
||||||
ids: number[];
|
ids: number[];
|
||||||
};
|
};
|
||||||
@@ -1864,6 +1965,10 @@ export interface components {
|
|||||||
data: components["schemas"]["cabana.SettingsEntry"][];
|
data: components["schemas"]["cabana.SettingsEntry"][];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_AdminActionResult": {
|
||||||
|
data: components["schemas"]["cabana.AdminActionResult"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_AdminLoginData": {
|
"cabana.Envelope-cabana_AdminLoginData": {
|
||||||
data: components["schemas"]["cabana.AdminLoginData"];
|
data: components["schemas"]["cabana.AdminLoginData"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
@@ -1919,6 +2024,10 @@ export interface components {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
"cabana.FormField": {
|
"cabana.FormField": {
|
||||||
|
/** @description Action names the controller action the widget runs (pact.HasAdminActions). */
|
||||||
|
action?: string;
|
||||||
|
/** @description ActionLabel is the action's Label, localized per request. */
|
||||||
|
actionLabel?: string;
|
||||||
attributes?: {
|
attributes?: {
|
||||||
[key: string]: components["schemas"]["cabana.jsonScalar"];
|
[key: string]: components["schemas"]["cabana.jsonScalar"];
|
||||||
};
|
};
|
||||||
@@ -1926,6 +2035,8 @@ export interface components {
|
|||||||
context?: components["schemas"]["cabana.fieldContext"];
|
context?: components["schemas"]["cabana.fieldContext"];
|
||||||
default?: components["schemas"]["cabana.jsonScalar"];
|
default?: components["schemas"]["cabana.jsonScalar"];
|
||||||
emptyOption?: string;
|
emptyOption?: string;
|
||||||
|
/** @description Fill lists the fields of the same form the action writes back (D-07). */
|
||||||
|
fill?: string[];
|
||||||
label?: string;
|
label?: string;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1938,6 +2049,8 @@ export interface components {
|
|||||||
span?: string;
|
span?: string;
|
||||||
tab?: string;
|
tab?: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
/** @description Widget is the custom-element tag of a `type: widget` field (D-06). */
|
||||||
|
widget?: string;
|
||||||
};
|
};
|
||||||
"cabana.FormMessages": {
|
"cabana.FormMessages": {
|
||||||
create: components["schemas"]["cabana.MessageForms"];
|
create: components["schemas"]["cabana.MessageForms"];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
|||||||
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
||||||
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md), and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md), and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
||||||
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
|
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
|
||||||
|
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys with scalar values. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
||||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
|
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
|
||||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix.
|
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix.
|
||||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend`, login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend`, login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
||||||
@@ -36,6 +37,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
|||||||
| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record. |
|
| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record. |
|
||||||
| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record. |
|
| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record. |
|
||||||
| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. |
|
| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. |
|
||||||
|
| POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. |
|
||||||
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
|
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
|
||||||
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
||||||
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. |
|
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. |
|
||||||
@@ -109,7 +111,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
|||||||
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
|
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
|
||||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||||
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
|
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
|
||||||
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. |
|
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
|
||||||
|
| `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. |
|
||||||
|
| `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
199
modules/cabana/actions.go
Normal file
199
modules/cabana/actions.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// widgetAction serves POST .../{controller}/widgets/{field} (D-05, D-07): the
|
||||||
|
// SPA posts on behalf of a `type: widget` field, cabana checks the controller
|
||||||
|
// and action permissions, loads record_id through the controller's form scope
|
||||||
|
// and runs the registered action. Only the field's declared fill keys with
|
||||||
|
// scalar values reach the action and the response.
|
||||||
|
func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
field, ok := widgetField(cc, r.PathValue("field"))
|
||||||
|
if !ok {
|
||||||
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action, ok := cc.Actions[field.Action]
|
||||||
|
if !ok {
|
||||||
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.allowAction(w, r, action) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := decodeActionRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)}
|
||||||
|
if in.RecordID != nil {
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record, err := readScopedRecord(r.Context(), db, cc, *in.RecordID)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := *in.RecordID
|
||||||
|
input.RecordID = &id
|
||||||
|
input.Record = record
|
||||||
|
}
|
||||||
|
s.runAction(w, r, cc, action, input, field.Fill)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowAction applies the action's own permissions on top of the controller's
|
||||||
|
// (already checked by protect). A denial is logged and answered 403.
|
||||||
|
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, action pact.AdminAction) bool {
|
||||||
|
principal, _ := bouncer.User(r.Context())
|
||||||
|
if Allows(principal, action.Permissions) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var adminID uint
|
||||||
|
if principal != nil {
|
||||||
|
adminID = principal.ID
|
||||||
|
}
|
||||||
|
s.logAuth(r, "denied", adminID)
|
||||||
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAction calls the plugin's Run and writes the D-10 envelope. A
|
||||||
|
// *ValidationError is a 422; any other error is logged and answered with the
|
||||||
|
// generic 500 body, never the error text.
|
||||||
|
func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) {
|
||||||
|
tr := s.translator()
|
||||||
|
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
|
||||||
|
result, err := action.Run(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
var invalid *ValidationError
|
||||||
|
if errors.As(err, &invalid) {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, AdminActionResult{
|
||||||
|
Message: translateKey(ctx, tr, result.Message),
|
||||||
|
Fill: onlyFillScalars(fill, result.Fill),
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetField returns the form's `type: widget` field with the given name.
|
||||||
|
func widgetField(cc *CompiledController, name string) (FormField, bool) {
|
||||||
|
if cc == nil || cc.Form == nil || name == "" {
|
||||||
|
return FormField{}, false
|
||||||
|
}
|
||||||
|
for _, field := range cc.Form.Fields {
|
||||||
|
if field.Name == name && field.Type == "widget" {
|
||||||
|
return field, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FormField{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeActionRequest decodes the strict {record_id, values} body: unknown
|
||||||
|
// keys, a malformed body or trailing tokens are a validation failure (422).
|
||||||
|
func decodeActionRequest(r *http.Request) (AdminActionRequest, error) {
|
||||||
|
invalid := &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.UseNumber()
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
var in AdminActionRequest
|
||||||
|
if err := dec.Decode(&in); err != nil {
|
||||||
|
return AdminActionRequest{}, invalid
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := dec.Decode(&trailing); err != io.EOF {
|
||||||
|
return AdminActionRequest{}, invalid
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readScopedRecord loads one record through the controller's FormExtendQuery
|
||||||
|
// scope, exactly as show and update do, but without a row lock: it is a read
|
||||||
|
// outside any write transaction. Missing and out-of-scope ids are both
|
||||||
|
// recordNotFound (404).
|
||||||
|
func readScopedRecord(ctx context.Context, db *gorm.DB, cc *CompiledController, id uint64) (any, error) {
|
||||||
|
model, err := newWritableModel(cc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pk, err := coercePK(model, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, recordNotFound{}
|
||||||
|
}
|
||||||
|
q := db.WithContext(ctx)
|
||||||
|
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
|
||||||
|
if next := ext.FormExtendQuery(ctx, q); next != nil {
|
||||||
|
q = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = q.Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: pk}).Take(model).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, recordNotFound{}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
return model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyFillScalars keeps the keys named in fill whose values are JSON scalars
|
||||||
|
// or null. The result is never nil.
|
||||||
|
func onlyFillScalars(fill []string, values map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for _, key := range fill {
|
||||||
|
value, ok := values[key]
|
||||||
|
if !ok || !isJSONScalar(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJSONScalar reports whether v encodes as a JSON string, number, boolean or
|
||||||
|
// null.
|
||||||
|
func isJSONScalar(v any) bool {
|
||||||
|
if v == nil || nestedValue(v) {
|
||||||
|
return v == nil
|
||||||
|
}
|
||||||
|
rv := reflect.ValueOf(v)
|
||||||
|
for rv.Kind() == reflect.Pointer {
|
||||||
|
if rv.IsNil() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rv = rv.Elem()
|
||||||
|
}
|
||||||
|
switch rv.Kind() {
|
||||||
|
case reflect.Bool, reflect.String,
|
||||||
|
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||||
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||||
|
reflect.Float32, reflect.Float64:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -387,6 +387,44 @@ func AdminCreate() {}
|
|||||||
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
||||||
func AdminBulkDelete() {}
|
func AdminBulkDelete() {}
|
||||||
|
|
||||||
|
// AdminActionRequest is the body of a widget or toolbar action. record_id is
|
||||||
|
// the record a widget on the update form belongs to (absent on create and
|
||||||
|
// always absent for a toolbar action); values is the widget's snapshot of its
|
||||||
|
// fill fields.
|
||||||
|
type AdminActionRequest struct {
|
||||||
|
RecordID *uint64 `json:"record_id,omitempty"`
|
||||||
|
Values map[string]any `json:"values,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminActionResult is an action's answer: a localized message for the toast
|
||||||
|
// and the widget write-back, holding only the field's declared fill keys with
|
||||||
|
// scalar values. fill is always an object.
|
||||||
|
type AdminActionResult struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Fill map[string]any `json:"fill"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWidgetAction documents the widget action route.
|
||||||
|
//
|
||||||
|
// @Summary Run a widget action
|
||||||
|
// @Description Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response.
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Param vendor path string true "Vendor"
|
||||||
|
// @Param plugin path string true "Plugin"
|
||||||
|
// @Param controller path string true "Controller"
|
||||||
|
// @Param field path string true "Widget field name"
|
||||||
|
// @Param body body AdminActionRequest true "Record id and fill snapshot"
|
||||||
|
// @Success 200 {object} Envelope[AdminActionResult]
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Router /{vendor}/{plugin}/{controller}/widgets/{field} [post]
|
||||||
|
func AdminWidgetAction() {}
|
||||||
|
|
||||||
// AdminShow documents the record show route.
|
// AdminShow documents the record show route.
|
||||||
//
|
//
|
||||||
// @Summary Show an admin record
|
// @Summary Show an admin record
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ type CompiledController struct {
|
|||||||
Writable []WritableField
|
Writable []WritableField
|
||||||
// FieldRelations are the form's `type: relation` fields keyed by field name.
|
// FieldRelations are the form's `type: relation` fields keyed by field name.
|
||||||
FieldRelations map[string]*CompiledFieldRelation
|
FieldRelations map[string]*CompiledFieldRelation
|
||||||
|
// Actions are the controller's pact.HasAdminActions entries keyed by name:
|
||||||
|
// the single namespace that toolbar.buttons names and widget action: keys
|
||||||
|
// resolve through. create and delete are reserved built-in names.
|
||||||
|
Actions map[string]pact.AdminAction
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry is the immutable controller map keyed by controller ID.
|
// Registry is the immutable controller map keyed by controller ID.
|
||||||
|
|||||||
127
modules/cabana/extension.go
Normal file
127
modules/cabana/extension.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// widgetTagPattern is a valid custom-element name restricted to lowercase
|
||||||
|
// ASCII: a letter, then at least one hyphenated segment.
|
||||||
|
var widgetTagPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)+$`)
|
||||||
|
|
||||||
|
// reservedWidgetTags are the hyphenated names the HTML specification reserves;
|
||||||
|
// customElements.define refuses them.
|
||||||
|
var reservedWidgetTags = map[string]bool{
|
||||||
|
"annotation-xml": true, "color-profile": true, "font-face": true, "font-face-src": true,
|
||||||
|
"font-face-uri": true, "font-face-format": true, "font-face-name": true, "missing-glyph": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtinToolbarActions are the toolbar actions the framework implements
|
||||||
|
// itself (D-14); a controller may not register an action with these names.
|
||||||
|
var builtinToolbarActions = map[string]bool{"create": true, "delete": true}
|
||||||
|
|
||||||
|
// widgetTagPrefix is the custom-element prefix a plugin's widgets must use:
|
||||||
|
// the plugin ID lowercased with dots and underscores turned into hyphens,
|
||||||
|
// plus a trailing hyphen (acme.conform -> "acme-conform-"). It keeps two
|
||||||
|
// plugins from defining the same element.
|
||||||
|
func widgetTagPrefix(pluginID string) string {
|
||||||
|
return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileExtension validates a controller's runtime admin extension points
|
||||||
|
// after its list and form are compiled and its writable fields are bound: the
|
||||||
|
// registered actions and every `type: widget` field. Every failure stops boot.
|
||||||
|
func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error {
|
||||||
|
if cc == nil || cc.Controller == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := cc.Controller.ID()
|
||||||
|
actions, err := compileActions(cc.Controller)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
||||||
|
}
|
||||||
|
cc.Actions = actions
|
||||||
|
if cc.Form == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
file := cc.Form.fieldsPath
|
||||||
|
if file == "" {
|
||||||
|
file = "fields.yaml"
|
||||||
|
}
|
||||||
|
fields := map[string]FormField{}
|
||||||
|
for _, field := range cc.Form.Fields {
|
||||||
|
fields[field.Name] = field
|
||||||
|
}
|
||||||
|
writable := map[string]bool{}
|
||||||
|
for _, field := range cc.Writable {
|
||||||
|
writable[field.Name] = true
|
||||||
|
}
|
||||||
|
prefix := widgetTagPrefix(pluginID)
|
||||||
|
for i := range cc.Form.Fields {
|
||||||
|
field := &cc.Form.Fields[i]
|
||||||
|
if field.Type != "widget" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := checkWidgetTag(field.Widget, prefix); err != nil {
|
||||||
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: %w", field.Name, err))
|
||||||
|
}
|
||||||
|
action, ok := actions[field.Action]
|
||||||
|
if !ok {
|
||||||
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: action %s is not registered by the controller (pact.HasAdminActions)", field.Name, field.Action))
|
||||||
|
}
|
||||||
|
for _, key := range field.Fill {
|
||||||
|
target, exists := fields[key]
|
||||||
|
if !exists {
|
||||||
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a field of this form", field.Name, key))
|
||||||
|
}
|
||||||
|
if !scalarFormField(target.Type) || !writable[key] {
|
||||||
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a writable scalar field", field.Name, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
field.ActionLabel = action.Label
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkWidgetTag(tag, prefix string) error {
|
||||||
|
if !widgetTagPattern.MatchString(tag) {
|
||||||
|
return fmt.Errorf("widget %q is not a valid custom-element name (lowercase, with a hyphen)", tag)
|
||||||
|
}
|
||||||
|
if reservedWidgetTags[tag] {
|
||||||
|
return fmt.Errorf("widget %q is a reserved element name", tag)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(tag, prefix) {
|
||||||
|
return fmt.Errorf("widget %q must start with the plugin prefix %q", tag, prefix)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileActions collects a controller's registered actions into the single
|
||||||
|
// action namespace (assumption-delta decision: create and delete are reserved).
|
||||||
|
func compileActions(ctl pact.AdminController) (map[string]pact.AdminAction, error) {
|
||||||
|
out := map[string]pact.AdminAction{}
|
||||||
|
src, ok := ctl.(pact.HasAdminActions)
|
||||||
|
if !ok || src == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for _, action := range src.AdminActions() {
|
||||||
|
if !identifier(action.Name) {
|
||||||
|
return nil, fmt.Errorf("action name %q is not an identifier", action.Name)
|
||||||
|
}
|
||||||
|
if builtinToolbarActions[action.Name] {
|
||||||
|
return nil, fmt.Errorf("action %s uses a reserved built-in name (create, delete)", action.Name)
|
||||||
|
}
|
||||||
|
if _, dup := out[action.Name]; dup {
|
||||||
|
return nil, fmt.Errorf("duplicate action %s", action.Name)
|
||||||
|
}
|
||||||
|
if action.Run == nil {
|
||||||
|
return nil, fmt.Errorf("action %s has no Run function", action.Name)
|
||||||
|
}
|
||||||
|
out[action.Name] = action
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ var (
|
|||||||
formFieldTypes = map[string]struct{}{
|
formFieldTypes = map[string]struct{}{
|
||||||
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
||||||
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
||||||
|
"widget": {},
|
||||||
}
|
}
|
||||||
formSpans = map[string]struct{}{
|
formSpans = map[string]struct{}{
|
||||||
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
||||||
@@ -34,7 +35,10 @@ var (
|
|||||||
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
|
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
|
||||||
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
|
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
|
||||||
"nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {},
|
"nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {},
|
||||||
|
"widget": {}, "action": {}, "fill": {},
|
||||||
}
|
}
|
||||||
|
// widgetKeys are valid only on `type: widget` (D-06).
|
||||||
|
widgetKeys = []string{"widget", "action", "fill"}
|
||||||
)
|
)
|
||||||
|
|
||||||
type formConfigDocument struct {
|
type formConfigDocument struct {
|
||||||
@@ -108,6 +112,7 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
|||||||
ModelClass: doc.ModelClass,
|
ModelClass: doc.ModelClass,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
redirects: FormRedirects{Default: doc.DefaultRedirect},
|
redirects: FormRedirects{Default: doc.DefaultRedirect},
|
||||||
|
fieldsPath: fieldsPath,
|
||||||
}
|
}
|
||||||
if doc.Messages != nil {
|
if doc.Messages != nil {
|
||||||
schema.messageKeys = *doc.Messages
|
schema.messageKeys = *doc.Messages
|
||||||
@@ -139,6 +144,8 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
|
|||||||
field.Comment = translateKey(ctx, tr, src.Comment)
|
field.Comment = translateKey(ctx, tr, src.Comment)
|
||||||
field.Tab = translateKey(ctx, tr, src.Tab)
|
field.Tab = translateKey(ctx, tr, src.Tab)
|
||||||
field.EmptyOption = translateKey(ctx, tr, src.EmptyOption)
|
field.EmptyOption = translateKey(ctx, tr, src.EmptyOption)
|
||||||
|
field.ActionLabel = translateKey(ctx, tr, src.ActionLabel)
|
||||||
|
field.Fill = append([]string(nil), src.Fill...)
|
||||||
options, err := localizeOptions(ctx, tr, src, provider)
|
options, err := localizeOptions(ctx, tr, src, provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -412,6 +419,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
|||||||
if typ == "relation-manager" && field.Relation == "" {
|
if typ == "relation-manager" && field.Relation == "" {
|
||||||
return FormField{}, fmt.Errorf("relation is required")
|
return FormField{}, fmt.Errorf("relation is required")
|
||||||
}
|
}
|
||||||
|
if err := compileWidgetKeys(typ, values, &field); err != nil {
|
||||||
|
return FormField{}, err
|
||||||
|
}
|
||||||
if node, ok := values["required"]; ok {
|
if node, ok := values["required"]; ok {
|
||||||
field.Required, err = nodeBool(node)
|
field.Required, err = nodeBool(node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -440,6 +450,54 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
|||||||
return field, nil
|
return field, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compileWidgetKeys decodes the D-06 widget keys. They are refused on any
|
||||||
|
// other type; a widget needs a custom-element tag and an action name, and may
|
||||||
|
// list the fields its action writes back. The tag prefix, the registered
|
||||||
|
// action and the fill fields are checked against the controller in
|
||||||
|
// compileExtension.
|
||||||
|
func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
||||||
|
if typ != "widget" {
|
||||||
|
for _, key := range widgetKeys {
|
||||||
|
if _, ok := values[key]; ok {
|
||||||
|
return fmt.Errorf("%s is only valid on type: widget", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tag, err := nodeString(values["widget"])
|
||||||
|
if err != nil || strings.TrimSpace(tag) == "" {
|
||||||
|
return fmt.Errorf("widget (the custom-element tag) is required on type: widget")
|
||||||
|
}
|
||||||
|
field.Widget = tag
|
||||||
|
action, err := nodeString(values["action"])
|
||||||
|
if err != nil || !identifier(action) {
|
||||||
|
return fmt.Errorf("action %q is not an identifier (type: widget needs a registered action)", nodeText(values["action"]))
|
||||||
|
}
|
||||||
|
field.Action = action
|
||||||
|
if node, ok := values["fill"]; ok {
|
||||||
|
seq, ok := node.(*ast.SequenceNode)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("fill must be a list of field names")
|
||||||
|
}
|
||||||
|
items := sequenceValues(seq)
|
||||||
|
fill := make([]string, 0, len(items))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, item := range items {
|
||||||
|
name, err := nodeString(unwrapNode(item))
|
||||||
|
if err != nil || !identifier(name) {
|
||||||
|
return fmt.Errorf("fill %q is not a field name", nodeText(item))
|
||||||
|
}
|
||||||
|
if _, dup := seen[name]; dup {
|
||||||
|
return fmt.Errorf("fill: duplicate field %s", name)
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
fill = append(fill, name)
|
||||||
|
}
|
||||||
|
field.Fill = fill
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func compileContext(node ast.Node) (*fieldContext, error) {
|
func compileContext(node ast.Node) (*fieldContext, error) {
|
||||||
switch n := node.(type) {
|
switch n := node.(type) {
|
||||||
case *ast.StringNode:
|
case *ast.StringNode:
|
||||||
|
|||||||
@@ -218,6 +218,11 @@ func (s *service) mount(r pact.Router) {
|
|||||||
constrainController(g)
|
constrainController(g)
|
||||||
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
|
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
|
||||||
constrainController(g)
|
constrainController(g)
|
||||||
|
// Runtime extension actions (Phase 10.1): cabana owns these routes, so
|
||||||
|
// CSRF, auth and record scoping never depend on plugin code.
|
||||||
|
g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
|
||||||
|
constrainController(g)
|
||||||
|
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
||||||
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
|
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
|
||||||
constrainController(g)
|
constrainController(g)
|
||||||
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
|
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
)
|
)
|
||||||
@@ -240,6 +241,18 @@ func validateMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
actionNames := make([]string, 0, len(cc.Actions))
|
||||||
|
for name := range cc.Actions {
|
||||||
|
actionNames = append(actionNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(actionNames)
|
||||||
|
for _, name := range actionNames {
|
||||||
|
// A label with a namespace separator is a phrase key and must
|
||||||
|
// resolve; anything else is literal button text.
|
||||||
|
if label := cc.Actions[name].Label; strings.Contains(label, "::") && !tr.Has(label) {
|
||||||
|
return fmt.Errorf("cabana: admin controller %s/%s: action %s label names missing phrase key %s", cc.PluginID, id, name, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
names := make([]string, 0, len(cc.Relations))
|
names := make([]string, 0, len(cc.Relations))
|
||||||
for name := range cc.Relations {
|
for name := range cc.Relations {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cabana_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -103,6 +104,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
|||||||
e.gadgetID = dataID(t, rec.Body.Bytes())
|
e.gadgetID = dataID(t, rec.Body.Bytes())
|
||||||
return rec
|
return rec
|
||||||
}, into[cabana.RecordEnvelope]()},
|
}, into[cabana.RecordEnvelope]()},
|
||||||
|
{"POST /{vendor}/{plugin}/{controller}/widgets/{field}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/widgets/lookup", map[string]any{"record_id": e.gadgetID, "values": map[string]any{"name": "x", "active": false}}, true)
|
||||||
|
// The fixture action also returns active, which is outside the
|
||||||
|
// field's fill: the server filter must drop it.
|
||||||
|
var body cabana.Envelope[cabana.AdminActionResult]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("widget action body: %v\n%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(body.Data.Fill) != 1 || body.Data.Fill["name"] != "lookup-"+e.stamp {
|
||||||
|
t.Fatalf("widget fill = %#v, want only name", body.Data.Fill)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
|
||||||
{"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
|
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
|
||||||
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
|
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
|
||||||
@@ -299,7 +313,7 @@ func newConformEnv(t *testing.T) *conformEnv {
|
|||||||
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
plugins := []party.Plugin{conformPlugin{}}
|
plugins := []party.Plugin{conformPlugin{stamp: stamp}}
|
||||||
if err := phrasebook.Activate(app, plugins); err != nil {
|
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -362,14 +376,14 @@ func (conformSettings) TableName() string { return "cabana_conform_settin
|
|||||||
func (conformSettings) Fillable() []string { return []string{"enabled"} }
|
func (conformSettings) Fillable() []string { return []string{"enabled"} }
|
||||||
func (conformSettings) Rules() map[string]string { return map[string]string{} }
|
func (conformSettings) Rules() map[string]string { return map[string]string{} }
|
||||||
|
|
||||||
type conformPlugin struct{}
|
type conformPlugin struct{ stamp string }
|
||||||
|
|
||||||
func (conformPlugin) ID() string { return "acme.conform" }
|
func (conformPlugin) ID() string { return "acme.conform" }
|
||||||
func (conformPlugin) Requires() []string { return nil }
|
func (conformPlugin) Requires() []string { return nil }
|
||||||
func (conformPlugin) Register(*backpack.App) error { return nil }
|
func (conformPlugin) Register(*backpack.App) error { return nil }
|
||||||
func (conformPlugin) Boot(*backpack.App) error { return nil }
|
func (conformPlugin) Boot(*backpack.App) error { return nil }
|
||||||
func (conformPlugin) AdminControllers() []pact.AdminController {
|
func (p conformPlugin) AdminControllers() []pact.AdminController {
|
||||||
return []pact.AdminController{conformController{}}
|
return []pact.AdminController{conformController{stamp: p.stamp}}
|
||||||
}
|
}
|
||||||
func (conformPlugin) Permissions() []pact.Permission {
|
func (conformPlugin) Permissions() []pact.Permission {
|
||||||
return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}}
|
return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}}
|
||||||
@@ -386,7 +400,7 @@ func (conformPlugin) Settings() []pact.SettingsItem {
|
|||||||
}
|
}
|
||||||
func (conformPlugin) AdminFS() fs.FS { return conformFS() }
|
func (conformPlugin) AdminFS() fs.FS { return conformFS() }
|
||||||
|
|
||||||
type conformController struct{}
|
type conformController struct{ stamp string }
|
||||||
|
|
||||||
func (conformController) ID() string { return "acme.conform.gadgets" }
|
func (conformController) ID() string { return "acme.conform.gadgets" }
|
||||||
func (conformController) ModelName() string { return "Gadget" }
|
func (conformController) ModelName() string { return "Gadget" }
|
||||||
@@ -403,6 +417,15 @@ func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
|
|||||||
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
|
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c conformController) AdminActions() []pact.AdminAction {
|
||||||
|
return []pact.AdminAction{{
|
||||||
|
Name: "lookup", Label: "Look up", Permissions: []string{"acme.conform.access"},
|
||||||
|
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||||
|
return pact.AdminActionResult{Message: "Looked up", Fill: map[string]any{"name": "lookup-" + c.stamp, "active": true}}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
func conformFS() fs.FS {
|
func conformFS() fs.FS {
|
||||||
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
|
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
|
||||||
return fstest.MapFS{
|
return fstest.MapFS{
|
||||||
@@ -483,6 +506,12 @@ update:
|
|||||||
type: relation-manager
|
type: relation-manager
|
||||||
relation: members
|
relation: members
|
||||||
context: [update]
|
context: [update]
|
||||||
|
lookup:
|
||||||
|
label: Lookup
|
||||||
|
type: widget
|
||||||
|
widget: acme-conform-lookup
|
||||||
|
action: lookup
|
||||||
|
fill: [name]
|
||||||
`),
|
`),
|
||||||
"models/settings/fields.yaml": file(`fields:
|
"models/settings/fields.yaml": file(`fields:
|
||||||
enabled:
|
enabled:
|
||||||
|
|||||||
@@ -94,13 +94,14 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
"POST /auth/refresh",
|
"POST /auth/refresh",
|
||||||
"POST /{vendor}/{plugin}/{controller}",
|
"POST /{vendor}/{plugin}/{controller}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
|
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
|
||||||
|
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||||
"PUT /settings/{code}",
|
"PUT /settings/{code}",
|
||||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||||
}
|
}
|
||||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 9 besides login):\n%s", strings.Join(unsafe, "\n"))
|
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 10 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||||
}
|
}
|
||||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||||
// nested pattern serving field options, filter options and relation lists.
|
// nested pattern serving field options, filter options and relation lists.
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ func TestPhase10CSRF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// refresh, logout, settings put, create, bulk-delete, update, delete, link, unlink
|
// refresh, logout, settings put, create, bulk-delete, widget action,
|
||||||
if unsafe != 9 {
|
// update, delete, link, unlink
|
||||||
t.Fatalf("walked %d state-changing routes, want 9: %v", unsafe, router.order)
|
if unsafe != 10 {
|
||||||
|
t.Fatalf("walked %d state-changing routes, want 10: %v", unsafe, router.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
loginHandler := router.handlers[login]
|
loginHandler := router.handlers[login]
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
|||||||
if err := BindWritableFields(compiled); err != nil {
|
if err := BindWritableFields(compiled); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := compileExtension(item.plugin.ID(), compiled, assets.AdminFS()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
byID[id] = compiled
|
byID[id] = compiled
|
||||||
}
|
}
|
||||||
return &Registry{byID: byID}, nil
|
return &Registry{byID: byID}, nil
|
||||||
@@ -179,6 +182,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for name, action := range controller.Actions {
|
||||||
|
if err := reg.validatePermissions("action "+id+"."+name, action.Permissions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, item := range reg.navigation {
|
for _, item := range reg.navigation {
|
||||||
if err := reg.validateNavigation(item); err != nil {
|
if err := reg.validateNavigation(item); err != nil {
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ type FormSchema struct {
|
|||||||
|
|
||||||
messageKeys formMessageKeys
|
messageKeys formMessageKeys
|
||||||
redirects FormRedirects
|
redirects FormRedirects
|
||||||
|
// fieldsPath is the fields.yaml the form was compiled from, for boot errors.
|
||||||
|
fieldsPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormView is one request's localized form, including the locale actually used.
|
// FormView is one request's localized form, including the locale actually used.
|
||||||
@@ -170,6 +172,14 @@ type FormField struct {
|
|||||||
Default *jsonScalar `json:"default,omitempty"`
|
Default *jsonScalar `json:"default,omitempty"`
|
||||||
Attributes map[string]jsonScalar `json:"attributes,omitempty"`
|
Attributes map[string]jsonScalar `json:"attributes,omitempty"`
|
||||||
Options []FormOption `json:"options,omitempty"`
|
Options []FormOption `json:"options,omitempty"`
|
||||||
|
// Widget is the custom-element tag of a `type: widget` field (D-06).
|
||||||
|
Widget string `json:"widget,omitempty"`
|
||||||
|
// Action names the controller action the widget runs (pact.HasAdminActions).
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
// ActionLabel is the action's Label, localized per request.
|
||||||
|
ActionLabel string `json:"actionLabel,omitempty"`
|
||||||
|
// Fill lists the fields of the same form the action writes back (D-07).
|
||||||
|
Fill []string `json:"fill,omitempty"`
|
||||||
|
|
||||||
optionsMethod string
|
optionsMethod string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ var phase09Routes = []adminRoute{
|
|||||||
{key: "GET /{vendor}/{plugin}/{controller}"},
|
{key: "GET /{vendor}/{plugin}/{controller}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}"},
|
{key: "POST /{vendor}/{plugin}/{controller}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
||||||
|
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
|
||||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
|
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
|
||||||
{key: "PUT /{vendor}/{plugin}/{controller}/{id}"},
|
{key: "PUT /{vendor}/{plugin}/{controller}/{id}"},
|
||||||
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}"},
|
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}"},
|
||||||
@@ -264,6 +265,7 @@ func phase09ProtectedCalls() []phase09Call {
|
|||||||
{"list", (*service).list},
|
{"list", (*service).list},
|
||||||
{"create", (*service).create},
|
{"create", (*service).create},
|
||||||
{"bulk-delete", (*service).bulkDelete},
|
{"bulk-delete", (*service).bulkDelete},
|
||||||
|
{"widget-action", (*service).widgetAction},
|
||||||
{"show", (*service).show},
|
{"show", (*service).show},
|
||||||
{"update", (*service).update},
|
{"update", (*service).update},
|
||||||
{"delete", (*service).deleteRecord},
|
{"delete", (*service).deleteRecord},
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
|
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
|
||||||
}
|
}
|
||||||
|
for _, field := range fields {
|
||||||
|
// A settings screen has no admin controller to own actions or view models.
|
||||||
|
if field.Type == "widget" {
|
||||||
|
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
|
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
|
||||||
columns := modelColumns(model)
|
columns := modelColumns(model)
|
||||||
fillable := map[string]struct{}{}
|
fillable := map[string]struct{}{}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
|
|||||||
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
|
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
|
||||||
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
|
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
|
||||||
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
|
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
|
||||||
|
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
||||||
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
|
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
|
||||||
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
|
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
|
||||||
- `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package.
|
- `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package.
|
||||||
@@ -83,6 +84,12 @@ func showPost(w http.ResponseWriter, r *http.Request) {}
|
|||||||
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
|
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
|
||||||
| `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. |
|
| `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. |
|
||||||
| `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. |
|
| `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. |
|
||||||
|
| `pact.AdminClientAssets` | Declares a controller's admin JS (`AdminJS`) and CSS (`AdminCSS`) files, paths under the plugin's `assets/` directory. |
|
||||||
|
| `pact.AdminAction` | One named controller action: name, label, extra permissions and the Go `Run` function. |
|
||||||
|
| `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, and the fill snapshot. |
|
||||||
|
| `pact.AdminActionResult` | What an action returns: a message for the toast and the fill write-back values. |
|
||||||
|
| `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. |
|
||||||
|
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
|
||||||
| `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. |
|
| `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. |
|
||||||
| `pact.Option` | One dropdown choice (value and label). |
|
| `pact.Option` | One dropdown choice (value and label). |
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,70 @@ type AdminRecordSource interface {
|
|||||||
NewRecord() any
|
NewRecord() any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminClientAssets is Winter's addJs/addCss for one admin controller. The
|
||||||
|
// paths are relative to the owning plugin's AdminFS and must live under
|
||||||
|
// assets/ (for example assets/js/lookup.js). The admin SPA loads them when the
|
||||||
|
// controller's list or form opens; files are always served from the embedded
|
||||||
|
// tree, never from disk. It is separate from AdminAssets, which is the YAML
|
||||||
|
// tree itself.
|
||||||
|
type AdminClientAssets interface {
|
||||||
|
AdminJS() []string
|
||||||
|
AdminCSS() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAction is one controller action that a list toolbar button or a form
|
||||||
|
// widget runs. The admin framework owns the HTTP route, the CSRF check,
|
||||||
|
// authentication and record scoping; Run only carries the business logic.
|
||||||
|
// Name is an identifier unique within the controller; create and delete are
|
||||||
|
// reserved for the built-in toolbar actions. Label is a phrase key or literal
|
||||||
|
// text used as the button caption. Permissions are checked in addition to the
|
||||||
|
// controller's RequiredPermissions.
|
||||||
|
type AdminAction struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Permissions []string
|
||||||
|
Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminActionInput is what the framework hands an AdminAction. Field is the
|
||||||
|
// widget field name and is empty for a toolbar action. RecordID is nil on the
|
||||||
|
// create form and always nil for a toolbar action: toolbar actions carry no
|
||||||
|
// record ids, so an id list can never become an unscoped lookup. Record is the
|
||||||
|
// record the framework loaded through the controller's FormExtendQuery scope,
|
||||||
|
// nil when RecordID is nil. Values is the widget's snapshot of its fill
|
||||||
|
// fields, already reduced to the field's declared fill keys and to scalars.
|
||||||
|
type AdminActionInput struct {
|
||||||
|
Field string
|
||||||
|
RecordID *uint64
|
||||||
|
Record any
|
||||||
|
Values map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminActionResult is an action's answer. Message is a phrase key or text,
|
||||||
|
// localized by the framework and shown as a toast. Fill is the widget
|
||||||
|
// write-back; keys outside the field's declared fill keys and non-scalar
|
||||||
|
// values are dropped before the response is written.
|
||||||
|
type AdminActionResult struct {
|
||||||
|
Message string
|
||||||
|
Fill map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAdminActions is implemented by an admin controller that registers named
|
||||||
|
// actions for its toolbar.buttons list and its `type: widget` form fields.
|
||||||
|
type HasAdminActions interface {
|
||||||
|
AdminActions() []AdminAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPartialData supplies the view model a controller partial template
|
||||||
|
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
|
||||||
|
// is the partial name; record is the scoped record for a form partial on an
|
||||||
|
// existing record, else nil. The result must be a curated view model built
|
||||||
|
// for the template, never the GORM model itself: the framework refuses a
|
||||||
|
// value of the controller's model type.
|
||||||
|
type AdminPartialData interface {
|
||||||
|
PartialData(ctx context.Context, name string, record any) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Permission is one registerPermissions() entry.
|
// Permission is one registerPermissions() entry.
|
||||||
type Permission struct {
|
type Permission struct {
|
||||||
Code string
|
Code string
|
||||||
|
|||||||
Reference in New Issue
Block a user