- 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
404 lines
18 KiB
Markdown
404 lines
18 KiB
Markdown
# 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.rows` — **rune** 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)
|
||
|
||
```svelte
|
||
<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:
|
||
|
||
```typescript
|
||
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.svelte` → `createSearch()` | 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()`
|
||
|
||
```svelte
|
||
<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
|
||
|
||
```svelte
|
||
<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)
|
||
|
||
```svelte
|
||
<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)
|
||
|
||
6. **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
|
||
|
||
7. **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)
|
||
|
||
8. **Migrate `Ops.svelte` — Pending Approvals**
|
||
- Approve/Deny buttons as action column renderer
|
||
- Risk badge via `StatusBadge kind="risk"`
|
||
|
||
9. **Migrate `Ops.svelte` — Decided Approvals**
|
||
- Same columns, no actions
|
||
|
||
10. **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)
|
||
|
||
11. **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)
|
||
|
||
12. **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
|
||
|
||
13. **Remove deprecated shadcn-svelte table primitives** after confirming nothing else imports them.
|
||
|
||
14. **Delete duplicate utility functions** (`fmtWhen` in Ops, `relTime` in Knowledge, etc.)
|
||
|
||
### Phase 6 — Polish (~1 PR)
|
||
|
||
15. **Column visibility toggle** (optional)
|
||
16. **CSV export** for entity tables (optional)
|
||
17. **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 1–3 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
|