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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user