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

@@ -18,6 +18,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count.
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.

View File

@@ -335,6 +335,7 @@ func AdminFilterOptions() {}
// AdminList documents the record list route.
//
// @Summary List admin records
// @Description meta.row_states carries the states of the page's rows keyed by row id, each a subset of deleted, negative and disabled; it is absent when the controller reports no state.
// @Tags admin
// @Produce json
// @Security BackendBearer

View File

@@ -564,7 +564,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return err
}
if update {
err = tx.Save(target).Error
// The row write uses the scope the load used: a soft-deleted
// record the controller's FormExtendQuery includes (Winter's
// withTrashed) is updated in place and stays soft-deleted.
err = tx.Unscoped().Save(target).Error
} else {
err = tx.Create(target).Error
}

View File

@@ -0,0 +1,28 @@
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
}

View File

@@ -942,12 +942,17 @@ func (s *service) list(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, result.Data, map[string]any{
meta := map[string]any{
"page": result.Meta.Page,
"per_page": result.Meta.PerPage,
"total": result.Meta.Total,
"last_page": result.Meta.LastPage,
})
}
// Row states (D-12) are sent only when a row of the page has one.
if len(result.Meta.RowStates) > 0 {
meta["row_states"] = result.Meta.RowStates
}
WriteData(w, http.StatusOK, result.Data, meta)
})
}

View File

@@ -42,7 +42,7 @@ const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","reco
// defaultListMessagesJSON is a compiled list's messages block when the YAML
// declares none: every key is a framework default phrase key (D-13).
const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"}}`
const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"},"rowStateDeleted":{"other":"backend::lang.messages.list.row_state_deleted"},"rowStateNegative":{"other":"backend::lang.messages.list.row_state_negative"},"rowStateDisabled":{"other":"backend::lang.messages.list.row_state_disabled"}}`
func TestListSchemaCompile(t *testing.T) {
columns := readListFixture(t, "testdata/list/all_columns.yaml")

View File

@@ -36,6 +36,10 @@ type listMessageKeys struct {
DeleteSelected string `yaml:"deleteSelected"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
// The row-state badge labels (D-12).
RowStateDeleted string `yaml:"rowStateDeleted"`
RowStateNegative string `yaml:"rowStateNegative"`
RowStateDisabled string `yaml:"rowStateDisabled"`
}
// ListMessages is a list schema's copy, every key resolved (same field order
@@ -51,6 +55,10 @@ type ListMessages struct {
DeleteSelected MessageForms `json:"deleteSelected"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
// The row-state badge labels (D-12).
RowStateDeleted MessageForms `json:"rowStateDeleted"`
RowStateNegative MessageForms `json:"rowStateNegative"`
RowStateDisabled MessageForms `json:"rowStateDisabled"`
}
// formMessageKeys is the config_form.yaml messages block: phrase keys.
@@ -143,6 +151,10 @@ var (
DeleteSelected: "backend::lang.list.delete_selected",
DeleteConfirm: "backend::lang.messages.list.delete_confirm",
Deleted: "backend::lang.messages.list.deleted",
RowStateDeleted: "backend::lang.messages.list.row_state_deleted",
RowStateNegative: "backend::lang.messages.list.row_state_negative",
RowStateDisabled: "backend::lang.messages.list.row_state_disabled",
}
formMessageDefaults = formMessageKeys{
Create: "backend::lang.messages.form.create",

View File

@@ -11,6 +11,9 @@ import (
"strings"
"testing"
"testing/fstest"
"time"
"gorm.io/gorm"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
@@ -454,3 +457,199 @@ func TestFormSchemaRecordActionsBoot(t *testing.T) {
}
})
}
// rosterList fetches the people list and returns its rows and row states.
func rosterList(t *testing.T, env *rosterEnv, query string) (cabana.ListEnvelope[[]cabana.AdminRecord], string) {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+query, "", "bearer")
var body cabana.ListEnvelope[[]cabana.AdminRecord]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("list body %s: %v", rec.Body.String(), err)
}
return body, rec.Body.String()
}
func rosterDeleted() gorm.DeletedAt {
return gorm.DeletedAt{Time: time.Now().UTC(), Valid: true}
}
// TestRowStateSmoke checks the row-state batch hook through the assembled
// router on PostgreSQL (D-12; T-12.1-07): one hook call per page, the fixed
// set in the fixed order, unknown values dropped, and no key without states.
func TestRowStateSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true})
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
gone := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone", Active: true, Banned: true, DeletedAt: rosterDeleted()})
all := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "All", Banned: true, DeletedAt: rosterDeleted()})
odd := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Active: true})
rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed"})
key := func(id uint) string { return fmt.Sprint(id) }
t.Run("one call per page, fixed set and order", func(t *testing.T) {
env.spy.takeStates()
body, raw := rosterList(t, env, "")
if len(body.Data) != 5 {
t.Fatalf("rows = %d, want the five acme people including the soft-deleted: %s", len(body.Data), raw)
}
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{5}) {
t.Fatalf("ListRowStates calls = %v, want one call with the page's five records", calls)
}
want := map[string][]string{
key(idle): {"disabled"},
key(gone): {"deleted", "negative"},
key(all): {"deleted", "negative", "disabled"},
}
if !reflect.DeepEqual(body.Meta.RowStates, want) {
t.Fatalf("row_states = %v, want %v", body.Meta.RowStates, want)
}
// A row without a state, and one whose only value is unknown, are
// left out; the unknown value is never sent.
if _, ok := body.Meta.RowStates[key(plain)]; ok {
t.Fatalf("a row without states is listed: %v", body.Meta.RowStates)
}
if _, ok := body.Meta.RowStates[key(odd)]; ok || strings.Contains(raw, "starred") {
t.Fatalf("an unknown state was sent: %s", raw)
}
// States are not row data: a column key can never collide with them.
for _, row := range body.Data {
if _, ok := row["row_states"]; ok {
t.Fatalf("row carries row_states: %v", row)
}
}
})
t.Run("each page gets its own call", func(t *testing.T) {
env.spy.takeStates()
body, _ := rosterList(t, env, "?search=Idle")
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{1}) {
t.Fatalf("ListRowStates calls = %v", calls)
}
if !reflect.DeepEqual(body.Meta.RowStates, map[string][]string{key(idle): {"disabled"}}) {
t.Fatalf("row_states = %v", body.Meta.RowStates)
}
})
t.Run("no key when no row has a state", func(t *testing.T) {
_, raw := rosterList(t, env, "?search=Plain")
if strings.Contains(raw, "row_states") {
t.Fatalf("row_states sent for a page without states: %s", raw)
}
env.spy.takeStates()
// An empty page does not call the hook.
_, raw = rosterList(t, env, "?search=nobody-matches-this")
if calls := env.spy.takeStates(); len(calls) != 0 || strings.Contains(raw, "row_states") {
t.Fatalf("empty page: calls=%v body=%s", calls, raw)
}
})
t.Run("a controller without the hook sends no row_states", func(t *testing.T) {
demo, demoDB := newActEnv(t)
actInsert(t, demoDB, "plain", "acme")
rec := demo.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets", "", "bearer")
if strings.Contains(rec.Body.String(), "row_states") || !strings.Contains(rec.Body.String(), `"total":1`) {
t.Fatalf("list = %s", rec.Body.String())
}
})
t.Run("the list schema carries the badge labels", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
for _, want := range []string{`"rowStateDeleted":{"other":"Deleted"}`, `"rowStateNegative":{"other":"Blocked"}`, `"rowStateDisabled":{"other":"Not active"}`} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("list schema lacks %s: %s", want, rec.Body.String())
}
}
})
}
// TestSoftDeletedRecordSmoke checks D-13's framework side: a soft-deleted
// record that the controller's list and form scopes include can be shown,
// updated, targeted by bulk and record actions and permanently deleted
// through the admin API.
func TestSoftDeletedRecordSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
trashed := func(name string) uint {
return rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name, DeletedAt: rosterDeleted()})
}
exists := func(id uint) bool {
var n int64
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("id = ?", id).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n == 1
}
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
t.Run("show and update keep it soft-deleted", func(t *testing.T) {
id := trashed("Trashed")
rec := env.expect(t, http.StatusOK, http.MethodGet, path(id), "", "bearer")
if !strings.Contains(rec.Body.String(), `"name":"Trashed"`) {
t.Fatalf("show = %s", rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodPut, path(id), `{"name":"Renamed","email":"renamed@example.test"}`, "bearer")
if !strings.Contains(rec.Body.String(), `"name":"Renamed"`) {
t.Fatalf("update = %s", rec.Body.String())
}
person := rosterLoad(t, gdb, id)
if person.Name != "Renamed" || person.Email != "renamed@example.test" || !person.DeletedAt.Valid {
t.Fatalf("stored person = %+v", person)
}
var rows int64
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("name = ?", "Renamed").Count(&rows).Error; err != nil || rows != 1 {
t.Fatalf("rows named Renamed = %d err=%v, want the one updated row", rows, err)
}
})
t.Run("bulk and record actions reach it", func(t *testing.T) {
id := trashed("Dormant")
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/activate", fmt.Sprintf(`{"ids":[%d]}`, id), "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 1 {
t.Fatalf("bulk result = %+v", result)
}
if person := rosterLoad(t, gdb, id); !person.Active || !person.DeletedAt.Valid {
t.Fatalf("after the bulk action: %+v", person)
}
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Barred", Active: true, Banned: true, DeletedAt: rosterDeleted()})
if got := rosterOffered(t, env, banned, "bearer"); !reflect.DeepEqual(got, []string{"reinstate"}) {
t.Fatalf("offered = %v", got)
}
env.expect(t, http.StatusOK, http.MethodPost, path(banned)+"/actions/reinstate", `{}`, "bearer")
if person := rosterLoad(t, gdb, banned); person.Banned || !person.DeletedAt.Valid {
t.Fatalf("after the record action: %+v", person)
}
})
t.Run("the form delete removes it for good", func(t *testing.T) {
id := trashed("Purged")
rec := env.expect(t, http.StatusOK, http.MethodDelete, path(id), "", "bearer")
if !strings.Contains(rec.Body.String(), `"deleted":1`) || exists(id) {
t.Fatalf("delete = %s, row still exists: %v", rec.Body.String(), exists(id))
}
// A live person is removed for good as well: the hook is the
// controller's rule.
live := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Live"})
env.expect(t, http.StatusOK, http.MethodDelete, path(live), "", "bearer")
if exists(live) {
t.Fatal("the live person still exists")
}
})
t.Run("bulk delete removes it for good", func(t *testing.T) {
first, second := trashed("First"), rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"})
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", fmt.Sprintf(`{"ids":[%d,%d]}`, first, second), "bearer")
if !strings.Contains(rec.Body.String(), `"deleted":2`) || exists(first) || exists(second) {
t.Fatalf("bulk delete = %s", rec.Body.String())
}
})
t.Run("a controller that hides soft-deleted rows behaves as before", func(t *testing.T) {
// Outside the acme scope nothing changes either: a soft-deleted
// person of another tenant stays invisible.
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", DeletedAt: rosterDeleted()})
env.expect(t, http.StatusNotFound, http.MethodGet, path(foreign), "", "bearer")
env.expect(t, http.StatusNotFound, http.MethodPut, path(foreign), `{"name":"x"}`, "bearer")
if !exists(foreign) || rosterLoad(t, gdb, foreign).Name != "Zed" {
t.Fatal("an out-of-scope soft-deleted person was changed")
}
})
}

View File

@@ -49,6 +49,22 @@ type rosterSpy struct {
mu sync.Mutex
bulk []pact.AdminBulkActionInput
record []pact.AdminRecordActionInput
// states counts ListRowStates calls and keeps the size of each page.
states []int
}
func (s *rosterSpy) recordStates(n int) {
s.mu.Lock()
defer s.mu.Unlock()
s.states = append(s.states, n)
}
func (s *rosterSpy) takeStates() []int {
s.mu.Lock()
defer s.mu.Unlock()
out := s.states
s.states = nil
return out
}
func (s *rosterSpy) recordOne(in pact.AdminRecordActionInput) {
@@ -125,12 +141,52 @@ func (rosterController) RequiredPermissions() []string { return []string{"acme.r
func (rosterController) NewRecord() any { return &rosterPerson{} }
// ListExtendQuery and FormExtendQuery scope every lookup to the acme tenant,
// so a person of another tenant is out of scope.
// so a person of another tenant is out of scope. Both include soft-deleted
// people, as a WinterCMS controller with withTrashed does.
func (rosterController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("tenant = ?", "acme")
return db.Unscoped().Where("tenant = ?", "acme")
}
func (rosterController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("tenant = ?", "acme")
return db.Unscoped().Where("tenant = ?", "acme")
}
// ListRowStates marks a page of people: deleted when soft-deleted, negative
// when banned, disabled when not active. It answers out of order and with a
// duplicate and, for a person named Odd, a value outside the fixed set, so
// the framework's reduction is visible.
func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
c.spy.recordStates(len(records))
if _, inTx := cabana.TxFromContext(ctx); inTx || db == nil {
return nil, fmt.Errorf("a list hook gets the list handle, not a transaction")
}
out := make([][]pact.RowState, len(records))
for i, record := range records {
person := record.(*rosterPerson)
if !person.Active {
out[i] = append(out[i], pact.RowStateDisabled, pact.RowStateDisabled)
}
if person.Banned {
out[i] = append(out[i], pact.RowStateNegative)
}
if person.DeletedAt.Valid {
out[i] = append(out[i], pact.RowStateDeleted)
}
if person.Name == "Odd" {
out[i] = append(out[i], pact.RowState("starred"))
}
}
return out, nil
}
// FormAfterDelete removes the person for good inside the delete's
// transaction: the list keeps soft-deleted people, so deleting one there is
// permanent.
func (rosterController) FormAfterDelete(ctx context.Context, model any) error {
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return fmt.Errorf("no transaction on the context")
}
return tx.Unscoped().Delete(model).Error
}
// AdminBulkActions: activate needs acme.roster.manage and sets active on the
@@ -152,7 +208,8 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
if person.Active {
continue
}
if err := tx.Model(person).Update("active", true).Error; err != nil {
// Unscoped: the list scope includes soft-deleted people.
if err := tx.Unscoped().Model(person).Update("active", true).Error; err != nil {
return pact.AdminBulkActionResult{}, err
}
changed++
@@ -194,7 +251,7 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
if !ok {
return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context")
}
if err := tx.Model(in.Record).Update("active", true).Error; err != nil {
if err := tx.Unscoped().Model(in.Record).Update("active", true).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil
@@ -210,7 +267,7 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
if !ok {
return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context")
}
if err := tx.Model(in.Record).Update("banned", false).Error; err != nil {
if err := tx.Unscoped().Model(in.Record).Update("banned", false).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{}, nil

View File

@@ -5,8 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -28,12 +30,16 @@ type ListInput struct {
Filters map[string]string
}
// ListMeta is the D-11 pagination block.
// ListMeta is the D-11 pagination block. RowStates are the states of the
// page's rows keyed by row id (a decimal string), each a subset of deleted,
// negative and disabled in that order; rows without a state are left out, and
// the key is absent when the controller reports no state at all.
type ListMeta struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
LastPage int `json:"last_page"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
LastPage int `json:"last_page"`
RowStates map[string][]string `json:"row_states,omitempty"`
}
// ListResult is one page of projected rows.
@@ -139,21 +145,82 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li
}
values := slice.Elem()
data := make([]map[string]any, 0, values.Len())
records := make([]any, 0, values.Len())
for i := 0; i < values.Len(); i++ {
data = append(data, projectRow(values.Index(i).Addr().Interface(), cc.Controller, cc.List.Columns))
record := values.Index(i).Addr().Interface()
records = append(records, record)
data = append(data, projectRow(record, cc.Controller, cc.List.Columns))
}
states, err := listRowStates(ctx, db, cc, records)
if err != nil {
return nil, err
}
paged := lagoon.Paginate(data, page, per, total)
return &ListResult{
Data: paged.Data,
Meta: ListMeta{
Page: paged.Meta.CurrentPage,
PerPage: paged.Meta.PerPage,
Total: paged.Meta.Total,
LastPage: paged.Meta.LastPage,
Page: paged.Meta.CurrentPage,
PerPage: paged.Meta.PerPage,
Total: paged.Meta.Total,
LastPage: paged.Meta.LastPage,
RowStates: states,
},
}, nil
}
// rowStateOrder is the fixed set of row states and the order they are sent in.
var rowStateOrder = []pact.RowState{pact.RowStateDeleted, pact.RowStateNegative, pact.RowStateDisabled}
// listRowStates asks a controller implementing pact.ListRowStates for the
// states of one page of records, in one call (D-12). Each row keeps only the
// known states, once each, in the fixed order; an unknown value is dropped
// and logged, never sent. The result is keyed by primary key and nil when no
// row has a state. A hook error or a result that is not index-aligned with
// records fails the list.
func listRowStates(ctx context.Context, db *gorm.DB, cc *CompiledController, records []any) (map[string][]string, error) {
hook, ok := cc.Controller.(pact.ListRowStates)
if !ok || hook == nil || len(records) == 0 {
return nil, nil
}
result, err := hook.ListRowStates(ctx, db.WithContext(ctx), records)
if err != nil {
slog.Error("cabana: list row states failed", "controller", controllerID(cc), "error", err)
return nil, err
}
if len(result) != len(records) {
slog.Error("cabana: list row states are not aligned with the page", "controller", controllerID(cc), "records", len(records), "states", len(result))
return nil, fmt.Errorf("cabana: controller %s returned %d row states for %d records", controllerID(cc), len(result), len(records))
}
var out map[string][]string
for i, states := range result {
if len(states) == 0 {
continue
}
has := map[pact.RowState]bool{}
for _, state := range states {
if !slices.Contains(rowStateOrder, state) {
slog.Warn("cabana: unknown list row state dropped", "controller", controllerID(cc), "state", string(state))
continue
}
has[state] = true
}
kept := make([]string, 0, len(has))
for _, state := range rowStateOrder {
if has[state] {
kept = append(kept, string(state))
}
}
if len(kept) == 0 {
continue
}
if out == nil {
out = map[string][]string{}
}
out[uitoa(pkUint(records[i]))] = kept
}
return out, nil
}
func normalizePage(schema *ListSchema, in ListInput) (int, int, error) {
page := 1
if in.Page != "" {

View File

@@ -11,3 +11,4 @@ toolbar:
bulkActions: [activate, archive]
messages:
create: acme.roster::lang.people.create
rowStateDisabled: acme.roster::lang.people.state_inactive

View File

@@ -11,3 +11,4 @@ people:
activated: The person was activated.
reinstate: Reinstate
reinstate_confirm: Lift the ban on this person?
state_inactive: Not active

View File

@@ -11,3 +11,4 @@ people:
activated: Osoba została aktywowana.
reinstate: Przywróć
reinstate_confirm: Zdjąć blokadę z tej osoby?
state_inactive: Nieaktywna