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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
28
modules/cabana/example_rowstate_test.go
Normal file
28
modules/cabana/example_rowstate_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -11,3 +11,4 @@ toolbar:
|
||||
bulkActions: [activate, archive]
|
||||
messages:
|
||||
create: acme.roster::lang.people.create
|
||||
rowStateDisabled: acme.roster::lang.people.state_inactive
|
||||
|
||||
@@ -11,3 +11,4 @@ people:
|
||||
activated: The person was activated.
|
||||
reinstate: Reinstate
|
||||
reinstate_confirm: Lift the ban on this person?
|
||||
state_inactive: Not active
|
||||
|
||||
@@ -11,3 +11,4 @@ people:
|
||||
activated: Osoba została aktywowana.
|
||||
reinstate: Przywróć
|
||||
reinstate_confirm: Zdjąć blokadę z tej osoby?
|
||||
state_inactive: Nieaktywna
|
||||
|
||||
Reference in New Issue
Block a user