feat(render-spec): add list.row_action — t-paliad-163 Slice 1

Schema bump that lets the universal <FilterBar> tell shape-list which
row interaction to wire (navigate / complete_toggle / approve / none).
Defaults to navigate when empty so existing SystemView definitions and
saved user views continue to render rows that route to the per-kind
detail page.

Validator extended; pure-Go test cases over every enum value + reject.
TS mirror updated in client/views/types.ts. No DB migration — the
field is purely additive on the JSON shape.
This commit is contained in:
m
2026-05-08 21:49:00 +02:00
parent 1e23745792
commit d5a01e6682
3 changed files with 65 additions and 3 deletions

View File

@@ -71,10 +71,13 @@ export interface FilterSpec {
export type RenderShape = "list" | "cards" | "calendar";
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
export interface ListConfig {
columns?: string[];
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
row_action?: ListRowAction;
}
export interface CardsConfig {

View File

@@ -45,10 +45,23 @@ type RenderSpec struct {
// ListConfig is the per-shape config for shape=list. Powers both the
// /events table look (density=comfortable) and the activity-feed look
// (density=compact + actor/time columns).
//
// RowAction tells shape-list which row interaction to wire when the
// universal <FilterBar> renders the table. "navigate" (the default and
// the contract for the existing /agenda/dashboard surfaces) routes a
// row click to a per-kind detail page. "complete_toggle" is the
// /events deadline-row pattern (checkbox + reopen button). "approve"
// is the /inbox approver row (approve/reject buttons + revoke). "none"
// is read-only (audit views, retrospective lists).
//
// shape-list.ts honours this when emitting the table's `entity-table`
// classes — `entity-table--readonly` plus `none` skips the navigate
// handler entirely.
type ListConfig struct {
Columns []string `json:"columns,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
Density ListDensity `json:"density,omitempty"`
Columns []string `json:"columns,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
Density ListDensity `json:"density,omitempty"`
RowAction ListRowAction `json:"row_action,omitempty"`
}
// CardsConfig is the per-shape config for shape=cards.
@@ -78,6 +91,29 @@ const (
DensityCompact ListDensity = "compact"
)
// ListRowAction identifies which row interaction the list-shape renderer
// should wire. Defaults to RowActionNavigate when empty so existing
// SystemView definitions and saved user views continue to render rows
// that route to the per-kind detail page.
type ListRowAction string
const (
RowActionNavigate ListRowAction = "navigate"
RowActionCompleteToggle ListRowAction = "complete_toggle"
RowActionApprove ListRowAction = "approve"
RowActionNone ListRowAction = "none"
)
// KnownRowActions is the registry the validator checks against. Adding a
// new action = add a const above AND append here AND extend
// shape-list.ts's switch.
var KnownRowActions = []ListRowAction{
RowActionNavigate,
RowActionCompleteToggle,
RowActionApprove,
RowActionNone,
}
type CardsGroupBy string
const (
@@ -148,6 +184,9 @@ func (c *ListConfig) validate() error {
default:
return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density)
}
if c.RowAction != "" && !slices.Contains(KnownRowActions, c.RowAction) {
return fmt.Errorf("%w: unknown list.row_action %q", ErrInvalidInput, c.RowAction)
}
return nil
}

View File

@@ -75,6 +75,26 @@ func TestRenderSpec_CalendarViewEnum(t *testing.T) {
}
}
func TestRenderSpec_RowActionEnum(t *testing.T) {
for _, action := range KnownRowActions {
t.Run(string(action), func(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: action}}
if err := s.Validate(); err != nil {
t.Fatalf("known row_action %q must validate: %v", action, err)
}
})
}
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: "delete"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown row_action must reject, got %v", err)
}
// Empty defaults to navigate at the renderer level — schema accepts.
empty := RenderSpec{Shape: ShapeList, List: &ListConfig{}}
if err := empty.Validate(); err != nil {
t.Fatalf("empty row_action must validate (defaults to navigate): %v", err)
}
}
func TestRenderSpec_RoundTrip(t *testing.T) {
original := RenderSpec{
Shape: ShapeList,