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

@@ -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 != "" {