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

@@ -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")
}
})
}