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

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