Merge: t-paliad-163 Phase 1 — universal <FilterBar> primitive + /inbox migration

Three slices on mai/riemann/inventor-universal:

  d5a01e6  Slice 1 — RenderSpec.list.row_action + validator + tests
  de4e133  Slice 2 — <FilterBar> scaffolding (axes / url-codec / save-modal)
  4670cd6  Slice 3 — /inbox migrates to <FilterBar>; tabs collapse to chips

What ships (Phase 1):

- A new frontend/src/client/filter-bar/ module:
    types.ts        — Spec + RenderSpec + AxisDeclaration types
    axes.ts         — registry of supported filter axes
    url-codec.ts    — URL ↔ FilterSpec serialization (round-tripping)
    save-modal.ts   — "Speichern als Sicht" dialog
    index.ts        — <FilterBar> mounts
  Plus a url-codec.test.ts golden table.
- /inbox surface migrates to the bar:
    Top-level "Zur Genehmigung / Meine Anfragen" tabs collapse into the
    bar's `approval_viewer_role` chip cluster (incoming / outgoing /
    both). One control, three mutually exclusive options. Stateful via
    `?role=` URL param.
    Bookmark-friendly: legacy `?tab=mine` + `?tab=pending-mine` redirect
    to `?role=outgoing` and `?role=incoming` respectively for one
    release.
    Sortable column headers on the result list (list-shape only;
    cards/calendar shape-modes defer their own ordering to the spec).
- RenderSpec.list gains `row_action` ("navigate" | "expand" | "none")
  so list-shape surfaces declare row click behaviour explicitly. The
  validator + tests cover the new field.
- system_views.go gains the inbox SystemView definitions so the bar
  reads its base spec from the same registry that custom views use.

m's locked positions (commit `1e23745` design doc; m's greenlight
2026-05-08 21:47): all 11 default picks honoured. Q4 = collapse
tabs to chips ✓.

Phase 2 surfaces (port /agenda → bar; port /events → bar; port
/deadlines → bar; port /appointments → bar) follow as separate PRs.

Refs m/paliad#23.
This commit is contained in:
m
2026-05-08 22:03:51 +02:00
17 changed files with 2468 additions and 297 deletions

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,

View File

@@ -101,8 +101,13 @@ func EventsSystemView() SystemView {
}
// InboxSystemView returns the SystemView definition for /inbox — the
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
// 4-eye approval surface (the "Zur Genehmigung" view). The "Eigene
// Anfragen" sibling view is selected via the bar's
// approval_viewer_role axis (chip cluster on the same surface).
//
// RowAction = RowActionApprove → shape-list.ts renders the approval
// row layout (entity title + diff + approve/reject/revoke buttons)
// and the surface wires action handlers via the rendered data-attrs.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
@@ -122,14 +127,17 @@ func InboxSystemView() SystemView {
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
Density: DensityComfortable,
Sort: SortDateAsc,
RowAction: RowActionApprove,
},
},
}
}
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
// InboxRequesterSystemView is the "Eigene Anfragen" sibling view of
// /inbox. Reachable via the bar's approval_viewer_role chip ("Eigene
// Anfragen") on the /inbox surface, or as its own URL on /views/inbox-mine.
func InboxRequesterSystemView() SystemView {
return SystemView{
Slug: "inbox-mine",
@@ -148,8 +156,9 @@ func InboxRequesterSystemView() SystemView {
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
Density: DensityComfortable,
Sort: SortDateAsc,
RowAction: RowActionApprove,
},
},
}