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

@@ -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