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;