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