Files
paliad/internal/services/render_spec.go
m d5a01e6682 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.
2026-05-08 21:49:00 +02:00

247 lines
7.2 KiB
Go

package services
// RenderSpec is the structured render description that drives how a view
// is presented. Same shape lives in paliad.user_views.render_spec (jsonb)
// and is composed by the frontend's view editor.
//
// Design: docs/design-data-display-model-2026-05-06.md §4 (Q4-Q5).
//
// Q4 lock-in (m, 2026-05-07): three shapes — list, cards, calendar.
// "Activity" is content selection (sources + filters), not visualisation —
// achieved via shape="list", list.density="compact", list.columns=
// ["time","actor","title","project"]. Same component, different config.
import (
"encoding/json"
"fmt"
"slices"
)
// RenderShape identifies which presentation component a view uses.
type RenderShape string
const (
ShapeList RenderShape = "list"
ShapeCards RenderShape = "cards"
ShapeCalendar RenderShape = "calendar"
)
// AllShapes lists every supported shape. Used by the validator and by
// the in-page shape switcher.
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
// RenderSpec is the top-level render description.
//
// Per-shape config blocks are kept on save even when a different shape
// is selected, so flipping back to a previously-used shape preserves
// its tweaks (Q5 design decision).
type RenderSpec struct {
Shape RenderShape `json:"shape"`
List *ListConfig `json:"list,omitempty"`
Cards *CardsConfig `json:"cards,omitempty"`
Calendar *CalendarConfig `json:"calendar,omitempty"`
}
// 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"`
RowAction ListRowAction `json:"row_action,omitempty"`
}
// CardsConfig is the per-shape config for shape=cards.
type CardsConfig struct {
GroupBy CardsGroupBy `json:"group_by,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
ShowEmptyDays bool `json:"show_empty_days,omitempty"`
}
// CalendarConfig is the per-shape config for shape=calendar.
type CalendarConfig struct {
DefaultView CalendarView `json:"default_view,omitempty"`
ShowWeekends bool `json:"show_weekends,omitempty"`
}
type SortOrder string
const (
SortDateAsc SortOrder = "date_asc"
SortDateDesc SortOrder = "date_desc"
)
type ListDensity string
const (
DensityComfortable ListDensity = "comfortable"
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 (
CardsGroupByDay CardsGroupBy = "day"
CardsGroupByWeek CardsGroupBy = "week"
CardsGroupByNone CardsGroupBy = "none"
)
type CalendarView string
const (
CalendarMonth CalendarView = "month"
CalendarWeek CalendarView = "week"
)
// KnownListColumns is the curated set of column keys the list-shape
// renderer understands. Validator rejects anything else so a typo in
// the editor surfaces immediately.
var KnownListColumns = []string{
"date", "time", "title", "project", "actor", "status",
"rule", "event_type", "location", "appointment_type",
"approval_status", "decided_by", "kind",
}
// Validate runs the full RenderSpec contract.
func (s *RenderSpec) Validate() error {
if s == nil {
return fmt.Errorf("%w: render_spec is required", ErrInvalidInput)
}
switch s.Shape {
case ShapeList, ShapeCards, ShapeCalendar:
// fine
default:
return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape)
}
if s.List != nil {
if err := s.List.validate(); err != nil {
return err
}
}
if s.Cards != nil {
if err := s.Cards.validate(); err != nil {
return err
}
}
if s.Calendar != nil {
if err := s.Calendar.validate(); err != nil {
return err
}
}
return nil
}
func (c *ListConfig) validate() error {
for _, col := range c.Columns {
if !slices.Contains(KnownListColumns, col) {
return fmt.Errorf("%w: unknown list.columns value %q", ErrInvalidInput, col)
}
}
switch c.Sort {
case "", SortDateAsc, SortDateDesc:
default:
return fmt.Errorf("%w: unknown list.sort %q", ErrInvalidInput, c.Sort)
}
switch c.Density {
case "", DensityComfortable, DensityCompact:
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
}
func (c *CardsConfig) validate() error {
switch c.GroupBy {
case "", CardsGroupByDay, CardsGroupByWeek, CardsGroupByNone:
default:
return fmt.Errorf("%w: unknown cards.group_by %q", ErrInvalidInput, c.GroupBy)
}
switch c.Sort {
case "", SortDateAsc, SortDateDesc:
default:
return fmt.Errorf("%w: unknown cards.sort %q", ErrInvalidInput, c.Sort)
}
return nil
}
func (c *CalendarConfig) validate() error {
switch c.DefaultView {
case "", CalendarMonth, CalendarWeek:
default:
return fmt.Errorf("%w: unknown calendar.default_view %q", ErrInvalidInput, c.DefaultView)
}
return nil
}
// DefaultRenderSpec returns a minimal valid spec — used as the seed when
// the editor opens with a blank canvas.
func DefaultRenderSpec() RenderSpec {
return RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Sort: SortDateAsc,
Density: DensityComfortable,
},
}
}
// MarshalRenderSpec validates + encodes for storage.
func MarshalRenderSpec(s RenderSpec) ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
return json.Marshal(s)
}
// UnmarshalRenderSpec parses + validates.
func UnmarshalRenderSpec(b []byte) (RenderSpec, error) {
var s RenderSpec
if err := json.Unmarshal(b, &s); err != nil {
return RenderSpec{}, fmt.Errorf("%w: render_spec malformed: %v", ErrInvalidInput, err)
}
if err := s.Validate(); err != nil {
return RenderSpec{}, err
}
return s, nil
}