feat(frontend): slice 1 — foundation for MUI->shadcn migration
Web UI rework (openspec/changes/web-ui-rework). Foundation slice: - Add @tanstack/react-table; remove orphaned recharts, d3 - Vendor shadcn primitives: tabs table dialog input label checkbox switch progress separator avatar textarea dropdown-menu scroll-area - Add Vitest + @testing-library/react + jsdom harness; npm test script - Delete no-op theme.ts shim; remove getAppTheme references - Smoke test proves the harness Gate: build + lint + test green.
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
# Design — web-ui-rework
|
||||
|
||||
> Phase: **design**. Technical design (not implementation) for finishing the
|
||||
> MUI v9 → shadcn/ui + Tailwind v4 + lucide-react migration and reconciling the
|
||||
> Manage information architecture to a single, coherent, thin-dashboard UI.
|
||||
>
|
||||
> Grounded in `exploration.md` (verified inventory), `proposal.md`, and
|
||||
> `spec.md` (locked decisions). No source changes in this phase.
|
||||
|
||||
## 0. Locked design constraints (do not re-litigate)
|
||||
|
||||
These are settled by the spec and bound every decision below:
|
||||
|
||||
- **Single design system:** shadcn/ui + Tailwind v4 `@theme` tokens + lucide-react only. Zero `@mui/*` / `@emotion/*` / `recharts` / `d3` at the end; `theme.ts` deleted in slice 1.
|
||||
- **IA:** Backups becomes a top-level nav item; the Media/Applications surface is named **"Media"** at canonical route **`/media`**; **`/applications` → redirect (replace) to `/media`**, mirroring the existing `/monitoring` → `/observability` redirect in `App.tsx`. Auth (`auth.ts`, `react-oidc-context`) and `react-router-dom` structure otherwise unchanged.
|
||||
- **TanStack Table:** **visibility-only** parity — pagination, row selection, row click, column visibility. **No sorting, no resizing.**
|
||||
- **Palette:** primary stays `#4f8cff`; `chart-1..5` tokens **repurposed** as status/Grafana-link semantic cues (not dropped).
|
||||
- **Density:** comfortable everywhere (`p-4 md:p-6`, `gap-4`); **no compact mode**.
|
||||
- **Test harness:** Vitest + `@testing-library/react` introduced in slice 1.
|
||||
- **Delivery:** force-chained PRs, ≤400 changed lines/slice; ~8 slices per exploration §9.
|
||||
- **Observability model:** thin dashboard, **no in-app charting**, Grafana deep-links preserved.
|
||||
|
||||
## 1. MUI → shadcn/ui + Tailwind component mapping table
|
||||
|
||||
Authoritative destination for every MUI component present across the 22
|
||||
`@mui/material` consumers (see exploration §2a for per-file frequency). The 22
|
||||
files follow this table verbatim; deviation requires a design-note in the
|
||||
slice's tasks file.
|
||||
|
||||
| MUI component | Destination | shadcn primitive / Tailwind pattern | Status |
|
||||
|---|---|---|---|
|
||||
| `Typography` | semantic element + text utilities | `<h1>`–`<h6>`/`<p>`/`<span>` + `text-* font-*` per the §2 ramp | none needed |
|
||||
| `Box` | `<div>` + utilities | `<div className="…">` with flex/grid utilities | none needed |
|
||||
| `Stack` | `<div>` flex stack | `<div className="flex flex-col gap-4">` (or `flex-row gap-*`) | none needed |
|
||||
| `Grid` | CSS grid `<div>` | `<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">` w/ responsive `md:`/`lg:` | none needed |
|
||||
| `Paper` | bordered surface `<div>` or `Card` | `<div className="rounded-lg border bg-card">`; prefer `Card` where it already wraps a titled section | none needed |
|
||||
| `Card` / `CardContent` | shadcn `Card` / `CardContent` | `@/components/ui/card` (Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter) | **present** |
|
||||
| `Chip` | `Badge` | `@/components/ui/badge` (variants incl. new `success` — §2) | **present** |
|
||||
| `Button` | `Button` | `@/components/ui/button` (`variant`, `size`) | **present** |
|
||||
| `IconButton` | `Button size="icon"` | `<Button variant="ghost" size="icon">` (pattern already used in the shell) | **present** |
|
||||
| `Tooltip` | `Tooltip` family | `@/components/ui/tooltip` (TooltipProvider/Tooltip/TooltipTrigger/TooltipContent) | **present** |
|
||||
| `Alert` | `Alert` family | `@/components/ui/alert` (Alert, AlertTitle, AlertDescription) | **present** |
|
||||
| `Table` / `TableHead` / `TableBody` / `TableRow` / `TableCell` / `TableContainer` | shadcn `Table` family | `@/components/ui/table` (Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption) | **add** |
|
||||
| `Tabs` / `Tab` | shadcn `Tabs` | `@/components/ui/tabs` (Tabs, TabsList, TabsTrigger, TabsContent) | **add** |
|
||||
| `Dialog` / `DialogTitle` / `DialogContent` / `DialogActions` | shadcn `Dialog` | `@/components/ui/dialog` (Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose) | **add** |
|
||||
| `TextField` | `Input` (+ `Label`) | `@/components/ui/input`, `@/components/ui/label` | **add** |
|
||||
| `Select` / `MenuItem` / `FormControl` / `InputLabel` | shadcn `Select` | `@/components/ui/select` (Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel) | **present** |
|
||||
| `Switch` | `Switch` | `@/components/ui/switch` | **add** |
|
||||
| `Checkbox` | `Checkbox` | `@/components/ui/checkbox` | **add** |
|
||||
| `LinearProgress` | `Progress` | `@/components/ui/progress` (indeterminate via animated class) | **add** |
|
||||
| `Divider` | `Separator` | `@/components/ui/separator` | **add** |
|
||||
| `Avatar` | `Avatar` | `@/components/ui/avatar` (Avatar, AvatarImage, AvatarFallback) | **add** |
|
||||
| `Drawer` | `Sheet side="right"` | `@/components/ui/sheet` (already used for mobile nav) | **present** |
|
||||
| `FormControlLabel` | `Label` + control | `<Label>` wrapping the control; or sibling `<div className="flex items-center gap-2">` | **add** `Label` |
|
||||
| `FormHelperText` | muted `<p>` | `<p className="text-xs text-muted-foreground">` | none needed |
|
||||
| `DataGrid` (`@mui/x-data-grid`) | TanStack Table + `Table` | reusable `DataTable` wrapper (§3) on shadcn `Table` | **add** `@tanstack/react-table` |
|
||||
|
||||
**Shared Tailwind layout patterns (anti-drift reference for all 22 files):**
|
||||
|
||||
- **Vertical stack:** `<div className="flex flex-col gap-4">` (replaces MUI `<Stack direction="column" spacing={n}>`).
|
||||
- **Horizontal row:** `<div className="flex flex-row items-center gap-2">`.
|
||||
- **Responsive grid:** `<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">` (replaces MUI `<Grid>` + `<Grid item xs={…} md={…}>`).
|
||||
- **Page section:** `SectionCard` (migrated in slice 2) wraps titled sections; consistent `gap-4` between cards.
|
||||
- **Single surface treatment:** one border weight (`border-border`) and one radius (`rounded-lg` = `--radius` 0.625rem).
|
||||
|
||||
## 2. Token + theme design (`frontend/src/index.css`)
|
||||
|
||||
The existing Tailwind v4 `@theme` block is already a complete shadcn-style
|
||||
system (verified against current source). The edits are surgical: keep the
|
||||
palette, repurpose the chart tokens with documented roles, and add the `success`
|
||||
Badge variant. `tailwind.config.cjs` stays minimal (content glob only) — no JS
|
||||
theme config is introduced.
|
||||
|
||||
### 2.1 Palette — primary unchanged, `chart-1..5` repurposed
|
||||
|
||||
Primary remains `#4f8cff` (light + dark). The five `--color-chart-*` tokens are
|
||||
retained at their current hues and documented in `index.css` as the **status /
|
||||
Grafana-link semantic cue** source of truth. Single source of truth: Badge
|
||||
variants and status code map to the `chart-N` tokens directly, **not** to
|
||||
parallel `--color-success` aliases (which would drift).
|
||||
|
||||
| Token | Current value | Repurposed role | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `--color-chart-1` | `#4f8cff` | **info** — default/informational status; also the Grafana deep-link brand cue (matches primary) | `Badge variant="default"`; Grafana-link `ExternalLink` affordance |
|
||||
| `--color-chart-2` | `#22c55e` | **success** — healthy/OK (target up, run succeeded, resolved) | `Badge variant="success"` (new, §2.3) |
|
||||
| `--color-chart-3` | `#f59e0b` | **warning** — pending/stale/missed-schedule | `Badge variant="warning"` (optional, map at call site) |
|
||||
| `--color-chart-4` | `#ef4444` | **destructive** — error / firing alert (already aligns with `--color-destructive`) | `Badge variant="destructive"`; Alertmanager firing |
|
||||
| `--color-chart-5` | `#8b5cf6` | **neutral-accent** — unknown/paused/secondary, and Grafana Explore deep-link cue (distinct from brand/destructive) | `Badge variant="secondary"` accents; Explore links |
|
||||
|
||||
**Concrete `index.css` edit shape:** add an inline comment block above the
|
||||
`chart-1..5` lines in both the `@theme` block and `.dark` block documenting the
|
||||
role mapping above. No token values change; no token is removed. Example:
|
||||
|
||||
```css
|
||||
@theme {
|
||||
/* …unchanged… */
|
||||
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||
chart-4=destructive, chart-5=neutral-accent. */
|
||||
--color-chart-1: #4f8cff;
|
||||
--color-chart-2: #22c55e;
|
||||
--color-chart-3: #f59e0b;
|
||||
--color-chart-4: #ef4444;
|
||||
--color-chart-5: #8b5cf6;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Density + typography ramp (comfortable, no compact mode)
|
||||
|
||||
Codified as the standard every page follows (already matches the migrated
|
||||
shell `<main className="p-4 md:p-6">` and `gap-4`):
|
||||
|
||||
- **Page padding:** `p-4 md:p-6` (the shell `<main>` already provides this; pages must not double-pad).
|
||||
- **Card / section gap:** `gap-4` between cards; `space-y-4` for stacked sections.
|
||||
- **Typography ramp:**
|
||||
- Page title — `text-lg font-semibold` (rendered by `TopBar` from `navItems`).
|
||||
- Section title — `text-base font-semibold`.
|
||||
- Body — `text-sm`.
|
||||
- Caption / helper — `text-xs text-muted-foreground`.
|
||||
- **Radius:** one — `rounded-lg` (`--radius: 0.625rem`).
|
||||
- **No compact/dense mode** anywhere: Media, Users, Backups runs all use the same comfortable row padding (`<TableCell className="py-3">`). No density toggle is introduced.
|
||||
|
||||
### 2.3 `success` Badge variant to add (`frontend/src/components/ui/badge.tsx`)
|
||||
|
||||
The current `badge.tsx` exposes variants `default | secondary | destructive |
|
||||
outline | ghost | link` — there is **no success/healthy variant**. Add one,
|
||||
mirroring the existing `destructive` soft-cue pattern and drawing color from the
|
||||
repurposed `chart-2` token so the cue is the documented source of truth:
|
||||
|
||||
```ts
|
||||
// inside badgeVariants variants: { …
|
||||
success:
|
||||
"bg-chart-2/10 text-chart-2 focus-visible:ring-chart-2/20 dark:bg-chart-2/20 dark:focus-visible:ring-chart-2/40 [a]:hover:bg-chart-2/20",
|
||||
// optional warning (chart-3) may be added identically if a call site needs it
|
||||
// }
|
||||
```
|
||||
|
||||
Tailwind v4 resolves `chart-2` from the `--color-chart-2` `@theme` token, so
|
||||
`text-chart-2` / `bg-chart-2/10` / `ring-chart-2/20` are valid utilities.
|
||||
|
||||
**Status → variant mapping (consumed consistently by Backups, Observability, Users):**
|
||||
|
||||
| Domain status | Badge variant | Cue token |
|
||||
|---|---|---|
|
||||
| healthy / OK / succeeded / up | `success` | `chart-2` |
|
||||
| informational / default | `default` | `chart-1` |
|
||||
| neutral / secondary / unknown | `secondary` | `chart-5` accent |
|
||||
| pending / stale / warning | `warning` (add if needed) or `secondary` | `chart-3` |
|
||||
| error / firing alert / failed | `destructive` | `chart-4` |
|
||||
|
||||
## 3. TanStack Table design — reusable `DataTable` wrapper
|
||||
|
||||
Both DataGrid surfaces (`Media.tsx`, `FileBrowser.impl.tsx`) are rebuilt on a
|
||||
single headless `@tanstack/react-table` instance styled with the shadcn `Table`
|
||||
primitive, behind a thin reusable wrapper. **Feature scope is visibility-only**
|
||||
(locked): pagination, row selection, row click, column visibility. Sorting and
|
||||
resizing are explicit non-goals.
|
||||
|
||||
### 3.1 Component shape
|
||||
|
||||
`frontend/src/components/ui/data-table.tsx` — a generic wrapper built on
|
||||
`@/components/ui/table`. Props shape (design, not full impl):
|
||||
|
||||
```ts
|
||||
import type { ColumnDef, OnChangeFn, RowSelectionState,
|
||||
VisibilityState, PaginationState } from "@tanstack/react-table";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
// stable identity (Media needs path-derived id so selection survives paging)
|
||||
getRowId?: (row: TData, index: number) => string;
|
||||
// visibility-only feature set
|
||||
enableRowSelection?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
onRowClick?: (row: TData) => void; // Media→open files; FileBrowser→preview
|
||||
columnVisibility?: VisibilityState;
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
|
||||
enableColumnVisibilityToggle?: boolean; // renders the column dropdown
|
||||
// pagination (Media only; FileBrowser does not paginate)
|
||||
enablePagination?: boolean;
|
||||
manualPagination?: boolean; // Media: server-driven via limit/offset
|
||||
pagination?: PaginationState;
|
||||
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||
pageSizeOptions?: number[];
|
||||
rowCount?: number; // server total for Media
|
||||
emptyMessage?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Internally, `DataTable` calls `useReactTable` wired as follows:
|
||||
|
||||
- **Core:** `getCoreRowModel: getCoreRowModel()`.
|
||||
- **Pagination:** `getPaginationRowModel: getPaginationRowModel()` only when `enablePagination` and **not** `manualPagination`. When `manualPagination` is true (Media), the table renders the supplied slice and `rowCount` drives the pager; the page index/size are lifted to the parent and feed `useMediaQuery` limit/offset.
|
||||
- **Selection:** `enableRowSelection: true` gated by the prop; `rowSelection` state is controlled by the parent.
|
||||
- **Column visibility:** `columnVisibility` state controlled by the parent; a `DropdownMenu` + `Checkbox` list renders the toggle UI when `enableColumnVisibilityToggle`.
|
||||
- **Row click:** `<TableRow onClick={() => onRowClick?.(row.original)}>` with `className="cursor-pointer"` when `onRowClick` is set; selection column click stops propagation so checkboxes do not trigger navigation.
|
||||
- **Selection column:** a leading display `ColumnDef` rendering a `Checkbox` (header = select-all-on-page via `table.getIsSomeRowsSelected()/getIsSelected()`), present only when `enableRowSelection`.
|
||||
|
||||
### 3.2 Column-def typing pattern
|
||||
|
||||
Columns are declared as `ColumnDef<TData>[]` with `accessorKey`/`accessorFn`,
|
||||
`header`, and `cell` returning a styled `<TableCell>` child. Static widths use
|
||||
Tailwind classes on the cell content (e.g. `className="w-[120px]`) — **no**
|
||||
TanStack `size`/`enableColumnResizing` (locked out). No column sets
|
||||
`enableSorting`; the table instance never receives `getSortedRowModel`.
|
||||
|
||||
### 3.3 Explicit non-goals (enforced)
|
||||
|
||||
- **No sorting:** no `getSortedRowModel`, no sortable header affordance, no sort indicators. Media's existing external filter controls (search/type/library/HDR) remain the only filtering path.
|
||||
- **No resizing:** `enableColumnResizing` unset; no resize handles rendered.
|
||||
|
||||
### 3.4 Consumer contracts
|
||||
|
||||
**`pages/Media.tsx`** — builds `mediaColumns: ColumnDef<MediaItem>[]` for the 15
|
||||
pre-rework columns (`title, series, season, episode, type, year, runtime_min,
|
||||
size, bitrate, hdr, video, resolution, date_added, library, path`) and renders
|
||||
`<DataTable>` with: `enableRowSelection`, `enablePagination` + `manualPagination`
|
||||
- `rowCount` (driven by `queryResult.total`), `onRowClick` →
|
||||
`navigate('/files?path=…')`, and `enableColumnVisibilityToggle`. Pagination and
|
||||
column-visibility state persist via the existing `usePersistentState` media
|
||||
state, feeding `useMediaQuery({ limit, offset, … })`. The toggleable column set
|
||||
matches the locked list in the spec scenario.
|
||||
|
||||
**`pages/FileBrowser.impl.tsx`** — builds `fileColumns: ColumnDef<FileEntry>[]`
|
||||
for the 5 columns (`type, name, ext, size, modified`) and renders `<DataTable>`
|
||||
with: `enableRowSelection`, `onRowClick` → selects the file for ffprobe preview,
|
||||
and `enableColumnVisibilityToggle`. **No pagination** (the directory listing is
|
||||
rendered in full, as today). Column-visibility state persists via the existing
|
||||
browser state.
|
||||
|
||||
## 4. Navigation / IA design (`frontend/src/App.tsx`)
|
||||
|
||||
Three surgical edits, mirroring the established `/monitoring` → `/observability`
|
||||
redirect pattern. Auth and routing structure are otherwise untouched.
|
||||
|
||||
### 4.1 `navItems` change
|
||||
|
||||
Current:
|
||||
|
||||
```ts
|
||||
const navItems = [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/observability", label: "Observability", icon: Activity },
|
||||
{ path: "/applications", label: "Media", icon: Monitor },
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{ path: "/users", label: "Users", icon: Users },
|
||||
{ path: "/actions", label: "Actions", icon: Zap },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
```
|
||||
|
||||
Target — retarget Media to `/media`, **add a top-level Backups item**, and import
|
||||
a lucide icon for it:
|
||||
|
||||
```ts
|
||||
import { /* …existing…, */ DatabaseBackup } from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/observability", label: "Observability", icon: Activity },
|
||||
{ path: "/media", label: "Media", icon: Monitor },
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ path: "/users", label: "Users", icon: Users },
|
||||
{ path: "/actions", label: "Actions", icon: Zap },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
```
|
||||
|
||||
**Icon choice + fallback:** `DatabaseBackup` is the semantic fit; if the pinned
|
||||
`lucide-react@^1.14.0` (see §8 risk) does not export it, fall back to `HardDrive`
|
||||
or `Archive` (both are long-standing exports). Verify the export before the
|
||||
Backups slice (slice 3). Backups is placed after Files (operational grouping) and
|
||||
before Users.
|
||||
|
||||
### 4.2 Route changes
|
||||
|
||||
In **both** route blocks (the OIDC-configured branch and the unauthenticated
|
||||
branch), make `/media` the canonical element and convert `/applications` to a
|
||||
replace-redirect, exactly mirroring `/monitoring`:
|
||||
|
||||
```tsx
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route path="/applications" element={<Navigate to="/media" replace />} />
|
||||
```
|
||||
|
||||
`/backups` already renders `<BackupsPage />`; no route addition is needed (only
|
||||
the nav item). The page component remains `Applications` (the `Applications.tsx`
|
||||
file rename is **out of scope** — non-goal: routing structure unchanged); only
|
||||
the nav label + canonical route change.
|
||||
|
||||
## 5. Icon migration — MUI → lucide-react
|
||||
|
||||
The 10 distinct `@mui/icons-material` icons map as follows (from exploration §2c):
|
||||
|
||||
| File | MUI icon | lucide-react |
|
||||
|---|---|---|
|
||||
| `components/HoverEditButton.tsx` | `EditOutlined` | `Pencil` |
|
||||
| `pages/UsersPage.impl.tsx` | `Close` | `X` |
|
||||
| `pages/UsersPage.impl.tsx` | `AttachFile` | `Paperclip` |
|
||||
| `pages/UsersPage.impl.tsx` | `FormatBold` | `Bold` |
|
||||
| `pages/UsersPage.impl.tsx` | `FormatItalic` | `Italic` |
|
||||
| `pages/UsersPage.impl.tsx` | `Link` | `Link` |
|
||||
| `pages/UsersPage.impl.tsx` | `FormatListBulleted` | `List` |
|
||||
| `pages/UsersPage.impl.tsx` | `MailOutlined` | `Mail` |
|
||||
| `pages/UsersPage.impl.tsx` | `Send` | `Send` |
|
||||
| `pages/UsersPage.impl.tsx` | `DeleteOutlined` | `Trash2` |
|
||||
|
||||
**Version-pin verification (required gate):** `package.json` pins
|
||||
`lucide-react: ^1.14.0`, an unusual major. All 10 names above (plus
|
||||
`DatabaseBackup`, `ExternalLink` already in use) are standard lucide exports,
|
||||
but the exact pin must be verified to export them **before the UsersPage slice
|
||||
(slice 6)** and the Backups icon (slice 3). Verification step: in a scratch
|
||||
build, import each name from the pinned version; if any is missing, pick the
|
||||
nearest lucide equivalent (e.g. `DatabaseBackup` → `HardDrive`) or bump the pin
|
||||
within `^1.x` as a slice-1 foundation task. The icon set for the already-migrated
|
||||
`ObservabilityPage` (12 icons) and shell is the proof that common names exist at
|
||||
this pin.
|
||||
|
||||
## 6. Vitest setup design (slice 1)
|
||||
|
||||
A minimal component-test harness is introduced in the foundation slice. It must
|
||||
coexist with — not replace — the existing `node --test` suites in
|
||||
`frontend/tests/*.mjs`.
|
||||
|
||||
### 6.1 Config + scripts
|
||||
|
||||
- **Config file:** `frontend/vitest.config.ts` (separate from `vite.config.ts` to
|
||||
keep the build config clean), using `defineConfig` from `vitest/config`,
|
||||
`@vitejs/plugin-react`, `test.environment: "jsdom"`, `test.globals: true`,
|
||||
`test.setupFiles: ["./src/test/setup.ts"]`, the `@` path alias from
|
||||
`tsconfig.app.json`, and `test.include: ["src/**/*.{test,spec}.{ts,tsx}"]` so
|
||||
Vitest does **not** claim the `frontend/tests/*.mjs` node suites.
|
||||
- **Setup file:** `frontend/src/test/setup.ts` importing
|
||||
`@testing-library/jest-dom` (matcher registration).
|
||||
- **npm scripts (added to `package.json`):**
|
||||
- `"test": "vitest run"` — single-run CI gate.
|
||||
- `"test:watch": "vitest"` — watch mode for local dev.
|
||||
- `"test:node": "node --test tests"` — keeps the existing node suites runnable explicitly (they also still run directly).
|
||||
|
||||
### 6.2 Dev dependencies added in slice 1
|
||||
|
||||
`vitest`, `@testing-library/react`, `@testing-library/jest-dom`,
|
||||
`@testing-library/user-event`, `jsdom`. (Runtime deps untouched here.)
|
||||
|
||||
### 6.3 Example test shape (for a migrated component)
|
||||
|
||||
Co-located component test for a slice-2 shared block, asserting migrated
|
||||
behavior — representative pattern every migrated component follows:
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/__tests__/MetricCard.test.tsx
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MetricCard } from "../MetricCard";
|
||||
|
||||
describe("MetricCard", () => {
|
||||
it("renders the label and value", () => {
|
||||
render(<MetricCard label="Movies" value="1,234" subtext="across 3 libraries" />);
|
||||
expect(screen.getByText("Movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||
expect(screen.getByText(/across 3 libraries/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Status-Badge mapping is a natural component-test target (e.g. assert a healthy
|
||||
target renders `variant="success"`), satisfying the spec's "migrated components
|
||||
have component tests" scenario.
|
||||
|
||||
## 7. Slice contracts (~8 slices; full table in exploration §9)
|
||||
|
||||
Each slice is independently reviewable and leaves `npm run build` + `npm run lint`
|
||||
green. One-paragraph contracts follow; the authoritative file list is exploration §9.
|
||||
|
||||
1. **Foundation.** Add the §5 shadcn primitives (`tabs table dialog input label
|
||||
checkbox switch progress separator avatar textarea dropdown-menu scroll-area`),
|
||||
install `@tanstack/react-table`, remove `recharts` + `d3` from `package.json`,
|
||||
delete `frontend/src/theme.ts` (and its callers), and introduce the §6 Vitest
|
||||
harness + a trivial passing test. Add the `success` Badge variant + `chart-*`
|
||||
role comments (§2). **Exit:** no `theme`/`getAppTheme` imports remain; build +
|
||||
lint + `vitest run` green; no behavior change yet.
|
||||
|
||||
2. **Shared components.** Migrate the reusable building blocks to lock the shared
|
||||
language before pages: `SectionCard`, `SelectionRailCard`, `TabbedCard`,
|
||||
`MetricCard`, `DiskSpaceCard`, `HoverEditButton`, `DialogFooter`,
|
||||
`ConfirmDialog`, `LibraryOverview`, `NowPlaying`/`SessionActivityPanel`. Each
|
||||
uses `Card`/`Badge`/`Button`/`Table`/`Tabs`/`Dialog` per §1. **Exit:** all
|
||||
shared components MUI-free; one component test per migrated block; downstream
|
||||
pages still compile against the same exported APIs.
|
||||
|
||||
3. **Backups cluster + nav.** Migrate `BackupAlertsTable`, `BackupJobsTable`,
|
||||
`BackupRunsTable`, `BackupsPage`, `BackupDashboardWidget` (all `Table` family
|
||||
- `Badge` status cues), and apply the §4 `navItems` + route edits to surface
|
||||
`/backups` as a top-level item (verify `DatabaseBackup`/fallback icon).
|
||||
**Exit:** `/backups` reachable from the sidebar; `/applications` redirects to
|
||||
`/media`; build + lint green.
|
||||
|
||||
4. **Dashboard + Media/Applications surface.** Migrate `Dashboard.tsx` (20 MUI
|
||||
components → `Card`/`Grid`→CSS-grid/`Dialog`/`Select`/`Switch`/`TextField`→`Input`)
|
||||
and `Applications.tsx` (9 → `Card`/grid/`Tabs`). **Exit:** both pages MUI-free
|
||||
and visually consistent; build + lint green.
|
||||
|
||||
5. **Settings + Actions.** Migrate `Settings.tsx` (18) and `Actions.tsx` (19) —
|
||||
the form-heavy pair (`TextField`→`Input`, `Select`, `Switch`, `Checkbox`,
|
||||
`Tabs`, `Dialog`). Keep uncontrolled/`useState` form parity (no form library).
|
||||
**Exit:** both pages MUI-free; forms behave as before; build + lint green.
|
||||
|
||||
6. **Users (largest).** Migrate `UsersPage.impl.tsx` (25 MUI components + 9 icons
|
||||
- `Drawer`→`Sheet` + `Table` + rich-text compose). **High sub-split likelihood**
|
||||
(§8): likely split into (a) directory table + selection + drawer, (b) compose
|
||||
dialog + formatting actions + attachments, to stay ≤400 lines. Apply the §5
|
||||
icon map (verify the 9 names at the pin). **Exit:** page MUI-free; drawer,
|
||||
selection-across-pages, and compose/send behavior preserved; build + lint green.
|
||||
|
||||
7. **DataGrid → TanStack Table (highest risk, last).** Build the §3 `DataTable`
|
||||
wrapper on shadcn `Table`, then rebuild `Media.tsx` and `FileBrowser.impl.tsx`
|
||||
against it. Preserve pagination (Media, server-driven) / row selection / row
|
||||
click / column visibility exactly (locked scenario columns). No sorting, no
|
||||
resizing. **Exit:** both grids behaviorally at parity; `@mui/x-data-grid` no
|
||||
longer imported; build + lint green.
|
||||
|
||||
8. **Cleanup + docs.** Remove `@mui/*` + `@emotion/*` from `package.json`, run a
|
||||
final `tsc -b` + `eslint` + `vitest run` + existing `node --test`, and update
|
||||
`docs/REQUIREMENTS.md` (single design system, thin-dashboard observability,
|
||||
TanStack tables, `/media` + `/applications` redirect, Backups nav, removed
|
||||
deps). **Exit:** recursive search of `frontend/src` for `@mui/*`/`@emotion/*`
|
||||
returns zero; requirements doc updated; all gates green.
|
||||
|
||||
## 8. Review / judgment risks
|
||||
|
||||
1. **DataGrid slice sequenced last (high).** Slice 7 carries the single largest
|
||||
behavior-parity surface (Media pagination is **server-driven** via
|
||||
`useMediaQuery` limit/offset, and selection must survive paging via a stable
|
||||
`getRowId`). It is deliberately last so `Table`, tokens, and Badge cues are
|
||||
already settled; but manual smoke of Media pagination + row-click navigation
|
||||
and FileBrowser row-click preview is mandatory at slice exit. Mitigation:
|
||||
component-test the `DataTable` wrapper (selection toggle, column visibility
|
||||
toggle, row-click callback) in slice 7 before re-wiring the pages.
|
||||
|
||||
2. **UsersPage sub-split likelihood (high).** `UsersPage.impl.tsx` is the largest
|
||||
consumer (25 components + 9 icons + `Drawer` + rich-text compose). Expect to
|
||||
split slice 6 into two PRs (table/drawer vs. compose). Judgment call at apply
|
||||
time: split before exceeding 400 lines rather than after.
|
||||
|
||||
3. **Cross-slice drift mitigation (medium).** 22 files over ~8 slices risks
|
||||
inconsistent layout primitives. Mitigated by (a) shared-components-first
|
||||
(slice 2 before any page), (b) the §1 anti-drift Tailwind patterns, and (c)
|
||||
the single `chart-*` color source of truth. Reviewers enforce §1 patterns as
|
||||
the lint-supplement gate.
|
||||
|
||||
4. **lucide-react version risk (low–medium).** `^1.14.0` is an unusual major.
|
||||
The 10 mapped icons (plus `DatabaseBackup` for Backups) must be verified at
|
||||
the pin before the consuming slices (3 and 6). The already-migrated
|
||||
`ObservabilityPage` (12 icons) proves common names resolve; verification is a
|
||||
slice-1 foundation task with a fallback list.
|
||||
|
||||
5. **Test-coverage gaps (medium).** Today there is no component harness; legacy
|
||||
`node --test` suites cover only pure-JS transforms. The new Vitest harness
|
||||
mitigates this, but coverage is only as good as what each slice writes — make
|
||||
"at least one behavioral component test per migrated component" a hard slice
|
||||
gate (spec scenario), especially for the status-Badge mapping and the
|
||||
`DataTable` features.
|
||||
Reference in New Issue
Block a user