feat(17-01): tree reorder page and singleton admin controllers

- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
  sortColumn, maxDepth, partition) with fail-loud boot checks and the
  built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
  partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
  422 for duplicates, foreign parents, cycles and maxDepth, both columns
  written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
  scope checked), form schema singleton, list/create/bulk/delete 404,
  no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
  lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
This commit is contained in:
2026-10-12 15:35:43 +02:00
parent 46835fd064
commit 23f33c3911
31 changed files with 1905 additions and 15 deletions

View File

@@ -421,6 +421,125 @@
],
"type": "object"
},
"cabana.AdminReorderNode": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"parent_id": {
"type": "integer"
},
"sort_order": {
"type": "integer"
}
},
"required": [
"id",
"name",
"parent_id",
"sort_order"
],
"type": "object"
},
"cabana.AdminReorderNodeInput": {
"properties": {
"id": {
"type": "integer"
},
"parent_id": {
"type": "integer"
},
"sort_order": {
"type": "integer"
}
},
"required": [
"id",
"parent_id",
"sort_order"
],
"type": "object"
},
"cabana.AdminReorderPartition": {
"properties": {
"label": {
"type": "string"
},
"options": {
"items": {
"$ref": "#/components/schemas/cabana.AdminReorderPartitionOption"
},
"type": "array"
},
"value": {
"type": "integer"
}
},
"required": [
"label",
"options",
"value"
],
"type": "object"
},
"cabana.AdminReorderPartitionOption": {
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "integer"
}
},
"required": [
"label",
"value"
],
"type": "object"
},
"cabana.AdminReorderRequest": {
"properties": {
"nodes": {
"items": {
"$ref": "#/components/schemas/cabana.AdminReorderNodeInput"
},
"type": "array"
},
"partition": {
"type": "integer"
}
},
"required": [
"nodes",
"partition"
],
"type": "object"
},
"cabana.AdminReorderTree": {
"properties": {
"nodes": {
"items": {
"$ref": "#/components/schemas/cabana.AdminReorderNode"
},
"type": "array"
},
"partition": {
"$ref": "#/components/schemas/cabana.AdminReorderPartition"
},
"title": {
"type": "string"
}
},
"required": [
"nodes",
"partition",
"title"
],
"type": "object"
},
"cabana.AdminRoleSummary": {
"properties": {
"code": {
@@ -440,6 +559,17 @@
],
"type": "object"
},
"cabana.AdminSingletonResult": {
"properties": {
"id": {
"type": "integer"
}
},
"required": [
"id"
],
"type": "object"
},
"cabana.BulkAction": {
"properties": {
"confirm": {
@@ -747,6 +877,36 @@
],
"type": "object"
},
"cabana.Envelope-cabana_AdminReorderTree": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.AdminReorderTree"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_AdminSingletonResult": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.AdminSingletonResult"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_BulkActionResult": {
"properties": {
"data": {
@@ -1520,6 +1680,10 @@
}
],
"description": "Redirects are the raw Winter config_form.yaml targets; the SPA maps\nthem onto its routes."
},
"singleton": {
"description": "Singleton is true when the controller edits exactly one record\n(pact.AdminSingleton, D-11): the admin opens it through the singleton\nroute instead of a list.",
"type": "boolean"
}
},
"required": [
@@ -1748,6 +1912,34 @@
],
"type": "object"
},
"cabana.ListReorder": {
"properties": {
"partition": {
"$ref": "#/components/schemas/cabana.ListReorderPartition"
},
"title": {
"type": "string"
}
},
"required": [
"title"
],
"type": "object"
},
"cabana.ListReorderPartition": {
"properties": {
"column": {
"type": "string"
},
"label": {
"type": "string"
}
},
"required": [
"column"
],
"type": "object"
},
"cabana.ListSchema": {
"properties": {
"assets": {
@@ -1812,6 +2004,14 @@
"recordsPerPage": {
"type": "integer"
},
"reorder": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.ListReorder"
}
],
"description": "Reorder is set when the controller has a tree reorder page\n(config_reorder.yaml, D-09)."
},
"rowActions": {
"items": {
"$ref": "#/components/schemas/cabana.RowAction"
@@ -4553,6 +4753,233 @@
]
}
},
"/{vendor}/{plugin}/{controller}/reorder": {
"get": {
"description": "The records of a controller with config_reorder.yaml, scoped by the list scope (ListExtendQuery) and, when the page has a partition, by one partition: `partition` picks it (an id among `partition.options`; another id answers 404) and defaults to the first option. Nodes carry the id, parent_id (null at the top), sort_order and the nameFrom column as name, ordered by the sort column and then the id. A controller without a reorder page answers 404.",
"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": "Partition id",
"in": "query",
"name": "partition",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminReorderTree"
}
}
},
"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": "Load a reorder tree",
"tags": [
"admin"
]
},
"post": {
"description": "Saves the parent and sort columns of every record of the scoped set in one transaction that locks the set (ListExtendQuery plus the partition). `nodes` must list exactly the records of that set: any difference (a record added, removed or moved to another partition meanwhile) answers 409 `reorder_stale` and writes nothing. A duplicate id, a parent outside the set, a cycle and a depth above maxDepth answer 422 `validation_failed` on `nodes`; a missing partition on a partitioned page is a 422 on `partition`. The answer is the new tree.",
"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"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminReorderRequest"
}
}
},
"description": "The partition and every node with its new parent and sort value",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminReorderTree"
}
}
},
"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"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Conflict"
},
"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": "Save a reorder tree",
"tags": [
"admin"
]
}
},
"/{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. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route. A `type: richeditor` field's value is a string of HTML, sanitized on every save; what the sanitizer removed is reported in the save response's `meta.notices`. A `type: repeater` field carries `form.fields` (the fields of one row), `prompt`, `maxItems`, `minItems` and `titleFrom`; its value in a record response and in a save body is a list of row objects keyed by the nested field names, and a row's errors are keyed `\u003cfield\u003e.\u003cindex\u003e.\u003cnested\u003e`.",
@@ -4815,6 +5242,91 @@
]
}
},
"/{vendor}/{plugin}/{controller}/singleton": {
"get": {
"description": "The id of the one record a pact.AdminSingleton controller edits, created by the controller's SingletonRecord when it does not exist yet. The admin opens that record's form. The form schema of such a controller carries `singleton: true`, and its list, create, bulk and delete routes answer 404. A controller that is not a singleton answers 404.",
"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"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminSingletonResult"
}
}
},
"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": "Open a singleton record",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/toolbar/{action}": {
"post": {
"description": "Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.",

View File

@@ -1887,6 +1887,180 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/reorder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Load a reorder tree
* @description The records of a controller with config_reorder.yaml, scoped by the list scope (ListExtendQuery) and, when the page has a partition, by one partition: `partition` picks it (an id among `partition.options`; another id answers 404) and defaults to the first option. Nodes carry the id, parent_id (null at the top), sort_order and the nameFrom column as name, ordered by the sort column and then the id. A controller without a reorder page answers 404.
*/
get: {
parameters: {
query?: {
/** @description Partition id */
partition?: number;
};
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminReorderTree"];
};
};
/** @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"];
};
};
};
};
put?: never;
/**
* Save a reorder tree
* @description Saves the parent and sort columns of every record of the scoped set in one transaction that locks the set (ListExtendQuery plus the partition). `nodes` must list exactly the records of that set: any difference (a record added, removed or moved to another partition meanwhile) answers 409 `reorder_stale` and writes nothing. A duplicate id, a parent outside the set, a cycle and a depth above maxDepth answer 422 `validation_failed` on `nodes`; a missing partition on a partitioned page is a 422 on `partition`. The answer is the new tree.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
};
cookie?: never;
};
/** @description The partition and every node with its new parent and sort value */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminReorderRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminReorderTree"];
};
};
/** @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 Conflict */
409: {
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}/schema/form": {
parameters: {
query?: never;
@@ -2102,6 +2276,79 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/singleton": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Open a singleton record
* @description The id of the one record a pact.AdminSingleton controller edits, created by the controller's SingletonRecord when it does not exist yet. The admin opens that record's form. The form schema of such a controller carries `singleton: true`, and its list, create, bulk and delete routes answer 404. A controller that is not a singleton answers 404.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminSingletonResult"];
};
};
/** @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;
};
"/{vendor}/{plugin}/{controller}/toolbar/{action}": {
parameters: {
query?: never;
@@ -5113,11 +5360,43 @@ export interface components {
[key: string]: unknown;
};
};
"cabana.AdminReorderNode": {
id: number;
name: string;
parent_id: number;
sort_order: number;
};
"cabana.AdminReorderNodeInput": {
id: number;
parent_id: number;
sort_order: number;
};
"cabana.AdminReorderPartition": {
label: string;
options: components["schemas"]["cabana.AdminReorderPartitionOption"][];
value: number;
};
"cabana.AdminReorderPartitionOption": {
label: string;
value: number;
};
"cabana.AdminReorderRequest": {
nodes: components["schemas"]["cabana.AdminReorderNodeInput"][];
partition: number;
};
"cabana.AdminReorderTree": {
nodes: components["schemas"]["cabana.AdminReorderNode"][];
partition: components["schemas"]["cabana.AdminReorderPartition"];
title: string;
};
"cabana.AdminRoleSummary": {
code: string;
id: number;
name: string;
};
"cabana.AdminSingletonResult": {
id: number;
};
"cabana.BulkAction": {
confirm?: string;
label?: string;
@@ -5194,6 +5473,14 @@ export interface components {
data: components["schemas"]["cabana.AdminRecord"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_AdminReorderTree": {
data: components["schemas"]["cabana.AdminReorderTree"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_AdminSingletonResult": {
data: components["schemas"]["cabana.AdminSingletonResult"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_BulkActionResult": {
data: components["schemas"]["cabana.BulkActionResult"];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -5484,6 +5771,12 @@ export interface components {
* them onto its routes.
*/
redirects: components["schemas"]["cabana.FormRedirects"];
/**
* @description Singleton is true when the controller edits exactly one record
* (pact.AdminSingleton, D-11): the admin opens it through the singleton
* route instead of a list.
*/
singleton?: boolean;
};
"cabana.LangBundle": {
[key: string]: components["schemas"]["cabana.MessageForms"];
@@ -5550,6 +5843,14 @@ export interface components {
};
total: number;
};
"cabana.ListReorder": {
partition?: components["schemas"]["cabana.ListReorderPartition"];
title: string;
};
"cabana.ListReorderPartition": {
column: string;
label?: string;
};
"cabana.ListSchema": {
/** @description Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets). */
assets: components["schemas"]["cabana.ControllerAssets"];
@@ -5573,6 +5874,11 @@ export interface components {
perPageOptions: number[];
recordUrl?: string;
recordsPerPage: number;
/**
* @description Reorder is set when the controller has a tree reorder page
* (config_reorder.yaml, D-09).
*/
reorder?: components["schemas"]["cabana.ListReorder"];
rowActions: components["schemas"]["cabana.RowAction"][];
searchPrompt?: string;
searchTerm: string;

View File

@@ -446,6 +446,15 @@ The admin SPA shows the declared actions in a "Bulk actions" menu next to the se
Bulk actions have their own namespace next to the toolbar and widget actions: a name is unique among the controller's bulk actions, and `create` and `delete` stay reserved. `bulkActions` needs `showCheckboxes: true`. A name the controller does not register, a duplicate, or an action without a label stops the start-up. The built-in bulk delete is not a declared action and keeps its own route and toolbar button.
## Singleton controllers
A controller that edits exactly one record, such as a site-wide definition, implements `pact.AdminSingleton`. Its `SingletonRecord(ctx, db)` returns that record and creates it first when it does not exist (first or create), with `db` the request's transaction. Make the creation safe against two first requests at once, for example with a unique key column and GORM's `FirstOrCreate`.
- `GET <prefix>/api/v1/{vendor}/{plugin}/{controller}/singleton` answers the record's id (`cabana.AdminSingletonResult`), after checking that the form scope (`pact.FormExtendQuery`) can load it. The admin then opens the record's form.
- The form schema carries `singleton: true`. Show and update work as on any controller.
- The controller needs a `config_form.yaml` and no `config_list.yaml`. Its list, create, bulk and delete routes answer 404 `not_found`, and the singleton route answers 404 on any other controller.
- A settings entry whose `Controller` names the singleton controller links to it; see [Settings](settings.md).
## Record actions
A record action runs on one record, as a button on its screen: activating an account, lifting a ban. The controller registers its record actions through `pact.HasAdminRecordActions` (`PeopleController` above registers two), and `recordActions` in `config_form.yaml` lists the ones the form offers, in display order:

View File

@@ -37,7 +37,7 @@ toolbar:
| `recordsPerPage`, `perPageOptions` | The page size and the sizes a user may pick. |
| `showCheckboxes`, `showSetup`, `showSorting`, `showSearch` | Which list controls appear. |
| `defaultSort` | `column` and `direction` of the initial order. |
| `toolbar` | `buttons` (the built-in `create` and `delete` and registered actions) and `search.prompt`. |
| `toolbar` | `buttons` (the built-in `create`, `delete` and `reorder` and registered actions) and `search.prompt`. `reorder` needs a `config_reorder.yaml`; see [Tree reorder](#tree-reorder). |
| `filter` | The filter file, relative to the controller's directory. |
| `headerPartial` | A server-rendered strip above the list; see [Partials and widgets](partials-and-widgets.md). |
| `bulkActions` | The bulk actions the list offers for the selected rows: a list of names the controller registers through `pact.HasAdminBulkActions`. Needs `showCheckboxes: true`. |
@@ -167,6 +167,44 @@ The badge texts are list messages. Override them per list in the `messages` bloc
A list that shows soft-deleted records includes them in its scope, as `withTrashed()` does in WinterCMS: return `db.Unscoped()` (narrowed as needed) from `pact.ListExtendQuery` and `pact.FormExtendQuery`. Such a record can then be shown, updated and targeted by bulk and record actions, and it stays soft-deleted through an update. Code that writes to it, such as an action's `Run`, must use an unscoped handle too (`tx.Unscoped()`), or GORM adds its `deleted_at IS NULL` condition and the write matches nothing. Deleting it through the admin soft-deletes again, which changes nothing; a controller that wants the delete to be permanent removes the row in `pact.FormAfterDelete`.
## Tree reorder
A controller whose records form a tree (a `parent_id` and a `sort_order` column, such as menu entries) gets a drag-and-drop reorder page, the successor of WinterCMS's ReorderController. It needs a `config_reorder.yaml` next to `config_list.yaml` and the built-in `reorder` button in `toolbar.buttons`:
```yaml src=modules/cabana/testdata/companions/controllers/links/config_reorder.yaml
title: Reorder links
nameFrom: title
modelClass: Link
parentColumn: parent_id
sortColumn: sort_order
maxDepth: 3
partition:
column: group_id
relation: group
label: Group
```
| Key | Sets |
|-----|------|
| `title` | The page title, a translation key or text. |
| `nameFrom` | The column a node shows. |
| `modelClass` | Optional; when set it must equal the controller's model name, as in WinterCMS's `reorder_config.yaml`. |
| `parentColumn`, `sortColumn` | The tree columns, `parent_id` and `sort_order` by default. Both are written by a save. |
| `maxDepth` | The deepest level a node may sit at, counting the top level as 1; 0 (the default) is unlimited. |
| `partition` | Optional: reorder within one value of a column, such as the entries of one menu. `column` is that column, `relation` the form's `type: relation` belongsTo field over it (its options, scoped by `pact.RelationExtendOptionsQuery`, are the partitions offered), `nameFrom` an optional label column of the related record and `label` the selector's label. |
Any other key, a column that is not a column of the controller's record, a partition relation that is not a belongsTo relation field of the form over the partition column, a `reorder` button without the file, and a `reorder` button on a controller that registers an action of that name stop the start-up. The list schema then carries `reorder` with the title and the partition column, and WinterCMS's `toolbar` key of `reorder_config.yaml` is not supported.
The page reads `GET <prefix>/api/v1/{vendor}/{plugin}/{controller}/reorder?partition=<id>`, which answers the title, the partition (its label, the options and the value shown, the first option by default) and every record of the scoped set as nodes with `id`, `parent_id`, `sort_order` and `name`, ordered by the sort column and then the id. The scoped set is what the list scope (`pact.ListExtendQuery`) returns within the partition.
A save posts `{partition, nodes: [{id, parent_id, sort_order}]}` to `POST .../reorder`, with every node of the set exactly once. One transaction locks the scoped set (`SELECT ... FOR UPDATE`) and compares it with the posted ids:
- Any difference (a record was added or removed meanwhile, or moved to another partition, or the post names a record of another partition) answers 409 with the error code `reorder_stale` and the message `reorder.stale`, and writes nothing. The admin reloads the tree.
- A duplicate id, a parent outside the set, a cycle and a node deeper than `maxDepth` are 422 `validation_failed` on `nodes`; a partitioned page without `partition` is a 422 on `partition`.
- Otherwise the parent and sort columns of every node are written, without running the model's save hooks, and the answer is the new tree.
Both routes need the controller's permissions; the save is a write route (CSRF header, and a personal token needs a scope that allows POST).
## Remembered list state
The admin keeps a list's search, sort, filters, page and page size in the URL, so a filtered list can be bookmarked or shared. It also remembers the last of these per list in the browser's local storage. When an administrator opens a list without any of these parameters in the URL (from the navigation, a breadcrumb or a form's back, save or delete), the admin restores the remembered query. A URL that carries list parameters wins and replaces the remembered query.

View File

@@ -85,6 +85,8 @@ A link entry is not a singleton and needs no embedded admin tree. [cabana](../..
`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code.
A link entry may also open a [singleton controller](admin-controllers.md#singleton-controllers), one whose `pact.AdminSingleton` record the admin opens directly in its form, the way WinterCMS plugins auto-created one definition and redirected to it. The entry stays a link: the admin asks the controller's singleton route for the record and opens it.
In the admin, the entry's card on the Settings page opens the controller's pages. When the controller has no main navigation item of its own, the admin treats it as part of Settings: the rail marks Settings as current and the breadcrumbs read Settings, then the entry's label.
## How values are stored

View File

@@ -40,6 +40,8 @@ Use cases:
- 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.
- Rich text: `type: richeditor` edits HTML in a host text column (the admin SPA shows a WYSIWYG editor). It takes the common field keys plus `size`; any other key fails boot, and settings and relation forms refuse the type (supported in controller forms only). On create and update a submitted value must be a string or null (anything else is a 422 on the field) and is sanitized through [seaglass](../seaglass/README.md) before it is filled: HTML the policy keeps whole is stored byte for byte, otherwise the clean HTML is stored and what was removed is reported in `meta.notices` under the field (`backend::lang.richeditor.removed_elements`, `removed_attributes`, `removed_comments` with the count and names, or `markup_changed`).
- Repeater: `type: repeater` edits a JSON list in one column (`lagoon.Jsonable`) with a nested `form.fields` of `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or `dependsOn` a field of the same row) and `richeditor` fields, plus `prompt`, `minItems`, `maxItems` and `titleFrom` (`cabana.RepeaterForm` in the schema; the span is always full). Other nested types, a nested repeater, nested `tab`, `context` or `preset`, and WinterCMS's `groups` and `style` fail boot; settings and relation forms refuse the type. A save decodes the rows before the projection: null is an empty list, anything but a list of objects is a 422 on the field, each row keeps only the nested names with scalars coerced per type, a required nested value missing in a row is a 422 under `field.index.nested`, the row bounds are 422s on the field (`repeater.max_items`, `repeater.min_items`), nested richeditor values are sanitized with notices under the row path, and nested dependent dropdowns are rechecked. Record responses carry the list of rows.
- Tree reorder (D-09): a `config_reorder.yaml` (`title`, `nameFrom`, `modelClass`, `parentColumn` default `parent_id`, `sortColumn` default `sort_order`, `maxDepth`, and an optional `partition` with `column`, `relation`, `nameFrom` and `label`) gives the controller a reorder page and the built-in `reorder` toolbar button; unknown keys, columns that are not record columns, a partition relation that is not a belongsTo relation field over the column, and a `reorder` button without the file fail boot. The list schema carries `reorder` (`cabana.ListReorder`). GET `.../reorder` answers the scoped tree (`cabana.AdminReorderTree`); POST `.../reorder` locks the scoped set (ListExtendQuery plus the partition) in one transaction, answers 409 `reorder_stale` when the posted ids differ from it in any way, 422 for a duplicate, a foreign parent, a cycle or a depth above `maxDepth`, and otherwise writes both tree columns without model hooks.
- Singleton controllers (D-11): a controller implementing `pact.AdminSingleton` needs only `config_form.yaml`; GET `.../singleton` answers the id of the record `SingletonRecord` returns (created when absent) after checking the form scope, the form schema carries `singleton`, and the list, create, bulk and delete routes answer 404. A settings link entry may open it.
- Dependent dropdowns: `dependsOn: <field>` on a `type: dropdown` field names a sibling with a scalar value (the same form, or the same repeater row); the dropdown takes no `options` and its controller implements `pact.DependentOptionsProvider`, else boot fails. GET `.../fields/{field}/dependent-options?values[<sibling>]=<value>` answers `cabana.AdminDependentOptionsResult` (`applies` and the localized `options` with their `depth`); `{field}` is `repeater.field` for a dropdown in a repeater row; the form schema carries `dependsOn`. Every save rechecks the value with the submitted sibling value (per row in a repeater): a value that does not apply is stored null, one outside the options is a 422 `form.dependent_invalid` on the field. A menu or sitemap controller returns `signpost.ReferenceOptions` here, the one admin binding of the [signpost](../signpost/README.md) menu item type registry. Settings and relation forms refuse `dependsOn` (supported in controller forms only).
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
@@ -84,6 +86,8 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. |
| GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
| GET and POST `/{vendor}/{plugin}/{controller}/reorder` | Load the scoped reorder tree (`?partition=`) and save it: `{partition, nodes: [{id, parent_id, sort_order}]}` with every node exactly once, 409 `reorder_stale` for a changed set. Needs `config_reorder.yaml`. |
| GET `/{vendor}/{plugin}/{controller}/singleton` | The id of a `pact.AdminSingleton` controller's one record, created when absent. |
| GET `.../fields/{field}/dependent-options` | Choices of a dropdown with `dependsOn` for the sibling value passed as `values[<sibling>]`, from the controller's `pact.DependentOptionsProvider`: `{applies, options: [{value, label, depth}]}` (`cabana.AdminDependentOptionsResult`). A field that is not a dependsOn dropdown answers 404. |
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. A link body may carry `pivot` values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key. |
@@ -234,6 +238,11 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
| `cabana.AdminDependentOptionsResult` / `cabana.AdminDependentOption` | Answer of the dependent-options route: `applies` and the options, each with `value`, localized `label` and `depth`. |
| `cabana.AdminDependentOptions` | Swag annotation of the dependent-options route. |
| `cabana.ListReorder` / `cabana.ListReorderPartition` | The list schema's `reorder` object: the page title and the partition column and label. |
| `cabana.AdminReorderTree` / `cabana.AdminReorderNode` / `cabana.AdminReorderPartition` / `cabana.AdminReorderPartitionOption` | Answer of the reorder routes: title, partition (label, options, value) and the nodes with `id`, `parent_id`, `sort_order` and `name`. |
| `cabana.AdminReorderRequest` / `cabana.AdminReorderNodeInput` | Body of the reorder save. |
| `cabana.AdminReorderGet` / `cabana.AdminReorderSave` | Swag annotations of the reorder routes. |
| `cabana.AdminSingletonResult` / `cabana.AdminSingletonRecord` | Answer and swag annotation of the singleton route. |
| `cabana.RepeaterForm` | The `form` of a `type: repeater` field in a form schema: the fields of one row. |
| `cabana.FormPreview` | The `preview` object of a form schema: present when the form has a preview screen; `headerPartial` names its status hint partial. |
| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label and optional confirm text. |

View File

@@ -132,6 +132,9 @@ func toolbarActionOf(cc *CompiledController, name string) (pact.AdminAction, boo
// plugin receives loaded records and never an id.
func (s *service) bulkAction(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if singletonRefuses(w, r, cc) {
return
}
name := r.PathValue("action")
action, ok := bulkActionOf(cc, name)
if !ok {

View File

@@ -481,6 +481,64 @@ func AdminRelationSchema() {}
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
func AdminFieldOptions() {}
// AdminReorderGet documents the tree reorder load route (D-09).
//
// @Summary Load a reorder tree
// @Description The records of a controller with config_reorder.yaml, scoped by the list scope (ListExtendQuery) and, when the page has a partition, by one partition: `partition` picks it (an id among `partition.options`; another id answers 404) and defaults to the first option. Nodes carry the id, parent_id (null at the top), sort_order and the nameFrom column as name, ordered by the sort column and then the id. A controller without a reorder page answers 404.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param partition query integer false "Partition id"
// @Success 200 {object} Envelope[AdminReorderTree]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/reorder [get]
func AdminReorderGet() {}
// AdminReorderSave documents the tree reorder save route (D-09).
//
// @Summary Save a reorder tree
// @Description Saves the parent and sort columns of every record of the scoped set in one transaction that locks the set (ListExtendQuery plus the partition). `nodes` must list exactly the records of that set: any difference (a record added, removed or moved to another partition meanwhile) answers 409 `reorder_stale` and writes nothing. A duplicate id, a parent outside the set, a cycle and a depth above maxDepth answer 422 `validation_failed` on `nodes`; a missing partition on a partitioned page is a 422 on `partition`. The answer is the new tree.
// @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 body body AdminReorderRequest true "The partition and every node with its new parent and sort value"
// @Success 200 {object} Envelope[AdminReorderTree]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/reorder [post]
func AdminReorderSave() {}
// AdminSingleton documents the singleton record route (D-11).
//
// @Summary Open a singleton record
// @Description The id of the one record a pact.AdminSingleton controller edits, created by the controller's SingletonRecord when it does not exist yet. The admin opens that record's form. The form schema of such a controller carries `singleton: true`, and its list, create, bulk and delete routes answer 404. A controller that is not a singleton answers 404.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} Envelope[AdminSingletonResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/singleton [get]
func AdminSingletonRecord() {}
// AdminDependentOptions documents the dependent dropdown options route (D-08).
//
// @Summary Dependent dropdown options

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
@@ -40,8 +41,33 @@ type companionsLink struct {
Description *string `gorm:"column:description"`
// Rows is the repeater field.
Rows lagoon.Jsonable[[]map[string]any] `gorm:"column:rows"`
// GroupID is the reorder partition (the group relation field), ParentID
// and SortOrder the reorder tree columns.
GroupID *uint `gorm:"column:group_id"`
ParentID *uint `gorm:"column:parent_id"`
SortOrder int `gorm:"column:sort_order"`
}
// companionsGroup is the belongsTo target of the links' group field and the
// reorder partition.
type companionsGroup struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
}
func (companionsGroup) TableName() string { return "cabana_companions_groups" }
// companionsProfile is the one record of the singleton profiles controller.
type companionsProfile struct {
ID uint `gorm:"column:id;primaryKey"`
Key string `gorm:"column:key;uniqueIndex"`
Title string `gorm:"column:title"`
}
func (companionsProfile) TableName() string { return "cabana_companions_profiles" }
func (companionsProfile) Fillable() []string { return []string{"title"} }
func (companionsProfile) Rules() map[string]string { return map[string]string{} }
func (companionsLink) TableName() string { return "cabana_companions_links" }
func (companionsLink) Fillable() []string {
return []string{"title", "type", "reference", "description", "rows"}
@@ -49,7 +75,9 @@ func (companionsLink) Fillable() []string {
func (companionsLink) Rules() map[string]string { return map[string]string{"title": "required"} }
// companionsModels are the fixture tables, dropped and recreated per env.
func companionsModels() []any { return []any{&companionsLink{}} }
func companionsModels() []any {
return []any{&companionsLink{}, &companionsGroup{}, &companionsProfile{}}
}
// companionsEcho resolves an item to its layered pattern.
func companionsEcho(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
@@ -66,7 +94,13 @@ func (companionsPlugin) Register(*backpack.App) error { return nil }
func (companionsPlugin) Boot(*backpack.App) error { return nil }
func (companionsPlugin) AdminFS() fs.FS { return os.DirFS(companionsDir) }
func (p companionsPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{companionsLinks{app: p.app}}
return []pact.AdminController{companionsLinks{app: p.app}, companionsProfiles{}}
}
// Settings links the singleton profiles controller from the settings page.
func (companionsPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{Code: "companions_profile", Label: "Site profile", Category: "Acme", Icon: "settings",
Permissions: []string{"acme.companions.access"}, Controller: "acme.companions.profiles"}}
}
func (companionsPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.companions.access", Roles: []string{"developer"}}}
@@ -82,6 +116,8 @@ func (companionsPlugin) MenuItemTypes() []pact.MenuItemType {
}
var (
_ pact.AdminSingleton = companionsProfiles{}
_ cabana.FieldRelationProvider = companionsLinks{}
_ pact.HasMenuItemTypes = companionsPlugin{}
_ pact.DependentOptionsProvider = companionsLinks{}
_ pact.DropdownOptionsProvider = companionsLinks{}
@@ -98,6 +134,28 @@ func (companionsLinks) ConfigDir() string { return "controllers/link
func (companionsLinks) RequiredPermissions() []string { return []string{"acme.companions.access"} }
func (companionsLinks) NewRecord() any { return &companionsLink{} }
// AdminFieldRelations binds the group relation field, the reorder partition.
func (companionsLinks) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &companionsGroup{} }, ForeignKey: "group_id"}}
}
// companionsProfiles is a singleton controller: one profile record, created
// on first use.
type companionsProfiles struct{}
func (companionsProfiles) ID() string { return "acme.companions.profiles" }
func (companionsProfiles) ModelName() string { return "Profile" }
func (companionsProfiles) ConfigDir() string { return "controllers/profiles" }
func (companionsProfiles) RequiredPermissions() []string { return []string{"acme.companions.access"} }
func (companionsProfiles) NewRecord() any { return &companionsProfile{} }
// SingletonRecord is first or create on the unique key column.
func (companionsProfiles) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
profile := &companionsProfile{}
err := db.Where(companionsProfile{Key: "default"}).Attrs(companionsProfile{Title: "Default"}).FirstOrCreate(profile).Error
return profile, err
}
func (c companionsLinks) registry() *signpost.Registry {
if c.app == nil {
return nil
@@ -374,3 +432,134 @@ func TestCompanionsRepeaterSmoke(t *testing.T) {
t.Fatalf("nested dependent options status=%d body=%s", opts.Code, opts.Body.String())
}
}
// reorderTree is the decoded data of the links reorder routes.
func reorderTree(t *testing.T, rec *httptest.ResponseRecorder) cabana.AdminReorderTree {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("reorder status=%d body=%s", rec.Code, rec.Body.String())
}
var body cabana.Envelope[cabana.AdminReorderTree]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.Data
}
func reorderNode(id uint, parent *uint, sort int64) map[string]any {
node := map[string]any{"id": id, "parent_id": nil, "sort_order": sort}
if parent != nil {
node["parent_id"] = *parent
}
return node
}
// TestCompanionsReorderSmoke checks the D-09 tree reorder: the GET lists only
// the partition's records, a save nests a record and writes both columns, and
// a stale set (a record left out, or one of another partition), a cycle and
// a missing partition are refused without writing.
func TestCompanionsReorderSmoke(t *testing.T) {
e := newCompanionsEnv(t)
one, two := companionsGroup{Title: "One"}, companionsGroup{Title: "Two"}
if err := e.db.Create(&one).Error; err != nil {
t.Fatal(err)
}
if err := e.db.Create(&two).Error; err != nil {
t.Fatal(err)
}
links := []companionsLink{
{Title: "A", GroupID: &one.ID, SortOrder: 1},
{Title: "B", GroupID: &one.ID, SortOrder: 2},
{Title: "C", GroupID: &one.ID, SortOrder: 3},
{Title: "D", GroupID: &two.ID, SortOrder: 1},
}
for i := range links {
if err := e.db.Create(&links[i]).Error; err != nil {
t.Fatal(err)
}
}
a, b, c, d := links[0].ID, links[1].ID, links[2].ID, links[3].ID
path := fmt.Sprintf("/acme/companions/links/reorder?partition=%d", one.ID)
tree := reorderTree(t, e.send(t, http.MethodGet, path, nil, true))
if tree.Partition == nil || tree.Partition.Value == nil || *tree.Partition.Value != one.ID || len(tree.Partition.Options) != 2 || len(tree.Nodes) != 3 || tree.Nodes[0].Name != "A" {
t.Fatalf("tree = %+v", tree)
}
save := func(nodes ...map[string]any) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": one.ID, "nodes": nodes}, true)
}
reorderTree(t, save(reorderNode(a, nil, 1), reorderNode(b, &a, 1), reorderNode(c, nil, 2)))
var storedB, storedC companionsLink
if err := e.db.First(&storedB, b).Error; err != nil || storedB.ParentID == nil || *storedB.ParentID != a || storedB.SortOrder != 1 {
t.Fatalf("b = %+v (%v), want parent a and sort 1", storedB, err)
}
if err := e.db.First(&storedC, c).Error; err != nil || storedC.ParentID != nil || storedC.SortOrder != 2 {
t.Fatalf("c = %+v (%v), want top level and sort 2", storedC, err)
}
unchanged := func(t *testing.T) {
t.Helper()
var again companionsLink
if err := e.db.First(&again, b).Error; err != nil || again.ParentID == nil || *again.ParentID != a || again.SortOrder != 1 {
t.Fatalf("a refused save wrote b: %+v", again)
}
}
for name, rec := range map[string]*httptest.ResponseRecorder{
"omitted node": save(reorderNode(a, nil, 1), reorderNode(b, nil, 2)),
"node of a partition": save(reorderNode(a, nil, 1), reorderNode(b, nil, 2), reorderNode(d, nil, 3)),
} {
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"code":"reorder_stale"`) {
t.Fatalf("%s status=%d body=%s", name, rec.Code, rec.Body.String())
}
unchanged(t)
}
cycle := save(reorderNode(a, &b, 1), reorderNode(b, &a, 1), reorderNode(c, nil, 2))
if cycle.Code != http.StatusUnprocessableEntity || !strings.Contains(cycle.Body.String(), `"nodes"`) {
t.Fatalf("cycle status=%d body=%s", cycle.Code, cycle.Body.String())
}
unchanged(t)
missing := e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"nodes": []any{}}, true)
if missing.Code != http.StatusUnprocessableEntity || !strings.Contains(missing.Body.String(), `"partition"`) {
t.Fatalf("missing partition status=%d body=%s", missing.Code, missing.Body.String())
}
schema := e.send(t, http.MethodGet, "/acme/companions/links/schema/list", nil, true)
if !strings.Contains(schema.Body.String(), `"reorder":{"title":"Reorder links","partition":{"column":"group_id","label":"Group"}}`) {
t.Fatalf("list schema reorder = %s", schema.Body.String())
}
}
// TestCompanionsSingletonSmoke checks the D-11 singleton controller: the
// first GET creates the record, the second answers the same id, and the
// list, create and delete routes answer 404.
func TestCompanionsSingletonSmoke(t *testing.T) {
e := newCompanionsEnv(t)
first := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
id := dataID(t, first.Body.Bytes())
second := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
if again := dataID(t, second.Body.Bytes()); again != id {
t.Fatalf("second id = %d, want %d", again, id)
}
var count int64
if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("profiles = %d (%v)", count, err)
}
for _, rec := range []*httptest.ResponseRecorder{
e.send(t, http.MethodGet, "/acme/companions/profiles", nil, true),
e.send(t, http.MethodDelete, fmt.Sprintf("/acme/companions/profiles/%d", id), nil, true),
e.send(t, http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true),
} {
if rec.Code != http.StatusNotFound {
t.Fatalf("singleton route status=%d body=%s", rec.Code, rec.Body.String())
}
}
if rec := e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/profiles/%d", id), map[string]any{"title": "Renamed"}, true); rec.Code != http.StatusOK {
t.Fatalf("singleton update status=%d body=%s", rec.Code, rec.Body.String())
}
schema := e.send(t, http.MethodGet, "/acme/companions/profiles/schema/form", nil, true)
if !strings.Contains(schema.Body.String(), `"singleton":true`) {
t.Fatalf("form schema = %s", schema.Body.String())
}
if rec := e.send(t, http.MethodGet, "/acme/companions/links/singleton", nil, true); rec.Code != http.StatusNotFound {
t.Fatalf("non-singleton status=%d", rec.Code)
}
}

View File

@@ -137,6 +137,11 @@ type CompiledController struct {
// permissions are the form's `type: permissioneditor` fields: field name
// to mode (radio or checkbox).
permissions map[string]string
// reorder is the compiled config_reorder.yaml; nil without one (D-09).
reorder *compiledReorder
// singleton is true for a controller implementing pact.AdminSingleton
// (D-11): one record, opened directly; no list, create, bulk or delete.
singleton bool
}
// Registry is the immutable controller map keyed by controller ID.

View File

@@ -284,6 +284,7 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
Name: translateKey(ctx, tr, s.Name),
ModelClass: s.ModelClass,
Fields: fields,
Singleton: s.singleton,
Preview: preview,
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
Redirects: s.redirects,

View File

@@ -297,6 +297,14 @@ func (s *service) mount(r pact.Router) {
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
constrainController(g)
// Tree reorder page (D-09): the scoped set and its locked save.
g.Get("/{vendor}/{plugin}/{controller}/reorder", s.reorderGet)
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}/reorder", requireAjax(s.reorderPost))
constrainController(g)
// The one record of a pact.AdminSingleton controller (D-11).
g.Get("/{vendor}/{plugin}/{controller}/singleton", s.singletonRoute)
constrainController(g)
// Declared bulk actions (D-09): ids are resolved through the list
// scope before plugin code runs.
g.Post("/{vendor}/{plugin}/{controller}/bulk/{action}", requireAjax(s.bulkAction))
@@ -1043,6 +1051,9 @@ func (s *service) enabledContentLocales(ctx context.Context) []string {
func (s *service) list(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if singletonRefuses(w, r, cc) {
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
@@ -1101,6 +1112,10 @@ func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*Compi
// the toolbar `delete` button, which in turn needs showCheckboxes. The answer is
// 403 with the same envelope as a permission failure.
func (s *service) operationDeclared(w http.ResponseWriter, r *http.Request, cc *CompiledController, op string) bool {
// A singleton controller (D-11) has no create, delete or bulk delete.
if op != "update" && singletonRefuses(w, r, cc) {
return false
}
if cc.operationDeclared(op) {
return true
}

View File

@@ -366,6 +366,15 @@ func compileToolbarButtons(ctl pact.AdminController, toolbar *listToolbar, showC
out := append([]string(nil), toolbar.Buttons.items...)
custom := []ToolbarAction{}
for _, name := range out {
if name == "reorder" {
// The built-in tree reorder page: config_reorder.yaml is checked
// in compileReorder, and a registered action may not take the
// name.
if _, taken := registered[name]; taken {
return nil, nil, fmt.Errorf("toolbar.buttons: reorder is the built-in tree reorder; rename the registered action")
}
continue
}
if builtinToolbarActions[name] {
if name == "delete" && !showCheckboxes {
return nil, nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
@@ -594,6 +603,13 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*
sort := *s.DefaultSort
out.DefaultSort = &sort
}
if s.Reorder != nil {
reorder := ListReorder{Title: translateKey(ctx, tr, s.Reorder.Title)}
if p := s.Reorder.Partition; p != nil {
reorder.Partition = &ListReorderPartition{Column: p.Column, Label: translateKey(ctx, tr, p.Label)}
}
out.Reorder = &reorder
}
out.Columns = localizeListColumns(ctx, tr, s.Columns)
out.Filters = localizeListFilters(ctx, tr, s.Filters)
out.RowActions = localizeRowActions(ctx, tr, s.RowActions)

View File

@@ -290,6 +290,31 @@ func conformCases() []conformCase {
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 200, "cabana.Envelope-cabana_AdminDependentOptionsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, true)
}, into[cabana.Envelope[cabana.AdminDependentOptionsResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/reorder", 200, "cabana.Envelope-cabana_AdminReorderTree", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
group := companionsGroup{Title: "Conform " + e.stamp}
if err := e.db.Create(&group).Error; err != nil {
t.Fatal(err)
}
link := companionsLink{Title: "link-" + e.stamp, GroupID: &group.ID, SortOrder: 1}
if err := e.db.Create(&link).Error; err != nil {
t.Fatal(err)
}
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/companions/links/reorder?partition=%d", group.ID), nil, true)
}, into[cabana.Envelope[cabana.AdminReorderTree]](), nil},
{"POST /{vendor}/{plugin}/{controller}/reorder", 200, "cabana.Envelope-cabana_AdminReorderTree", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
tree := reorderTree(t, e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, true))
if tree.Partition == nil || tree.Partition.Value == nil || len(tree.Nodes) == 0 {
t.Fatalf("reorder tree = %+v", tree)
}
nodes := make([]map[string]any, 0, len(tree.Nodes))
for i, node := range tree.Nodes {
nodes = append(nodes, reorderNode(node.ID, nil, int64(i+1)))
}
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": *tree.Partition.Value, "nodes": nodes}, true)
}, into[cabana.Envelope[cabana.AdminReorderTree]](), nil},
{"GET /{vendor}/{plugin}/{controller}/singleton", 200, "cabana.Envelope-cabana_AdminSingletonResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
}, into[cabana.Envelope[cabana.AdminSingletonResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
}, into[cabana.Envelope[[]cabana.FilterOption]](), nil},

View File

@@ -102,6 +102,7 @@ func TestPhase10Coverage(t *testing.T) {
"POST /{vendor}/{plugin}/{controller}",
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
"POST /{vendor}/{plugin}/{controller}/bulk/{action}",
"POST /{vendor}/{plugin}/{controller}/reorder",
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
"POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}",

View File

@@ -72,9 +72,9 @@ func TestPhase10CSRF(t *testing.T) {
// delete, record action, link, unlink, file upload, file reorder, file
// caption, file remove, relation child create, update and delete, pivot
// update, child file upload, reorder, caption and remove, plugin field
// type upload
if unsafe != 32 {
t.Fatalf("walked %d state-changing routes, want 32: %v", unsafe, router.order)
// type upload, tree reorder save
if unsafe != 33 {
t.Fatalf("walked %d state-changing routes, want 33: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]

View File

@@ -2,6 +2,8 @@ package cabana
import (
"fmt"
"io/fs"
"path"
"reflect"
"sort"
"strings"
@@ -79,14 +81,32 @@ func compileRegistryWithTypes(items []controllerRef, types *fieldTypeRegistry) (
if !ok || fsys == nil || fsys.AdminFS() == nil {
return nil, fmt.Errorf("cabana: plugin %s has admin controllers but no AdminFS", item.plugin.ID())
}
list, err := compileListWithTypes(item.plugin.ID(), item.ctl, fsys.AdminFS(), types)
if err != nil {
return nil, err
// A singleton controller (pact.AdminSingleton, D-11) edits one
// record and needs no config_list.yaml.
_, singleton := item.ctl.(pact.AdminSingleton)
var (
list *ListSchema
err error
)
if !singleton || hasListConfig(item.ctl, fsys.AdminFS()) {
list, err = compileListWithTypes(item.plugin.ID(), item.ctl, fsys.AdminFS(), types)
if err != nil {
return nil, err
}
}
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, fsys.AdminFS(), types)
if err != nil {
return nil, err
}
if singleton {
if form == nil {
return nil, fmt.Errorf("cabana: admin controller %s/%s: a singleton controller (pact.AdminSingleton) needs config_form.yaml", item.plugin.ID(), id)
}
form.singleton = true
if list != nil {
list.ToolbarButtons = withoutAction(withoutAction(list.ToolbarButtons, "create"), "delete")
}
}
relations, err := compileRelationsWithTypes(item.plugin.ID(), item.ctl, fsys.AdminFS(), form, types)
if err != nil {
return nil, err
@@ -106,6 +126,7 @@ func compileRegistryWithTypes(items []controllerRef, types *fieldTypeRegistry) (
Form: form,
Relations: relations,
FieldRelations: fieldRelations,
singleton: singleton,
}
if err := BindWritableFields(compiled); err != nil {
return nil, err
@@ -122,6 +143,9 @@ func compileRegistryWithTypes(items []controllerRef, types *fieldTypeRegistry) (
if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil {
return nil, err
}
if err := compileReorder(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil {
return nil, err
}
// Two controllers of one plugin declaring the same file share an entry.
for _, file := range append(append([]*pluginAsset(nil), compiled.scripts...), compiled.styles...) {
if _, exists := assets[file.key]; !exists {
@@ -147,6 +171,14 @@ func (cc *CompiledController) operationDeclared(op string) bool {
return false
}
// hasListConfig reports whether the controller's config directory holds a
// config_list.yaml.
func hasListConfig(ctl pact.AdminController, fsys fs.FS) bool {
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
_, err := fs.Stat(fsys, path.Join(dir, "config_list.yaml"))
return err == nil
}
func hasToolbarButton(list *ListSchema, name string) bool {
for _, button := range list.ToolbarButtons {
if button == name {

525
modules/cabana/reorder.go Normal file
View File

@@ -0,0 +1,525 @@
package cabana
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"path"
"reflect"
"sort"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// msgReorderStale is the phrase key of the 409 a reorder answers when the
// records changed since the tree was loaded (backend::lang).
const msgReorderStale = "backend::lang.reorder.stale"
// maxReorderPartitions caps the partition choices a reorder page offers.
const maxReorderPartitions = 500
// reorderDocument is config_reorder.yaml (D-09): WinterCMS's
// reorder_config.yaml keys title, nameFrom and modelClass, plus the parent
// and sort columns, the depth limit and an optional partition.
type reorderDocument struct {
Title string `yaml:"title"`
NameFrom string `yaml:"nameFrom"`
ModelClass string `yaml:"modelClass"`
ParentColumn string `yaml:"parentColumn"`
SortColumn string `yaml:"sortColumn"`
MaxDepth int `yaml:"maxDepth"`
Partition *reorderPartitionY `yaml:"partition"`
}
type reorderPartitionY struct {
Column string `yaml:"column"`
Relation string `yaml:"relation"`
NameFrom string `yaml:"nameFrom"`
Label string `yaml:"label"`
}
// compiledReorder is a controller's compiled config_reorder.yaml.
type compiledReorder struct {
title string
nameFrom string
parentColumn string
sortColumn string
maxDepth int
partition *compiledPartition
}
type compiledPartition struct {
column string
relation string
label string
labelCol string
fieldRel *CompiledFieldRelation
}
// ListReorder is the list schema's `reorder` object: the controller has a
// tree reorder page (config_reorder.yaml). Partition is set when the tree is
// reordered within one value of a partition column, such as one menu.
type ListReorder struct {
Title string `json:"title"`
Partition *ListReorderPartition `json:"partition,omitempty"`
}
// ListReorderPartition names the partition column and its label.
type ListReorderPartition struct {
Column string `json:"column"`
Label string `json:"label,omitempty"`
}
// compileReorder reads the controller's optional config_reorder.yaml. Every
// unknown key, a column that is not a column of the controller's record, a
// partition relation that is not a belongsTo field relation of the form over
// the partition column, and a negative maxDepth fail boot. It also enforces
// that toolbar.buttons lists reorder only on a controller that has the file.
func compileReorder(pluginID string, cc *CompiledController, fsys fs.FS) error {
ctl := cc.Controller
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
cfgPath := path.Join(dir, "config_reorder.yaml")
listHasReorder := cc.List != nil && hasToolbarButton(cc.List, "reorder")
if _, err := fs.Stat(fsys, cfgPath); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return bootErr(pluginID, ctl.ID(), cfgPath, err)
}
if listHasReorder {
return bootErr(pluginID, ctl.ID(), "config_list.yaml", fmt.Errorf("toolbar.buttons: reorder needs config_reorder.yaml"))
}
return nil
}
raw, err := readAsset(fsys, cfgPath)
if err != nil {
return bootErr(pluginID, ctl.ID(), cfgPath, err)
}
var doc reorderDocument
if err := decodeStrict(raw, &doc); err != nil {
return bootErr(pluginID, ctl.ID(), cfgPath, err)
}
fail := func(err error) error { return bootErr(pluginID, ctl.ID(), cfgPath, err) }
if doc.ModelClass != "" && doc.ModelClass != ctl.ModelName() {
return fail(fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return fail(fmt.Errorf("a reorder page needs an AdminRecordSource"))
}
if cc.List == nil {
return fail(fmt.Errorf("a reorder page needs config_list.yaml"))
}
cols := modelColumns(src.NewRecord())
out := &compiledReorder{
title: doc.Title,
nameFrom: doc.NameFrom,
parentColumn: doc.ParentColumn,
sortColumn: doc.SortColumn,
maxDepth: doc.MaxDepth,
}
if out.parentColumn == "" {
out.parentColumn = "parent_id"
}
if out.sortColumn == "" {
out.sortColumn = "sort_order"
}
if doc.MaxDepth < 0 {
return fail(fmt.Errorf("maxDepth must be 0 (unlimited) or more"))
}
if !identifier(out.nameFrom) {
return fail(fmt.Errorf("nameFrom %q is not a column name", out.nameFrom))
}
for key, column := range map[string]string{"nameFrom": out.nameFrom, "parentColumn": out.parentColumn, "sortColumn": out.sortColumn} {
if _, ok := cols[column]; !ok || !identifier(column) {
return fail(fmt.Errorf("%s %s is not a column of the record", key, column))
}
}
if p := doc.Partition; p != nil {
if _, ok := cols[p.Column]; !ok || !identifier(p.Column) {
return fail(fmt.Errorf("partition.column %s is not a column of the record", p.Column))
}
fr, ok := cc.FieldRelations[p.Relation]
if !ok || fr.Contract.Kind != relationKindBelongsTo || fr.Contract.ForeignKey != p.Column {
return fail(fmt.Errorf("partition.relation %s must be a belongsTo relation field of the form over %s", p.Relation, p.Column))
}
labelCol := fr.LabelColumn
if p.NameFrom != "" {
if !identifier(p.NameFrom) {
return fail(fmt.Errorf("partition.nameFrom %q is not a column name", p.NameFrom))
}
if _, ok := modelColumns(fr.Contract.NewRelated())[p.NameFrom]; !ok {
return fail(fmt.Errorf("partition.nameFrom %s is not a column of the related record", p.NameFrom))
}
labelCol = p.NameFrom
}
out.partition = &compiledPartition{column: p.Column, relation: p.Relation, label: p.Label, labelCol: labelCol, fieldRel: fr}
}
cc.reorder = out
reorder := &ListReorder{Title: out.title}
if out.partition != nil {
reorder.Partition = &ListReorderPartition{Column: out.partition.column, Label: out.partition.label}
}
cc.List.Reorder = reorder
return nil
}
// AdminReorderPartitionOption is one partition a reorder page offers.
type AdminReorderPartitionOption struct {
Value uint `json:"value"`
Label string `json:"label"`
}
// AdminReorderPartition is the partition of a reorder tree: its label, the
// partitions the administrator may choose and the one shown (null when there
// is none to choose).
type AdminReorderPartition struct {
Label string `json:"label"`
Options []AdminReorderPartitionOption `json:"options"`
Value *uint `json:"value"`
}
// AdminReorderNode is one record of a reorder tree.
type AdminReorderNode struct {
ID uint `json:"id"`
ParentID *uint `json:"parent_id"`
SortOrder int64 `json:"sort_order"`
Name string `json:"name"`
}
// AdminReorderTree is the data of the reorder routes: the page title, the
// partition (null without one) and every record of the scoped set, ordered by
// the sort column and then the id.
type AdminReorderTree struct {
Title string `json:"title"`
Partition *AdminReorderPartition `json:"partition"`
Nodes []AdminReorderNode `json:"nodes"`
}
// AdminReorderNodeInput is one node of a reorder save: the record id, its new
// parent (null at the top) and its new sort value.
type AdminReorderNodeInput struct {
ID uint64 `json:"id"`
ParentID *uint64 `json:"parent_id"`
SortOrder int64 `json:"sort_order"`
}
// AdminReorderRequest is the body of POST .../reorder: the partition the tree
// belongs to (required when the page has one) and every node of the scoped
// set, exactly once.
type AdminReorderRequest struct {
Partition *uint64 `json:"partition"`
Nodes []AdminReorderNodeInput `json:"nodes"`
}
// reorderStale is the 409 of a reorder save whose node set no longer equals
// the locked scoped set.
type reorderStale struct{}
func (reorderStale) Error() string { return "cabana: reorder set changed" }
// reorderGet serves GET {vendor}/{plugin}/{controller}/reorder?partition=<id>.
func (s *service) reorderGet(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.reorder == nil || cc.singleton {
writeNotFound(w, r)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var partition *uint64
if raw := strings.TrimSpace(r.URL.Query().Get("partition")); raw != "" {
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil || n == 0 {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", map[string]any{"partition": []string{"The partition field must be an id."}})
return
}
partition = &n
}
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator()))
var tree AdminReorderTree
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
tree, err = s.reorderTree(withTx(ctx, tx), tx, cc, partition, false)
return err
})
if err != nil {
s.writeReorderError(w, r, cc, err)
return
}
WriteData(w, http.StatusOK, tree, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
})
}
// reorderPost serves POST {vendor}/{plugin}/{controller}/reorder: one
// transaction locks the scoped set (ListExtendQuery plus the partition),
// requires the submitted ids to equal it exactly (409 reorder_stale
// otherwise), refuses a duplicate id, a parent outside the set, a cycle and a
// depth above maxDepth (422), then writes the parent and sort columns of
// every node and answers the new tree.
func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.reorder == nil || cc.singleton {
writeNotFound(w, r)
return
}
var in AdminReorderRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
writeCRUDError(w, err)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator()))
var tree AdminReorderTree
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := withTx(ctx, tx)
if cc.reorder.partition != nil && in.Partition == nil {
return &ValidationError{Details: map[string]any{"partition": []string{"The partition field is required."}}}
}
locked, err := s.reorderTree(txCtx, tx, cc, in.Partition, true)
if err != nil {
return err
}
if err := checkReorderNodes(cc.reorder, locked.Nodes, in.Nodes); err != nil {
return err
}
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
pk := primaryColumn(proto)
for _, node := range in.Nodes {
var parent any
if node.ParentID != nil {
parent = castPK(proto, uint(*node.ParentID))
}
// UpdateColumns writes exactly the two columns and runs no
// model hooks, so a save hook that renumbers sort values
// cannot undo the new order.
err := tx.WithContext(txCtx).Model(cc.Controller.(pact.AdminRecordSource).NewRecord()).
Where(clause.Eq{Column: clause.Column{Name: pk}, Value: castPK(proto, uint(node.ID))}).
UpdateColumns(map[string]any{cc.reorder.parentColumn: parent, cc.reorder.sortColumn: node.SortOrder}).Error
if err != nil {
return lifecycleFailure(cc, err)
}
}
tree, err = s.reorderTree(txCtx, tx, cc, in.Partition, false)
return err
})
if err != nil {
s.writeReorderError(w, r, cc, err)
return
}
WriteData(w, http.StatusOK, tree, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
})
}
func (s *service) writeReorderError(w http.ResponseWriter, r *http.Request, cc *CompiledController, err error) {
var stale reorderStale
if errors.As(err, &stale) {
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator()))
WriteError(w, http.StatusConflict, "reorder_stale", translateKey(ctx, s.translator(), msgReorderStale))
return
}
var ve *ValidationError
var missing recordNotFound
if !errors.As(err, &ve) && !errors.As(err, &missing) {
slog.Error("cabana: reorder failed", "controller", controllerID(cc), "error", err)
}
writeCRUDError(w, err)
}
// reorderTree loads the scoped set of a reorder page: the records the list
// scope (ListExtendQuery) returns within the partition, ordered by the sort
// column and then the primary key; lock adds FOR UPDATE. A partition that is
// not among the offered ones is not found; without one requested, the first
// offered partition is shown.
func (s *service) reorderTree(ctx context.Context, tx *gorm.DB, cc *CompiledController, requested *uint64, lock bool) (AdminReorderTree, error) {
ro := cc.reorder
tr := s.translator()
tree := AdminReorderTree{Title: translateKey(ctx, tr, ro.title), Nodes: []AdminReorderNode{}}
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
q := tx.WithContext(ctx).Model(proto)
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
if next := ext.ListExtendQuery(ctx, q); next != nil {
q = next
}
}
table := tableName(proto)
if p := ro.partition; p != nil {
options, err := reorderPartitions(ctx, tx, cc, p)
if err != nil {
return AdminReorderTree{}, err
}
part := &AdminReorderPartition{Label: translateKey(ctx, tr, p.label), Options: options}
tree.Partition = part
switch {
case requested != nil:
found := false
for _, opt := range options {
found = found || uint64(opt.Value) == *requested
}
if !found {
return AdminReorderTree{}, recordNotFound{}
}
v := uint(*requested)
part.Value = &v
case len(options) > 0:
v := options[0].Value
part.Value = &v
default:
return tree, nil
}
q = q.Where(clause.Eq{Column: clause.Column{Table: table, Name: p.column}, Value: *part.Value})
}
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
pk := primaryColumn(proto)
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(proto).Elem()))
err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: ro.sortColumn}}).
Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}).
Find(holder.Interface()).Error
if err != nil {
return AdminReorderTree{}, lifecycleFailure(cc, err)
}
rows := holder.Elem()
for i := 0; i < rows.Len(); i++ {
row := rows.Index(i)
node := AdminReorderNode{ID: uintOf(fieldByColumn(row, pk))}
if parent := uintOf(fieldByColumn(row, ro.parentColumn)); parent != 0 {
node.ParentID = &parent
}
node.SortOrder = intOf(fieldByColumn(row, ro.sortColumn))
if f := fieldByColumn(row, ro.nameFrom); f.IsValid() && f.CanInterface() {
if v := derefValue(f.Interface()); v != nil {
node.Name = fmt.Sprint(v)
}
}
tree.Nodes = append(tree.Nodes, node)
}
return tree, nil
}
// reorderPartitions lists the partitions the administrator may choose: the
// related records the partition relation field offers (its
// RelationExtendOptionsQuery scope), ordered by label then id.
func reorderPartitions(ctx context.Context, tx *gorm.DB, cc *CompiledController, p *compiledPartition) ([]AdminReorderPartitionOption, error) {
related := p.fieldRel.Contract.NewRelated()
table := tableName(related)
pk := primaryColumn(related)
q := scopedRelationQuery(ctx, tx, cc, p.relation, p.fieldRel)
label := labelExpression(q, table, p.labelCol)
rows := []AdminReorderPartitionOption{}
err := q.Select(quotedIdent(q, table) + "." + quotedIdent(q, pk) + " AS value, " + label + " AS label").
Order(label).
Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}).
Limit(maxReorderPartitions).
Scan(&rows).Error
if err != nil {
return nil, lifecycleFailure(cc, err)
}
return rows, nil
}
// checkReorderNodes compares a submitted tree with the locked set: a
// duplicate id is a 422, any difference between the two id sets a 409, and a
// parent outside the set, a cycle or a depth above maxDepth a 422.
func checkReorderNodes(ro *compiledReorder, locked []AdminReorderNode, nodes []AdminReorderNodeInput) error {
invalid := func(msg string) error {
return &ValidationError{Details: map[string]any{"nodes": []string{msg}}}
}
parents := make(map[uint64]uint64, len(nodes))
for _, node := range nodes {
if node.ID == 0 {
return invalid("The nodes field must list record ids.")
}
if _, dup := parents[node.ID]; dup {
return invalid("The nodes field lists a record twice.")
}
parent := uint64(0)
if node.ParentID != nil {
parent = *node.ParentID
}
parents[node.ID] = parent
}
if len(parents) != len(locked) {
return reorderStale{}
}
for _, node := range locked {
if _, ok := parents[uint64(node.ID)]; !ok {
return reorderStale{}
}
}
ids := make([]uint64, 0, len(parents))
for id, parent := range parents {
ids = append(ids, id)
if parent == 0 {
continue
}
if parent == id {
return invalid("A record cannot be its own parent.")
}
if _, ok := parents[parent]; !ok {
return invalid("A parent is not one of the reordered records.")
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
depth := 1
for cur := parents[id]; cur != 0; cur = parents[cur] {
depth++
if depth > len(parents) {
return invalid("The tree has a cycle.")
}
}
if ro.maxDepth > 0 && depth > ro.maxDepth {
return invalid(fmt.Sprintf("The tree may be at most %d levels deep.", ro.maxDepth))
}
}
return nil
}
// uintOf reads an unsigned id from a struct field (a pointer is
// dereferenced; nil and non-numbers are 0).
func uintOf(f reflect.Value) uint {
if !f.IsValid() || !f.CanInterface() {
return 0
}
v := reflect.ValueOf(derefValue(f.Interface()))
switch v.Kind() {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return uint(v.Uint())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if v.Int() > 0 {
return uint(v.Int())
}
}
return 0
}
// intOf reads an integer from a struct field (a pointer is dereferenced; nil
// and non-numbers are 0).
func intOf(f reflect.Value) int64 {
if !f.IsValid() || !f.CanInterface() {
return 0
}
v := reflect.ValueOf(derefValue(f.Interface()))
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return int64(v.Uint())
}
return 0
}

View File

@@ -88,11 +88,14 @@ type ListSchema struct {
Assets ControllerAssets `json:"assets"`
// HeaderPartial names the controller partial rendered above the list
// (config_list.yaml headerPartial, D-11).
HeaderPartial string `json:"headerPartial,omitempty"`
Columns []ListColumn `json:"columns"`
Filters []ListFilter `json:"filters"`
RowActions []RowAction `json:"rowActions"`
BulkActions []BulkAction `json:"bulkActions"`
HeaderPartial string `json:"headerPartial,omitempty"`
// Reorder is set when the controller has a tree reorder page
// (config_reorder.yaml, D-09).
Reorder *ListReorder `json:"reorder,omitempty"`
Columns []ListColumn `json:"columns"`
Filters []ListFilter `json:"filters"`
RowActions []RowAction `json:"rowActions"`
BulkActions []BulkAction `json:"bulkActions"`
// Messages is the list's copy (D-13). The cached schema carries each
// phrase key as its own form; a response resolves them in its locale.
Messages *ListMessages `json:"messages"`
@@ -151,6 +154,8 @@ type FormSchema struct {
// preview is the config_form.yaml preview block; nil when the form has
// no preview screen.
preview *FormPreview
// singleton is true for a pact.AdminSingleton controller's form.
singleton bool
}
// FormPreview is a form's preview screen (config_form.yaml `preview:`, D-11):
@@ -167,6 +172,10 @@ type FormView struct {
Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"`
// Singleton is true when the controller edits exactly one record
// (pact.AdminSingleton, D-11): the admin opens it through the singleton
// route instead of a list.
Singleton bool `json:"singleton,omitempty"`
// Preview is set when the form has a preview screen (D-11); a form
// without one omits the key.
Preview *FormPreview `json:"preview,omitempty"`

View File

@@ -63,6 +63,9 @@ var phase09Routes = []adminRoute{
{key: "GET /{vendor}/{plugin}/{controller}"},
{key: "POST /{vendor}/{plugin}/{controller}"},
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
{key: "GET /{vendor}/{plugin}/{controller}/reorder"},
{key: "POST /{vendor}/{plugin}/{controller}/reorder"},
{key: "GET /{vendor}/{plugin}/{controller}/singleton"},
{key: "POST /{vendor}/{plugin}/{controller}/bulk/{action}"},
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
{key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"},
@@ -318,6 +321,9 @@ func phase09ProtectedCalls() []phase09Call {
{"relation-linked", (*service).relationLinked},
{"field-options", (*service).fieldOptions},
{"dependent-options", (*service).dependentOptionsRoute},
{"reorder-get", (*service).reorderGet},
{"reorder-post", (*service).reorderPost},
{"singleton", (*service).singletonRoute},
{"filter-options", (*service).filterOptions},
{"nested-get", (*service).nestedGet},
{"relation-candidates", (*service).relationCandidates},

View File

@@ -0,0 +1,84 @@
package cabana
import (
"errors"
"log/slog"
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// AdminSingletonResult is the data of the singleton route: the id of the one
// record a pact.AdminSingleton controller edits.
type AdminSingletonResult struct {
ID uint `json:"id"`
}
// singletonRefuses answers 404 for a list, create, bulk or delete request on
// a singleton controller (D-11) and reports whether it did.
func singletonRefuses(w http.ResponseWriter, r *http.Request, cc *CompiledController) bool {
if cc == nil || !cc.singleton {
return false
}
writeNotFound(w, r)
return true
}
// singletonRoute serves GET {vendor}/{plugin}/{controller}/singleton: in one
// transaction it asks the controller's SingletonRecord for the record
// (created when absent) and answers its id, after checking that the form
// scope (FormExtendQuery) can load it. It is guarded like the form schema; a
// controller that is not a singleton answers 404.
func (s *service) singletonRoute(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
provider, ok := cc.Controller.(pact.AdminSingleton)
if !cc.singleton || !ok || provider == nil {
writeNotFound(w, r)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
ctx := r.Context()
var result AdminSingletonResult
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := withTx(ctx, tx)
record, err := provider.SingletonRecord(txCtx, tx)
if err != nil {
return lifecycleFailure(cc, err)
}
var id uint
if record != nil {
id = pkUint(record)
}
if id == 0 {
return lifecycleFailure(cc, errors.New("cabana: SingletonRecord returned no record with an id"))
}
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(txCtx, tx, cc, target, pk); err != nil {
return err
}
result.ID = id
return nil
})
if err != nil {
var missing recordNotFound
if !errors.As(err, &missing) {
slog.Error("cabana: singleton record failed", "controller", controllerID(cc), "error", err)
}
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}

View File

@@ -4,4 +4,4 @@ title: Links
recordUrl: acme/companions/links/update/:id
recordsPerPage: 20
toolbar:
buttons: [create]
buttons: [create, reorder]

View File

@@ -0,0 +1,10 @@
title: Reorder links
nameFrom: title
modelClass: Link
parentColumn: parent_id
sortColumn: sort_order
maxDepth: 3
partition:
column: group_id
relation: group
label: Group

View File

@@ -0,0 +1,4 @@
name: Site profile
form: ~/plugins/acme/companions/models/profile/fields.yaml
modelClass: Profile
defaultRedirect: acme/companions/profiles

View File

@@ -3,6 +3,11 @@ fields:
label: Title
type: text
required: true
group:
label: Group
type: relation
nameFrom: title
emptyOption: backend::lang.form.none
type:
label: Type
type: dropdown

View File

@@ -0,0 +1,4 @@
fields:
title:
label: Title
type: text

View File

@@ -60,6 +60,9 @@ var tokenRouteClasses = map[string]tokenRouteClass{
"GET /{vendor}/{plugin}/{controller}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/bulk-delete": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/reorder": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/reorder": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/singleton": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/bulk/{action}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/widgets/{field}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}": classAllowsProtect,

View File

@@ -32,6 +32,7 @@ Use cases:
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
- Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree.
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot.
- Singleton admin controllers: `pact.AdminSingleton` (`SingletonRecord`, first or create) makes a controller edit exactly one record, opened directly instead of from a list.
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
- `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package.
@@ -159,6 +160,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.RelationBeforeDelete` | Optional controller hook run in the write transaction before a relation manager deletes a related record. |
| `pact.RelationAfterDelete` | Optional controller hook run after a relation manager deletes a related record, before the commit. |
| `pact.Option` | One dropdown choice (value and label), with `Depth`, the nesting level of a choice taken from a tree (omitted from JSON when 0). |
| `pact.AdminSingleton` | Optional controller capability: `SingletonRecord` returns the one record the controller edits, creating it when absent; the admin opens it directly and the list, create, bulk and delete routes answer 404. |
| `pact.DependentOptionsProvider` | Optional controller hook serving the choices of a dropdown with `dependsOn` from the submitted values of its siblings; asked by the options route and on every save. |
| `pact.DependentOptionSet` | `DependentOptions`' answer: `Applies` (false when the sibling's value takes no choice; the field then stores null) and `Options`. |
| `pact.HasMenuItemTypes` | Implemented by plugins that register menu item types; collected once at boot by signpost. |

View File

@@ -678,6 +678,17 @@ type HasMenuItemTypes interface {
MenuItemTypes() []MenuItemType
}
// AdminSingleton is implemented by an admin controller that edits exactly one
// record, such as a site-wide definition opened from the settings page.
// SingletonRecord returns that record, creating it first when it does not
// exist yet (first or create), using db, the request's transaction. The admin
// framework then opens the record's form directly; the controller's list,
// create, bulk and delete routes answer 404, and it needs no
// config_list.yaml.
type AdminSingleton interface {
SingletonRecord(ctx context.Context, db *gorm.DB) (any, error)
}
// ListExtendQuery optionally narrows the admin list query.
type ListExtendQuery interface {
ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB

View File

@@ -338,3 +338,6 @@ richeditor:
repeater:
max_items: Remove items until there are at most :max.
min_items: Add items until there are at least :min.
reorder:
stale: The records changed in the meantime. The list has been reloaded.
load_failed: The records could not be loaded. Refresh the page to try again.

View File

@@ -366,3 +366,6 @@ richeditor:
repeater:
max_items: Usuń pozycje, aby zostało ich najwyżej :max.
min_items: Dodaj pozycje, aby było ich co najmniej :min.
reorder:
stale: Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie.
load_failed: Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie.