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

@@ -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;