feat(12.1-01): list row states from one controller call per page

- pact.ListRowStates with the fixed RowState set deleted, negative, disabled
- list response meta.row_states keyed by row id; unknown values dropped
- list messages rowStateDeleted, rowStateNegative, rowStateDisabled
- update writes through the scope the load used, so a soft-deleted record
  a controller includes stays soft-deleted
- DataTable row state badges and text styles
- roster fixture, smoke tests, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:45:44 +02:00
parent e0ccced76a
commit 61d5fc72ad
35 changed files with 749 additions and 44 deletions

View File

@@ -377,6 +377,7 @@ toolbar:
bulkActions: [activate, archive]
messages:
create: acme.roster::lang.people.create
rowStateDisabled: acme.roster::lang.people.state_inactive
```
The admin SPA shows the declared actions in a "Bulk actions" menu next to the selection, asks for confirmation (the action's `Confirm` text, or a default one), and posts the selected ids to `POST /{controller}/bulk/{action}`. cabana owns that route:

View File

@@ -29,6 +29,8 @@ Each record form makes a session key when it opens: 32 random bytes, base64url e
A list whose schema carries declared bulk actions shows a "Bulk actions" menu after the selection count. The menu lists only the actions the server offered to this administrator, and its button stays disabled until a row is selected. Choosing an action always asks for confirmation; the dialog stays open while the request runs, and the list reloads afterwards. See [Bulk actions](admin-controllers.md#bulk-actions).
A list row that carries a state (deleted, negative or disabled) shows a text badge for each state after its first cell, together with a text style; the row background is never changed. See [Row state](lists-and-filters.md#row-state).
## Types from OpenAPI
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date:

View File

@@ -114,6 +114,55 @@ scopes:
WinterCMS `conditions` SQL fragments are not supported; use a `column` or a scope the model implements. A scope filter whose name the model does not list in `FilterScopes` stops the start-up, so request text can never select another method.
## Row state
A list can mark rows with a state, as a WinterCMS list does with `listInjectRowClass`: a deleted record, a blocked account, an inactive one. The controller implements `pact.ListRowStates`:
```go src=modules/cabana/example_rowstate_test.go
package cabana_test
import (
"context"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
var _ pact.ListRowStates = PeopleController{}
// ListRowStates marks the rows of one list page. The framework calls it once
// per page with the page's records, in page order; the result is aligned
// with records, and a nil entry means the row has no state. db is the list's
// handle, for a hook that needs one query for the whole page.
func (PeopleController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
states := make([][]pact.RowState, len(records))
for i, record := range records {
person := record.(*Person)
if person.Banned {
states[i] = append(states[i], pact.RowStateNegative)
}
if !person.Active {
states[i] = append(states[i], pact.RowStateDisabled)
}
}
return states, nil
}
```
cabana calls the hook once per list page, with that page's records and the list's database handle, never once per row. A list does not run in a transaction, so `cabana.TxFromContext` reports none in this hook; use the handle it is given.
The set of states is fixed: `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`. A row may carry several. The list response sends them in `meta.row_states`, keyed by row id, always in that order and each at most once; a value outside the set is dropped and logged, and a controller without the hook sends no `row_states` key. The admin shows each state as a text badge after the row's first cell and with a text style (a deleted row is struck through and muted, a negative one is red, a disabled one is muted), so the state is never carried by colour alone.
The badge texts are list messages. Override them per list in the `messages` block of `config_list.yaml`:
| Key | Default text |
|-----|--------------|
| `rowStateDeleted` | Deleted |
| `rowStateNegative` | Blocked |
| `rowStateDisabled` | Inactive |
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`.
## Scoping every list
To restrict which records an administrator sees at all, implement `pact.ListExtendQuery` on the controller. It receives the list query before search, filters and pagination are applied, so the restriction holds for every request. See [Admin controllers](admin-controllers.md) for the other hooks.