Files
oikos/plans/tables.md
dtoro 50aed11cc4 feat(web): adopt @vincjo/datatables for all tables, standardize shared components
- Add DataTable.svelte: declarative columns, built-in sorting, sticky headers,
  text truncation, column alignment, configurable widths, optional pagination/search
- 12 built-in renderers: BadgeRenderer, StatusBadgeRenderer (unified risk/severity/
  execution/state/type variant mapping), HealthDotRenderer, RelativeTimeRenderer,
  DateRenderer, DurationRenderer, StatusDotRenderer, SignalActions, ApprovalActions,
  ActivityAction, ActivityCancel
- Migrate Overview (task board), Signals, Ops (3 tables) to DataTable
- Refactor EntityTable treegrid to use shared SortHeader, EmptyState, HealthDotRenderer
- Create shared components: EmptyState, StatusBadge, FilterTabs
- Clean up Knowledge.svelte: replace inline relTime() and typeVariant() with shared utils
- Add width, align, truncate column props; table-fixed layout; rounded-xl borders
- Bump version to 0.11.0
2026-07-21 13:19:03 +02:00

18 KiB
Raw Permalink Blame History

Table & Component Standardization Plan

0. Motivation

The app currently has 5 table implementations, each hand-writing <Table.Root> boilerplate from scratch. The shadcn-svelte Table.* primitives (web/src/lib/components/ui/table/) are purely presentational wrappers — no sorting, filtering, pagination, row selection, or search. Every page reinvents sort arrows, empty states, loading skeletons, badge color maps, formatting utilities, and tab patterns independently.

Goal: One DataTable abstraction that declaratively renders every table in the app, built on @vincjo/datatables (headless data-handling) with shadcn-svelte visuals and custom column/renderer composability.

Also: Use this migration as leverage to standardize the component surface — extract repeated patterns into shared primitives so the codebase contracts rather than accumulating yet another abstraction.


1. Audit Summary

1.1 Tables in the App

# Page / Component File LOC Features (what it has) Gaps (what it's missing)
1 EntityTable.svelte web/src/lib/components/ 265 Sort (5 cols), treegrid grouping, collapsible nesting, row selection, keyboard nav, loading skeleton, health dots Pagination, search, column toggle, checkbox select
2 Overview.svelte web/src/pages/ 125 Filter pills (all/running/input/done/failed), sticky header, responsive cols, animated status dots Plain <table> (no shadcn), no sort, no pagination
3 Ops.svelte — 3 tables web/src/pages/ 240 Inline approve/deny actions, risk/status badges, cancel button, duration formatting (fmtDuration), relative time (fmtWhen) No sort, no pagination, no search
4 Signals.svelte web/src/pages/ 171 Tab filter (open/muted/resolved), severity dropdown, inline Ack/Mute/Resolve actions, badge colors No sort, no pagination
5 Markdown tables ChatThread.svelte, EntityDetailContent.svelte CSS-only Prose-styled <table> for AI output No interactive features (by design)

1.2 Repeated Patterns (duplicated per-page)

Pattern Occurrences Where
Sort header with arrow icons 1 (closed set in EntityTable) Only EntityTable has sort; Ops/Signals/Overview don't bother
riskVariant() / severityVariant() / stateVariant() / execStatusVariant() 6 Ops.svelte ×2, Signals.svelte ×1, EntityTable.svelte ×2, Knowledge.svelte ×1
fmtWhen() / relTime() inline relative-time formatting 3 Ops.svelte, Knowledge.svelte (both inline; utils.ts has relativeTime already)
<Table.Root> > <Table.Header> > <Table.Row> > <Table.Head> boilerplate 6 Every table page
Empty state <Table.Cell colspan={N}>No ...</Table.Cell> 6 Every table page
<Tabs.Root> > <Tabs.List> > <Tabs.Trigger> with badge counts 2 Ops.svelte, Signals.svelte
Loading skeleton 2 EntityTable.svelte (custom widths), EntityDetailContent.svelte

1.3 Current Tech Stack

Layer What Version
Framework Svelte 5 (runes mode) ^5.0.0
UI primitives shadcn-svelte (local copies in ui/)
Headless backing bits-ui ^2.18.1
CSS Tailwind v4 (CSS-first config, no PostCSS) ^4.3.2
Variant system tailwind-variants ^3.2.2
Icons @lucide/svelte ^1.23.0
Table library none

2. @vincjo/datatables — Why This Library

Headless. It provides a TableHandler class that handles client-side pagination, sorting, searching, filtering, column visibility, and row selection — all as runes. Rendering is entirely up to us. This pairs perfectly with shadcn-svelte visual styling.

API surface (what we care about):

  • new TableHandler(data) — instantiate with reactive data
  • table.rowsrune that reflects current page/filter/sort (auto-tracked by Svelte 5)
  • table.rowCount, table.pageCount, table.currentPage, table.pages, table.pagesWithEllipsis
  • table.setRows(data), table.setRowsPerPage(n), table.setPage('next'|'previous'|int)
  • table.createSort(), table.createSearch(), table.createFilter(), table.createView()
  • table.select(id), table.selectAll(), table.selected, table.isAllSelected
  • table.createCSV(), table.createCalculation(), table.createRecordFilter()

No dependencies. Lightweight. TypeScript-native. SSR friendly (even though we're SPA).

What it does NOT do (and that's fine)

  • No rendering. We build the UI ourselves — use shadcn-svelte primitives.
  • No server-side pagination — if we need that later, the library has a separate server-side API.
  • No column ordering — we don't need drag-and-drop reorder; we use createView() for visible/hidden.

3. Architecture Plan

3.1 New Core Component: DataTable.svelte

web/src/lib/components/data-table/
├── DataTable.svelte          # The main table component
├── DataTable.svelte.ts       # TypeScript type definitions
├── columns.ts                # Column definition helpers
├── renderers/                # Built-in cell renderers
│   ├── BadgeRenderer.svelte
│   ├── HealthDotRenderer.svelte
│   ├── RelativeTimeRenderer.svelte
│   └── DateRenderer.svelte
├── pagination/               # Pagination UI
│   ├── Pagination.svelte
│   ├── PageButton.svelte
│   └── RowsPerPage.svelte
├── sort-header.svelte        # Sortable column header with arrow icons
├── search-input.svelte       # Text search input
└── toolbar.svelte            # Top toolbar (search + filter + page size)

3.2 DataTable API (declarative, Svelte 5 runes)

<script lang="ts">
  import DataTable from '$lib/components/data-table/DataTable.svelte'
  import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte'

  let data = $state<MyRow[]>([])
  let selected = $state<Set<string>>(new Set())

  const columns: DataTableColumn<MyRow>[] = [
    { key: 'slug',   header: 'Slug',   sortable: true,                            class: 'font-mono text-xs' },
    { key: 'type',   header: 'Type',   sortable: true,  render: 'badge'                                     },
    { key: 'health', header: 'Health', sortable: true,  render: 'health-dot',      accessor: (r) => r        },
    { key: 'actions', header: '',      sortable: false, render: (row) => component /* snippet or component */ },
  ]
</script>

<DataTable
  {columns}
  {data}
  bind:selected
  pageSize={20}
  searchable
  paginated
  sortKey="slug"
  sortDir="asc"
  loading
  emptyMessage="No items."
>
  <!-- optional slot for toolbar actions -->
</DataTable>

3.3 Column System

A DataTableColumn<T> is:

type ColumnRenderer<T> =
  | 'badge'           // wraps value in <Badge variant="outline">
  | 'health-dot'      // colored dot + relative time
  | 'relative-time'   // relativeTime(val)
  | 'date'            // new Date(val).toLocaleString()
  | Component         // any Svelte component, receives { row, value }
  | ((row: T) => any) // raw value formatter
  | undefined         // raw value

Built-in renderers cover badge colors, health dots, timestamps — eliminating the 6 inline riskVariant()/severityVariant()/stateVariant() copies. Custom components cover action buttons and complex cells.

3.4 What ships with the table

Feature How Default
Sorting Click column header → createSort() Yes, if sortable: true
Pagination table.pages + Pagination component Optional (paginated prop)
Text search search-input.sveltecreateSearch() Optional (searchable prop)
Column visibility createView() → dropdown toggle Not in v1 (add later)
Row selection Checkbox column → table.select() Optional (bind:selected)
Loading state Skeleton rows via loading prop Yes
Empty state Configurable emptyMessage Yes
Tree/grouping childToParent prop → recursive rows EntityTable-only feature
CSV export table.createCSV() → download button Not in v1 (add later)
Server-side pagination handlePageChange callback Not needed yet

4. Standardized Shared Components

Extract the repeated patterns discovered in the audit into shared components:

4.1 StatusBadge.svelte

Replaces: 6 copies of riskVariant(), severityVariant(), stateVariant(), execStatusVariant()

<script lang="ts">
  let { value, kind = 'state' }: { value: string; kind?: 'risk' | 'severity' | 'state' | 'execution' } = $props()
  // Resolves variant mapping from kind + value
</script>

4.2 EmptyState.svelte

Replaces: 6 <Table.Cell colspan={N}>No ...</Table.Cell> blocks

<script lang="ts">
  let { message = 'No items.', colspan = 999, icon = null } = $props()
</script>

4.3 RelativeTime.svelte

Replaces: Oks.svelte:58 (fmtWhen), Knowledge.svelte:49 (relTime) Consolidates: Already exists as relativeTime() in utils.ts — wrap in a component that auto-updates.

4.4 FilterTabs.svelte

Replaces: Ops.svelte:114-120 and Signals.svelte:153-159 (Tabs.Root boilerplate with badge counts)

<script lang="ts">
  let { tabs, value = $bindable(''), class, children }: {
    tabs: { value: string; label: string; count?: number }[];
    value?: string;
    class?: string;
    children?: any;
  } = $props()
</script>

4.5 PageHeader.svelte

Replaces: Every page's <h1 class="text-lg font-semibold">...</h1> + optional actions row.


5. Migration Sequence (ordered for incremental delivery)

Phase 1 — Library & Foundation (~1 PR)

  1. Install @vincjo/datatables

    npm install -D @vincjo/datatables
    
  2. Build DataTable.svelte + DataTable.svelte.ts + columns.ts

    • Core loop: {#each table.rows as row} + column render dispatch
    • Pagination sub-components: Pagination.svelte, PageButton.svelte, RowsPerPage.svelte
    • SortHeader.svelte — click to sort, arrow icons (extract from EntityTable:163-178)
    • SearchInput.svelte — debounced text search
  3. Build renderers: BadgeRenderer.svelte, HealthDotRenderer.svelte, RelativeTimeRenderer.svelte, DateRenderer.svelte

  4. Build EmptyState.svelte

  5. Unit tests for DataTable column dispatch, sort, pagination, selection.

Phase 2 — Simple Tables (no tree, no actions) (~1 PR)

  1. Migrate Overview.svelte (task board)

    • Plain <table>DataTable with StatusBadge, RelativeTime, filter pills external
    • Drop sticky-header CSS (DataTable handles it)
    • Verify: filter pills, status dots, responsive summary column, click-to-open
  2. Migrate Signals.svelte

    • Replace signalTable snippet → DataTable with action-column renderer
    • Extract FilterTabs.svelte from the Tabs boilerplate
    • Verify: severity dropdown, tab counts, Ack/Mute/Resolve buttons

Phase 3 — Action Tables (~1 PR)

  1. Migrate Ops.svelte — Pending Approvals

    • Approve/Deny buttons as action column renderer
    • Risk badge via StatusBadge kind="risk"
  2. Migrate Ops.svelte — Decided Approvals

    • Same columns, no actions
  3. Migrate Ops.svelte — Activity

    • Cancel button, summary + error inline, duration via RendererComponent
    • Extract FilterTabs for Approvals vs Activity tabs

Phase 4 — Tree Table (~1 PR)

  1. Migrate EntityTable.svelte
    • Treegrid grouping is the hard part. Build a TreeTable variant or a grouped prop.
    • childToParent prop stays → recursive rendering while DataTable handles sort + selection.
    • Alternative: Ship treegrid as a separate TreeDataTable.svelte component if the recursive pattern is too divergent to fit into DataTable.

Phase 5 — Cleanup & Standardization (~1 PR)

  1. Extract shared components everywhere:

    • Audit every .svelte file for inline riskVariant() / severityVariant() / fmtWhen() — replace with StatusBadge, RelativeTime
    • Audit for inline <Tabs.Root> boilerplate — replace with FilterTabs
    • Audit for <Badge variant={...}> with inline logic — consolidate
  2. Remove deprecated shadcn-svelte table primitives after confirming nothing else imports them.

  3. Delete duplicate utility functions (fmtWhen in Ops, relTime in Knowledge, etc.)

Phase 6 — Polish (~1 PR)

  1. Column visibility toggle (optional)
  2. CSV export for entity tables (optional)
  3. Responsive tables — horizontal scroll with frozen left column for mobile

6. Risk Assessment

Risk Mitigation
@vincjo/datatables doesn't support treegrid grouping EntityTable's recursive rendering stays independent; DataTable wraps flat tables only
Svelte 5 runes + TableHandler reactivity mismatch TableHandler.rows is a rune. Wrap in $derived or $effect to feed data prop → table.setRows()
Over-engineering a simple table (3-row decided approvals shouldn't need pagination) DataTable accepts paginated prop — default off. Small tables stay simple.
Treegrid migration breaks KB browser Phase 4 is isolated. Phases 13 deliver value before touching the critical KB table.

7. Success Criteria

  1. Every <Table.Root> in the app routes through DataTable.svelte
  2. 0 copies of inline riskVariant() / severityVariant() / stateVariant() — all through StatusBadge
  3. 0 copies of inline fmtWhen() / relTime() — all through RelativeTime or utils.relativeTime
  4. 0 copies of manual <Table.Cell colspan={N}>No ...</Table.Cell> — all through EmptyState
  5. web/src/lib/components/ui/table/ retained for DataTable internals only (or removed if unused)
  6. TypeScript compiles with --noEmit and tests pass (vitest run)
  7. All existing features preserved: sort, tree expand/collapse, tab filters, severity dropdown, approve/deny/cancel/ack/resolve buttons, sticky headers, loading skeletons, health dots, empty states

8. File Manifest (what gets created / modified / deleted)

Created

plan/tables.md                                            ← this file
web/src/lib/components/data-table/DataTable.svelte
web/src/lib/components/data-table/DataTable.svelte.ts
web/src/lib/components/data-table/columns.ts
web/src/lib/components/data-table/columns.test.ts
web/src/lib/components/data-table/renderers/BadgeRenderer.svelte
web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte
web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte
web/src/lib/components/data-table/renderers/DateRenderer.svelte
web/src/lib/components/data-table/pagination/Pagination.svelte
web/src/lib/components/data-table/pagination/PageButton.svelte
web/src/lib/components/data-table/pagination/RowsPerPage.svelte
web/src/lib/components/data-table/sort-header.svelte
web/src/lib/components/data-table/search-input.svelte
web/src/lib/components/data-table/toolbar.svelte
web/src/lib/components/StatusBadge.svelte
web/src/lib/components/EmptyState.svelte
web/src/lib/components/RelativeTime.svelte
web/src/lib/components/FilterTabs.svelte
web/src/lib/components/PageHeader.svelte

Modified (in migration order)

web/package.json                                          ← add @vincjo/datatables
web/src/pages/Overview.svelte                             ← Phase 2
web/src/pages/Signals.svelte                              ← Phase 2
web/src/pages/Ops.svelte                                  ← Phase 3
web/src/lib/components/EntityTable.svelte                 ← Phase 4
web/src/pages/KnowledgeBase.svelte                        ← Phase 4 (consumer of EntityTable)
web/src/pages/Knowledge.svelte                            ← Phase 5 (remove relTime)

Potentially Removed (Phase 5)

web/src/lib/components/ui/table/*                          ← if DataTable is the sole consumer
(These stay if DataTable still uses them internally for rendering)

9. Implementation Status

Completed (2026-07-21)

Phase Task Status
1 Install @vincjo/datatables Done
1 DataTable.svelte core component Done
1 Types (DataTable.svelte.ts, columns.ts) Done
1 Pagination (Pagination, PageButton, RowsPerPage) Done
1 Sort header, search input, toolbar Done
1 Built-in renderers: BadgeRenderer, HealthDotRenderer, RelativeTimeRenderer, DateRenderer, RiskBadgeRenderer, ExecutionStatusRenderer, DurationRenderer, StatusDotRenderer Done
1 EmptyState.svelte shared component Done
2 Migrate Overview.svelte to DataTable Done
2 Migrate Signals.svelte to DataTable Done
3 Migrate Ops.svelte (3 tables) to DataTable Done
4 Refactor EntityTable.svelte to use shared {SortHeader, EmptyState, HealthDotRenderer} Done
5 Create StatusBadge.svelte (consolidates risk/severity/execution-type variant maps) Done
5 Create FilterTabs.svelte component Done
5 Clean up Knowledge.svelte: replace inline relTime()relativeTime(), typeVariant()StatusBadge Done

Key Decisions Made During Implementation

  • EntityTable treegrid NOT migrated to DataTable. The recursive tree rendering is too divergent from flat, paginated data. Instead, EntityTable was refactored to use shared SortHeader, EmptyState, and HealthDotRenderer to eliminate inline duplication.
  • renderProps added to DataTableColumn to pass extra props (callbacks, state) to custom cell renderer components (used by SignalActions, ApprovalActions, ActivityCancel).
  • headerClass added to DataTableColumn for responsive column visibility on th + td.
  • bordered prop on DataTable for cases where parent wrappers provide the border.
  • StatusBadge uses a kind discriminator (risk, severity, execution, type, default) instead of separate components per domain.
  • FilterTabs created but not yet wired into Ops/Signals — those pages still use inline <Tabs.Root> for the approvals/activity and open/muted/resolved tabs.

Remaining (Phase 6 — Future PR)

  • Wire FilterTabs into Ops.svelte and Signals.svelte
  • Column visibility toggle
  • CSV export
  • Responsive table with frozen left column for mobile