Compare commits
68 Commits
dd4e01d6a7
...
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
095abe1751 | ||
|
|
e8859e53ce | ||
|
|
021f6c7811 | ||
|
|
c18ba7d085 | ||
|
|
795ffef9dc | ||
|
|
039f2fa5fe | ||
|
|
ac2fb9608f | ||
|
|
8926627c5c | ||
|
|
bd4e22eafb | ||
|
|
eb62c80daf | ||
|
|
043f631eac | ||
|
|
2a78f1535e | ||
|
|
65ad955b97 | ||
|
|
80678a0d61 | ||
|
|
08bac437f7 | ||
|
|
8c1c953259 | ||
|
|
4abf80144e | ||
|
|
5fe7752b46 | ||
|
|
22c098f9b6 | ||
|
|
c89c163068 | ||
|
|
f00dc797f2 | ||
|
|
e664e449c3 | ||
|
|
b168d7c867 | ||
|
|
c4cb2b2e31 | ||
|
|
ef28c0b546 | ||
|
|
a62b69b8e2 | ||
|
|
ff4ba9bb91 | ||
|
|
c1cb9fa536 | ||
|
|
fd9b5e4fef | ||
|
|
ec0db5a011 | ||
|
|
bda0d11fde | ||
|
|
5c02b52cb0 | ||
|
|
be23161582 | ||
|
|
6521bbcf44 | ||
|
|
b959edd6c7 | ||
|
|
ff9f1aa519 | ||
|
|
91788737b0 | ||
|
|
5bd965e59a | ||
|
|
e21d920fe3 | ||
|
|
a92ada8117 | ||
|
|
932dc9dcbd | ||
|
|
9c9063dc1b | ||
|
|
4f3e9c0f35 | ||
|
|
daf53ad499 | ||
|
|
8418b89a77 | ||
|
|
fdf45d0d94 | ||
|
|
d9483ec4d1 | ||
|
|
f075968e66 | ||
|
|
544b82301a | ||
|
|
4526d4c7ef | ||
|
|
646551cb93 | ||
|
|
a173c5b6ce | ||
|
|
016c92ec4f | ||
|
|
1ec7ace4e3 | ||
|
|
af3219a7df | ||
|
|
cffda9a5a7 | ||
|
|
f7d30c1257 | ||
|
|
f9addff5a6 | ||
|
|
7a49a0b1db | ||
|
|
6a404ddd53 | ||
|
|
bef93f4fe8 | ||
|
|
20a5d2030e | ||
|
|
c76ae79d7a | ||
|
|
e2db46fc98 | ||
|
|
8695b9b878 | ||
|
|
fc835ef3f9 | ||
|
|
df5450925e | ||
|
|
d41961dbe2 |
@@ -17,23 +17,27 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
run: npx vitest run --exclude 'e2e/**'
|
||||
|
||||
- name: Build library
|
||||
run: npm run build:lib
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
TAG="latest"
|
||||
else
|
||||
;;
|
||||
*)
|
||||
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
|
||||
DATE=$(date +%Y%m%d)
|
||||
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
|
||||
TAG="dev"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
|
||||
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
|
||||
npm publish --tag "$TAG"
|
||||
|
||||
@@ -37,8 +37,8 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
|
||||
### Import Paths
|
||||
```tsx
|
||||
import { Button, Input } from '../design-system/primitives'
|
||||
import { Modal, DataTable } from '../design-system/composites'
|
||||
import type { Column } from '../design-system/composites'
|
||||
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
|
||||
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
|
||||
import { AppShell } from '../design-system/layout/AppShell'
|
||||
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||
```
|
||||
@@ -91,10 +91,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||
|
||||
```tsx
|
||||
// All components from single entry
|
||||
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
|
||||
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
|
||||
|
||||
// Types
|
||||
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
||||
|
||||
// Providers
|
||||
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- Page-level attention banner → **Alert**
|
||||
- Temporary non-blocking feedback → **Toast** (via `useToast`)
|
||||
- Destructive action confirmation → **AlertDialog**
|
||||
- Destructive action needing typed confirmation → **ConfirmDialog**
|
||||
- Generic dialog with custom content → **Modal**
|
||||
|
||||
### "I need a form input"
|
||||
@@ -19,6 +20,8 @@
|
||||
- Yes/no with label → **Checkbox**
|
||||
- One of N options (≤5) → **RadioGroup** + **RadioItem**
|
||||
- One of N options (>5) → **Select**
|
||||
- Select multiple from a list → **MultiSelect**
|
||||
- Edit text inline without a form → **InlineEdit**
|
||||
- Date/time → **DateTimePicker**
|
||||
- Date range → **DateRangePicker**
|
||||
- Wrap any input with label/error/hint → **FormField**
|
||||
@@ -30,6 +33,7 @@
|
||||
|
||||
### "I need to show status"
|
||||
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
|
||||
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
|
||||
- Labeled status → **Badge** with semantic color
|
||||
- Removable label → **Tag**
|
||||
|
||||
@@ -52,33 +56,43 @@
|
||||
- Categorical comparison → **BarChart**
|
||||
- Inline trend → **Sparkline**
|
||||
- Event log → **EventFeed**
|
||||
- Processing pipeline → **ProcessorTimeline**
|
||||
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||
- Processing pipeline (flow diagram) → **RouteFlow**
|
||||
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
|
||||
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
|
||||
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
|
||||
|
||||
### "I need to organize content"
|
||||
- Collapsible sections (standalone) → **Collapsible**
|
||||
- Multiple collapsible sections (one/many open) → **Accordion**
|
||||
- Tabbed content → **Tabs**
|
||||
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||
- Side panel inspector → **DetailPanel**
|
||||
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
|
||||
- Section with title + action → **SectionHeader**
|
||||
- Empty content placeholder → **EmptyState**
|
||||
- Grouped content box → **Card** (with optional accent)
|
||||
- Grouped content box → **Card** (with optional accent and title)
|
||||
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
|
||||
|
||||
### "I need to display text"
|
||||
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
|
||||
- Monospace inline text → **MonoText**
|
||||
- Keyboard shortcut hint → **KeyboardHint**
|
||||
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
|
||||
|
||||
### "I need to show people/users"
|
||||
- Single user avatar → **Avatar**
|
||||
- Stacked user avatars → **AvatarGroup**
|
||||
|
||||
### "I need to group buttons"
|
||||
- Connected button strip (toggle group, segmented control) → **ButtonGroup** (horizontal or vertical)
|
||||
### "I need to group buttons or toggle selections"
|
||||
- Multi-select toggle group with colored indicators → **ButtonGroup** (e.g., status filters)
|
||||
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||
|
||||
### "I need filtering"
|
||||
- Filter pill/chip → **FilterPill**
|
||||
- Multi-select status/category filter → **ButtonGroup** (toggle items on/off)
|
||||
- Filter pill/chip → **FilterPill** (individual toggleable pills)
|
||||
- Full filter bar with search → **FilterBar**
|
||||
- Select multiple from a list → **MultiSelect**
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
@@ -107,11 +121,20 @@ Row of StatCard components (each with optional Sparkline and trend)
|
||||
Below: charts (AreaChart, LineChart, BarChart)
|
||||
```
|
||||
|
||||
### Master/detail management pattern
|
||||
```
|
||||
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
|
||||
EntityList provides: search header, add button, selectable list
|
||||
SplitPane provides: responsive two-column layout with empty state
|
||||
```
|
||||
|
||||
### Detail/inspector pattern
|
||||
```
|
||||
DetailPanel (right slide) with Tabs for sections
|
||||
Each tab: Cards with data, CodeBlock for payloads,
|
||||
ProcessorTimeline for exchange flow
|
||||
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
|
||||
Tabbed: use tabs prop for multiple panels
|
||||
Scrollable: use children for stacked sections (overview, errors, route flow, timeline)
|
||||
Each section: Cards with data, CodeBlock for payloads,
|
||||
ProcessorTimeline or RouteFlow for exchange flow
|
||||
```
|
||||
|
||||
### Feedback flow
|
||||
@@ -151,45 +174,55 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
||||
| BarChart | composite | Categorical data comparison, optional stacking |
|
||||
| Breadcrumb | composite | Navigation path showing current location |
|
||||
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
||||
| ButtonGroup | primitive | Groups buttons into a connected strip with shared borders (horizontal/vertical) |
|
||||
| Card | primitive | Content container with optional accent border |
|
||||
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
||||
| Card | primitive | Content container with optional accent border and title header |
|
||||
| Checkbox | primitive | Boolean input with label |
|
||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||
| Collapsible | primitive | Single expand/collapse section |
|
||||
| CommandPalette | composite | Full-screen search and command interface |
|
||||
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||
| DateRangePicker | primitive | Date range selection with presets |
|
||||
| DateTimePicker | primitive | Single date/time input |
|
||||
| DetailPanel | composite | Slide-in side panel with tabs |
|
||||
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
||||
| Dropdown | composite | Action menu triggered by any element |
|
||||
| EmptyState | primitive | Placeholder for empty content areas |
|
||||
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
|
||||
| EventFeed | composite | Chronological event log with severity |
|
||||
| FilterBar | composite | Search + filter controls for data views |
|
||||
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
|
||||
| FilterPill | primitive | Individual filter chip (active/inactive), supports forwardRef |
|
||||
| FormField | primitive | Wrapper adding label, hint, error to any input |
|
||||
| InfoCallout | primitive | Inline contextual note with variant colors |
|
||||
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
||||
| Input | primitive | Single-line text input with optional icon |
|
||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
||||
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
||||
| Label | primitive | Form label with optional required asterisk |
|
||||
| LineChart | composite | Time series line visualization |
|
||||
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
||||
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||
| Modal | composite | Generic dialog overlay with backdrop |
|
||||
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
|
||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||
| Pagination | primitive | Page navigation controls |
|
||||
| Popover | composite | Click-triggered floating panel with arrow |
|
||||
| ProcessorTimeline | composite | Pipeline exchange visualization |
|
||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||
| SectionHeader | primitive | Section title with optional action button |
|
||||
| SegmentedTabs | composite | Pill-style segmented tab bar with sliding animated indicator. Same API as Tabs but with elevated active state. Props: tabs, active, onChange, trailing, trailingValue, className |
|
||||
| Select | primitive | Dropdown select input |
|
||||
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
||||
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
||||
| Sparkline | primitive | Inline mini chart for trends |
|
||||
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
|
||||
| Spinner | primitive | Animated loading indicator |
|
||||
| StatCard | primitive | KPI card with value, trend, optional sparkline |
|
||||
| StatusDot | primitive | Colored dot for status indication |
|
||||
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
|
||||
| Tabs | composite | Tabbed content switcher with optional counts |
|
||||
| Tag | primitive | Removable colored label |
|
||||
| Textarea | primitive | Multi-line text input with resize control |
|
||||
@@ -203,8 +236,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
||||
| Sidebar | Hierarchical navigation with Applications/Agents trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
||||
| TopBar | Header bar with breadcrumb, environment, user info |
|
||||
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
||||
|
||||
## Import Paths
|
||||
|
||||
|
||||
111
README.md
Normal file
111
README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Cameleer3 Design System
|
||||
|
||||
A component library and interactive UI prototype for the Cameleer3 monitoring platform. This project contains both the reusable design system (primitives, composites, layout components) and a fully functional mock application demonstrating all pages and interactions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** 20 or later (tested with 22.x) — [https://nodejs.org](https://nodejs.org)
|
||||
- **Git** — [https://git-scm.com](https://git-scm.com)
|
||||
|
||||
No other tools, accounts, or access to external registries are required. All dependencies are published on the public npm registry.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <repo-url> cameleer-design-system
|
||||
cd cameleer-design-system
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Start the development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server will start at **http://localhost:5173** (Vite will print the exact URL).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Start the development server with hot reload |
|
||||
| `npm run build` | Type-check and build the production bundle |
|
||||
| `npm run preview` | Serve the production build locally |
|
||||
| `npm test` | Run the test suite (Vitest, 332 tests) |
|
||||
| `npm run lint` | Run ESLint |
|
||||
|
||||
## Navigating the Prototype
|
||||
|
||||
Once the dev server is running, open **http://localhost:5173** in your browser. The application includes these sections:
|
||||
|
||||
### Sidebar Navigation
|
||||
|
||||
- **Applications** — Exchange monitoring dashboard
|
||||
- `/apps` — All exchanges across all applications
|
||||
- `/apps/:appId` — Filtered by application
|
||||
- `/apps/:appId/:routeId` — Filtered by application and route
|
||||
- Click the ↗ icon on any row to open the full **Exchange Detail** page
|
||||
|
||||
- **Agents** — JVM agent health monitoring
|
||||
- `/agents` — Overview of all agent instances grouped by application
|
||||
- `/agents/:appId` — Single application's agents
|
||||
- `/agents/:appId/:instanceId` — Instance detail with CPU, memory, threads, GC charts
|
||||
|
||||
- **Routes** — Per-route performance metrics
|
||||
- `/routes` — Aggregated KPI cards, route performance table, charts
|
||||
- `/routes/:appId` — Filtered by application
|
||||
- `/routes/:appId/:routeId` — Per-processor statistics and route flow diagram
|
||||
|
||||
- **Admin** — User management (RBAC), OIDC configuration, audit log
|
||||
- `/admin/rbac` — Users, groups, roles with inline editing
|
||||
- `/admin/oidc` — OIDC provider configuration form
|
||||
- `/admin/audit` — Searchable audit log table
|
||||
|
||||
- **Inventory** — Component showcase
|
||||
- `/inventory` — Interactive demos of every design system component
|
||||
|
||||
### Top Bar Controls
|
||||
|
||||
- **Search** (Ctrl+K) — Full-text search across applications, routes, agents, exchanges
|
||||
- **Status Filters** — Toggle OK / Warn / Error / Running to filter exchanges
|
||||
- **Time Range** — Preset time ranges (1h, 3h, 6h, Today, 24h, 7d) or custom date/time picker
|
||||
- **Theme Toggle** (☾/☀) — Switch between light and dark mode
|
||||
|
||||
### Key Interactions
|
||||
|
||||
- **Exchange slide-in panel** — Click any row in the exchanges table to open a detail panel on the right showing overview, errors, route flow, and processor timeline
|
||||
- **Exchange detail page** — Click the ↗ icon or "Open full details" link for the full inspector with Message IN/OUT panels and correlation chain
|
||||
- **Processor selection** — On the exchange detail page, click any processor in the timeline or flow diagram to see its message snapshots
|
||||
- **Starring** — Hover any item in the sidebar trees and click the star to pin it to the Starred section
|
||||
- **Dark mode** — Click the moon/sun icon in the top bar to toggle themes
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
design-system/
|
||||
primitives/ # Atomic components (Button, Input, Badge, StatusDot, ...)
|
||||
composites/ # Composed components (DataTable, Modal, EventFeed, RouteFlow, ...)
|
||||
layout/ # Page-level layout (AppShell, Sidebar, TopBar)
|
||||
providers/ # React context providers (Theme, CommandPalette, GlobalFilter)
|
||||
tokens.css # Design tokens (colors, spacing, typography, shadows)
|
||||
utils/ # Shared utilities (hashColor, timePresets)
|
||||
pages/ # Application pages (Dashboard, Routes, AgentHealth, Admin, ...)
|
||||
mocks/ # Static mock data (exchanges, routes, agents, metrics, sidebar)
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React 19** + **TypeScript**
|
||||
- **Vite** for development and bundling
|
||||
- **CSS Modules** for styling (all colors via design tokens)
|
||||
- **Vitest** + **React Testing Library** for tests
|
||||
- No runtime CSS-in-JS, no Tailwind, no external component libraries
|
||||
|
||||
## Notes
|
||||
|
||||
- All data is static mock data — no backend or API calls required
|
||||
- The prototype is fully self-contained and works offline after `npm install`
|
||||
- Light and dark themes are supported throughout
|
||||
- Fonts (DM Sans, JetBrains Mono) are loaded from Google Fonts and require an internet connection on first load
|
||||
3419
docs/superpowers/plans/2026-03-18-admin-pages.md
Normal file
3419
docs/superpowers/plans/2026-03-18-admin-pages.md
Normal file
File diff suppressed because it is too large
Load Diff
950
docs/superpowers/plans/2026-03-18-admin-redesign.md
Normal file
950
docs/superpowers/plans/2026-03-18-admin-redesign.md
Normal file
@@ -0,0 +1,950 @@
|
||||
# Admin Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Overhaul admin section UX/UI to match the design language of the rest of the application, fix critical bugs, and improve usability.
|
||||
|
||||
**Architecture:** Mostly file edits — replacing custom implementations with design system composites (DataTable, Tabs), fixing tokens, reworking the user creation flow with provider awareness, and adding toast feedback + accessibility.
|
||||
|
||||
**Tech Stack:** React 18, TypeScript, CSS Modules, Vitest + RTL
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-18-admin-redesign.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Modified files
|
||||
```
|
||||
src/pages/Admin/Admin.tsx — Replace custom nav with Tabs, import Tabs
|
||||
src/pages/Admin/Admin.module.css — Remove nav styles, fix padding
|
||||
src/pages/Admin/AuditLog/AuditLog.tsx — Rewrite to use DataTable
|
||||
src/pages/Admin/AuditLog/AuditLog.module.css — Replace with card + filter styles only
|
||||
src/pages/Admin/AuditLog/auditMocks.ts — Change id to string
|
||||
src/pages/Admin/OidcConfig/OidcConfig.tsx — Remove h2 title, add toolbar
|
||||
src/pages/Admin/OidcConfig/OidcConfig.module.css — Remove header styles, center form
|
||||
src/pages/Admin/UserManagement/UserManagement.tsx — Replace inline style
|
||||
src/pages/Admin/UserManagement/UserManagement.module.css — Fix tokens, radii, shadow, add tabContent + empty/security styles
|
||||
src/pages/Admin/UserManagement/UsersTab.tsx — Rework create form, add security section, toasts, accessibility, confirmations
|
||||
src/pages/Admin/UserManagement/GroupsTab.tsx — Add toasts, accessibility, confirmations, empty state
|
||||
src/pages/Admin/UserManagement/RolesTab.tsx — Replace emoji, add toasts, accessibility, empty state
|
||||
src/pages/Admin/UserManagement/rbacMocks.ts — No changes needed (provider field already exists)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix critical token bug + visual polish
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/Admin.module.css`
|
||||
- Modify: `src/pages/Admin/UserManagement/UserManagement.module.css`
|
||||
|
||||
- [ ] **Step 1: Fix `--bg-base` token in Admin.module.css**
|
||||
|
||||
In `Admin.module.css`, replace `var(--bg-base)` with `var(--bg-surface)` on line 6.
|
||||
|
||||
Also fix the content padding on line 35: change `padding: 20px` to `padding: 20px 24px 40px`.
|
||||
|
||||
- [ ] **Step 2: Fix `--bg-base` token and visual polish in UserManagement.module.css**
|
||||
|
||||
Replace both `var(--bg-base)` occurrences (lines 12, 19) with `var(--bg-surface)`.
|
||||
|
||||
Change `border-radius: var(--radius-md)` to `var(--radius-lg)` on `.splitPane` (line 7), `.listPane` (line 15), and `.detailPane` (line 22).
|
||||
|
||||
Add `box-shadow: var(--shadow-card)` to `.splitPane`.
|
||||
|
||||
Add these new classes at the end of the file:
|
||||
|
||||
```css
|
||||
.tabContent {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.emptySearch {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.securitySection {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.securityRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.passwordDots {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.resetForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.resetInput {
|
||||
width: 200px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/Admin.module.css src/pages/Admin/UserManagement/UserManagement.module.css
|
||||
git commit -m "fix: replace nonexistent --bg-base token with --bg-surface, fix radii and padding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Replace admin nav with Tabs composite
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/Admin.tsx`
|
||||
- Modify: `src/pages/Admin/Admin.module.css`
|
||||
|
||||
- [ ] **Step 1: Rewrite Admin.tsx**
|
||||
|
||||
Replace the entire file with:
|
||||
|
||||
```tsx
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import styles from './Admin.module.css'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const ADMIN_TABS = [
|
||||
{ label: 'User Management', value: '/admin/rbac' },
|
||||
{ label: 'Audit Log', value: '/admin/audit' },
|
||||
{ label: 'OIDC', value: '/admin/oidc' },
|
||||
]
|
||||
|
||||
interface AdminLayoutProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdminLayout({ title, children }: AdminLayoutProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Admin', href: '/admin' },
|
||||
{ label: title },
|
||||
]}
|
||||
environment="PRODUCTION"
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
<Tabs
|
||||
tabs={ADMIN_TABS}
|
||||
active={location.pathname}
|
||||
onChange={(path) => navigate(path)}
|
||||
/>
|
||||
<div className={styles.adminContent}>
|
||||
{children}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Clean up Admin.module.css**
|
||||
|
||||
Remove `.adminNav`, `.adminTab`, `.adminTab:hover`, `.adminTabActive` styles entirely. Keep only `.adminContent`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/Admin.tsx src/pages/Admin/Admin.module.css
|
||||
git commit -m "refactor: replace custom admin nav with Tabs composite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fix AuditLog mock data + migrate to DataTable
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/AuditLog/auditMocks.ts`
|
||||
- Modify: `src/pages/Admin/AuditLog/AuditLog.tsx`
|
||||
- Modify: `src/pages/Admin/AuditLog/AuditLog.module.css`
|
||||
|
||||
- [ ] **Step 1: Change AuditEvent id to string in auditMocks.ts**
|
||||
|
||||
Change `id: number` to `id: string` in the `AuditEvent` interface.
|
||||
|
||||
Change all mock IDs from numbers to strings: `id: 1` → `id: 'audit-1'`, `id: 2` → `id: 'audit-2'`, etc. through all 25 events.
|
||||
|
||||
- [ ] **Step 2: Rewrite AuditLog.tsx to use DataTable**
|
||||
|
||||
Replace the entire file with:
|
||||
|
||||
```tsx
|
||||
import { useState, useMemo } from 'react'
|
||||
import { AdminLayout } from '../Admin'
|
||||
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
|
||||
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||
import { Select } from '../../../design-system/primitives/Select/Select'
|
||||
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
|
||||
import { DataTable } from '../../../design-system/composites/DataTable/DataTable'
|
||||
import type { Column } from '../../../design-system/composites/DataTable/types'
|
||||
import type { DateRange } from '../../../design-system/utils/timePresets'
|
||||
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
|
||||
import styles from './AuditLog.module.css'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'All categories' },
|
||||
{ value: 'INFRA', label: 'INFRA' },
|
||||
{ value: 'AUTH', label: 'AUTH' },
|
||||
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
||||
{ value: 'CONFIG', label: 'CONFIG' },
|
||||
]
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-GB', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
const COLUMNS: Column<AuditEvent>[] = [
|
||||
{
|
||||
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
|
||||
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'username', header: 'User', sortable: true,
|
||||
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
|
||||
},
|
||||
{
|
||||
key: 'category', header: 'Category', width: '110px', sortable: true,
|
||||
render: (_, row) => <Badge label={row.category} color="auto" />,
|
||||
},
|
||||
{ key: 'action', header: 'Action' },
|
||||
{
|
||||
key: 'target', header: 'Target',
|
||||
render: (_, row) => <span className={styles.target}>{row.target}</span>,
|
||||
},
|
||||
{
|
||||
key: 'result', header: 'Result', width: '90px', sortable: true,
|
||||
render: (_, row) => (
|
||||
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const now = Date.now()
|
||||
const INITIAL_RANGE: DateRange = {
|
||||
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
|
||||
to: new Date(now).toISOString().slice(0, 16),
|
||||
}
|
||||
|
||||
export function AuditLog() {
|
||||
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
|
||||
const [userFilter, setUserFilter] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const from = new Date(dateRange.from).getTime()
|
||||
const to = new Date(dateRange.to).getTime()
|
||||
return AUDIT_EVENTS.filter((e) => {
|
||||
const ts = new Date(e.timestamp).getTime()
|
||||
if (ts < from || ts > to) return false
|
||||
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
|
||||
if (categoryFilter && e.category !== categoryFilter) return false
|
||||
if (searchFilter) {
|
||||
const q = searchFilter.toLowerCase()
|
||||
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [dateRange, userFilter, categoryFilter, searchFilter])
|
||||
|
||||
return (
|
||||
<AdminLayout title="Audit Log">
|
||||
<div className={styles.filters}>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Filter by user..."
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
onClear={() => setUserFilter('')}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
<Select
|
||||
options={CATEGORIES}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search action or target..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
onClear={() => setSearchFilter('')}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Audit Log</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{filtered.length} events
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
data={filtered}
|
||||
sortable
|
||||
flush
|
||||
pageSize={10}
|
||||
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
|
||||
expandedContent={(row) => (
|
||||
<div className={styles.expandedDetail}>
|
||||
<div className={styles.detailGrid}>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>IP Address</span>
|
||||
<MonoText size="xs">{row.ipAddress}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>User Agent</span>
|
||||
<span className={styles.detailValue}>{row.userAgent}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Detail</span>
|
||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rewrite AuditLog.module.css**
|
||||
|
||||
Replace the entire file with:
|
||||
|
||||
```css
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.target {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expandedDetail {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detailField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `npx vite build 2>&1 | tail -5`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/AuditLog/
|
||||
git commit -m "refactor: migrate AuditLog to DataTable with card wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove duplicate titles from OidcConfig
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/OidcConfig/OidcConfig.tsx`
|
||||
- Modify: `src/pages/Admin/OidcConfig/OidcConfig.module.css`
|
||||
|
||||
- [ ] **Step 1: Replace h2 header with toolbar in OidcConfig.tsx**
|
||||
|
||||
Replace the `.header` div (lines 74-84) with a compact toolbar:
|
||||
|
||||
```tsx
|
||||
<div className={styles.toolbar}>
|
||||
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update OidcConfig.module.css**
|
||||
|
||||
Remove `.header`, `.title`, `.headerActions`. Add:
|
||||
|
||||
```css
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
```
|
||||
|
||||
Also add `margin: 0 auto` to the `.page` class to center the form.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/OidcConfig/
|
||||
git commit -m "refactor: remove duplicate title from OIDC page, center form"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Replace inline style + fix UserManagement
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/UserManagement/UserManagement.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace inline style with CSS class**
|
||||
|
||||
Change line 20 from `<div style={{ marginTop: 16 }}>` to `<div className={styles.tabContent}>`.
|
||||
|
||||
Add the `styles` import if not already present (it already imports from `./UserManagement.module.css` — wait, it doesn't currently. Add:
|
||||
```tsx
|
||||
import styles from './UserManagement.module.css'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/UserManagement/UserManagement.tsx
|
||||
git commit -m "refactor: replace inline style with CSS module class"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add toasts to all RBAC tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
|
||||
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
|
||||
|
||||
- [ ] **Step 1: Add useToast to UsersTab**
|
||||
|
||||
Add import: `import { useToast } from '../../../design-system/composites/Toast/Toast'`
|
||||
|
||||
Add `const { toast } = useToast()` at the top of the `UsersTab` function body.
|
||||
|
||||
Add toast calls:
|
||||
- After `setSelectedId(newUser.id)` in `handleCreate`: `toast({ title: 'User created', description: newUser.displayName, variant: 'success' })`
|
||||
- After `setDeleteTarget(null)` in `handleDelete`: `toast({ title: 'User deleted', description: deleteTarget.username, variant: 'warning' })`
|
||||
|
||||
- [ ] **Step 2: Add useToast to GroupsTab**
|
||||
|
||||
Same pattern. Add toast calls:
|
||||
- After create: `toast({ title: 'Group created', description: newGroup.name, variant: 'success' })`
|
||||
- After delete: `toast({ title: 'Group deleted', description: deleteTarget.name, variant: 'warning' })`
|
||||
|
||||
- [ ] **Step 3: Add useToast to RolesTab**
|
||||
|
||||
Same pattern. Add toast calls:
|
||||
- After create: `toast({ title: 'Role created', description: newRole.name, variant: 'success' })`
|
||||
- After delete: `toast({ title: 'Role deleted', description: deleteTarget.name, variant: 'warning' })`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
|
||||
git commit -m "feat: add toast notifications to all RBAC mutations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Rework user creation form + password management
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||
|
||||
This is the largest single task. It covers spec items 3.2 (provider-aware create form), 3.3 (password management in detail pane), and 3.6 (remove unused password field).
|
||||
|
||||
- [ ] **Step 1: Rework the create form section**
|
||||
|
||||
Replace the create form state variables (lines 23-26):
|
||||
```tsx
|
||||
const [newUsername, setNewUsername] = useState('')
|
||||
const [newDisplay, setNewDisplay] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
```
|
||||
with:
|
||||
```tsx
|
||||
const [newUsername, setNewUsername] = useState('')
|
||||
const [newDisplay, setNewDisplay] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local')
|
||||
```
|
||||
|
||||
Add imports for RadioGroup, RadioItem, and InfoCallout:
|
||||
```tsx
|
||||
import { RadioGroup, RadioItem } from '../../../design-system/primitives/Radio/Radio'
|
||||
import { InfoCallout } from '../../../design-system/primitives/InfoCallout/InfoCallout'
|
||||
```
|
||||
|
||||
Update `handleCreate` to use the provider selection and validate password for local:
|
||||
```tsx
|
||||
function handleCreate() {
|
||||
if (!newUsername.trim()) return
|
||||
if (newProvider === 'local' && !newPassword.trim()) return
|
||||
const newUser: MockUser = {
|
||||
id: `usr-${Date.now()}`,
|
||||
username: newUsername.trim(),
|
||||
displayName: newDisplay.trim() || newUsername.trim(),
|
||||
email: newEmail.trim(),
|
||||
provider: newProvider,
|
||||
createdAt: new Date().toISOString(),
|
||||
directRoles: [],
|
||||
directGroups: [],
|
||||
}
|
||||
setUsers((prev) => [...prev, newUser])
|
||||
setCreating(false)
|
||||
setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword(''); setNewProvider('local')
|
||||
setSelectedId(newUser.id)
|
||||
toast({ title: 'User created', description: newUser.displayName, variant: 'success' })
|
||||
}
|
||||
```
|
||||
|
||||
Replace the create form JSX (lines 100-114) with:
|
||||
```tsx
|
||||
{creating && (
|
||||
<div className={styles.createForm}>
|
||||
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
|
||||
<RadioItem value="local" label="Local" />
|
||||
<RadioItem value="oidc" label="OIDC" />
|
||||
</RadioGroup>
|
||||
<div className={styles.createFormRow}>
|
||||
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
||||
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
||||
</div>
|
||||
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
||||
{newProvider === 'local' && (
|
||||
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
)}
|
||||
{newProvider === 'oidc' && (
|
||||
<InfoCallout variant="amber">
|
||||
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
|
||||
</InfoCallout>
|
||||
)}
|
||||
<div className={styles.createFormActions}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={!newUsername.trim() || (newProvider === 'local' && !newPassword.trim())}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Security section to detail pane**
|
||||
|
||||
Add password reset state at the top of the component:
|
||||
```tsx
|
||||
const [resettingPassword, setResettingPassword] = useState(false)
|
||||
const [newPw, setNewPw] = useState('')
|
||||
```
|
||||
|
||||
Add the Security section after the metadata grid (after the `</div>` closing the `.metaGrid`), before the "Group membership" SectionHeader:
|
||||
|
||||
```tsx
|
||||
<SectionHeader>Security</SectionHeader>
|
||||
<div className={styles.securitySection}>
|
||||
{selected.provider === 'local' ? (
|
||||
<>
|
||||
<div className={styles.securityRow}>
|
||||
<span className={styles.metaLabel}>Password</span>
|
||||
<span className={styles.passwordDots}>••••••••</span>
|
||||
{!resettingPassword && (
|
||||
<Button size="sm" variant="ghost" onClick={() => { setResettingPassword(true); setNewPw('') }}>
|
||||
Reset password
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{resettingPassword && (
|
||||
<div className={styles.resetForm}>
|
||||
<Input
|
||||
placeholder="New password"
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
className={styles.resetInput}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => setResettingPassword(false)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => { setResettingPassword(false); toast({ title: 'Password updated', description: selected.username, variant: 'success' }) }}
|
||||
disabled={!newPw.trim()}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.securityRow}>
|
||||
<span className={styles.metaLabel}>Authentication</span>
|
||||
<span className={styles.metaValue}>OIDC ({selected.provider})</span>
|
||||
</div>
|
||||
<InfoCallout variant="amber">
|
||||
Password managed by the identity provider.
|
||||
</InfoCallout>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `npx vite build 2>&1 | tail -5`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/UserManagement/UsersTab.tsx
|
||||
git commit -m "feat: rework user creation with provider selection, add password management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Keyboard accessibility for entity lists
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
|
||||
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
|
||||
|
||||
- [ ] **Step 1: Add keyboard support to UsersTab entity list**
|
||||
|
||||
On the `.entityList` wrapper div, add:
|
||||
```tsx
|
||||
<div className={styles.entityList} role="listbox" aria-label="Users">
|
||||
```
|
||||
|
||||
On each `.entityItem` div, add `role`, `tabIndex`, `aria-selected`, and `onKeyDown`:
|
||||
```tsx
|
||||
<div
|
||||
key={user.id}
|
||||
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
|
||||
onClick={() => setSelectedId(user.id)}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
aria-selected={selectedId === user.id}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(user.id) } }}
|
||||
>
|
||||
```
|
||||
|
||||
Add empty-search state after the list map:
|
||||
```tsx
|
||||
{filtered.length === 0 && (
|
||||
<div className={styles.emptySearch}>No users match your search</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add keyboard support to GroupsTab entity list**
|
||||
|
||||
Same pattern — add `role="listbox"` to container, `role="option"` + `tabIndex={0}` + `aria-selected` + `onKeyDown` to items, and empty-search state.
|
||||
|
||||
- [ ] **Step 3: Add keyboard support to RolesTab entity list**
|
||||
|
||||
Same pattern. Also replace the lock emoji on line 113:
|
||||
```tsx
|
||||
{role.system && <span title="System role"> 🔒</span>}
|
||||
```
|
||||
with:
|
||||
```tsx
|
||||
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
|
||||
```
|
||||
|
||||
Add empty-search state.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
|
||||
git commit -m "feat: add keyboard accessibility and empty states to entity lists"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Add confirmation for cascading removals
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
|
||||
|
||||
- [ ] **Step 1: Add group removal confirmation in UsersTab**
|
||||
|
||||
Add state for tracking the removal target:
|
||||
```tsx
|
||||
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null)
|
||||
```
|
||||
|
||||
Replace the direct `onRemove` on group Tags (in the "Group membership" section) with:
|
||||
```tsx
|
||||
onRemove={() => {
|
||||
const group = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||
if (group && group.directRoles.length > 0) {
|
||||
setRemoveGroupTarget(gId)
|
||||
} else {
|
||||
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== gId) })
|
||||
toast({ title: 'Group removed', variant: 'success' })
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
Add an AlertDialog (import from composites) for the confirmation:
|
||||
```tsx
|
||||
<AlertDialog
|
||||
open={removeGroupTarget !== null}
|
||||
onClose={() => setRemoveGroupTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (removeGroupTarget && selected) {
|
||||
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== removeGroupTarget) })
|
||||
toast({ title: 'Group removed', variant: 'success' })
|
||||
}
|
||||
setRemoveGroupTarget(null)
|
||||
}}
|
||||
title="Remove group membership"
|
||||
description={`Removing this group will also revoke inherited roles: ${MOCK_GROUPS.find((g) => g.id === removeGroupTarget)?.directRoles.join(', ') ?? ''}. Continue?`}
|
||||
confirmLabel="Remove"
|
||||
variant="warning"
|
||||
/>
|
||||
```
|
||||
|
||||
Add import: `import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'`
|
||||
|
||||
- [ ] **Step 2: Add role removal confirmation in GroupsTab**
|
||||
|
||||
Add state:
|
||||
```tsx
|
||||
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null)
|
||||
```
|
||||
|
||||
Replace direct `onRemove` on role Tags with:
|
||||
```tsx
|
||||
onRemove={() => {
|
||||
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
|
||||
if (memberCount > 0) {
|
||||
setRemoveRoleTarget(r)
|
||||
} else {
|
||||
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
|
||||
toast({ title: 'Role removed', variant: 'success' })
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
Add AlertDialog:
|
||||
```tsx
|
||||
<AlertDialog
|
||||
open={removeRoleTarget !== null}
|
||||
onClose={() => setRemoveRoleTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (removeRoleTarget && selected) {
|
||||
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== removeRoleTarget) })
|
||||
toast({ title: 'Role removed', variant: 'success' })
|
||||
}
|
||||
setRemoveRoleTarget(null)
|
||||
}}
|
||||
title="Remove role from group"
|
||||
description={`Removing ${removeRoleTarget} from ${selected?.name} will affect ${members.length} member(s) who inherit this role. Continue?`}
|
||||
confirmLabel="Remove"
|
||||
variant="warning"
|
||||
/>
|
||||
```
|
||||
|
||||
Add import: `import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx
|
||||
git commit -m "feat: add confirmation dialogs for cascading removals"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Add duplicate name validation to create forms
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
|
||||
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
|
||||
|
||||
- [ ] **Step 1: Add duplicate check in UsersTab**
|
||||
|
||||
Add a computed `duplicateUsername`:
|
||||
```tsx
|
||||
const duplicateUsername = newUsername.trim() && users.some((u) => u.username.toLowerCase() === newUsername.trim().toLowerCase())
|
||||
```
|
||||
|
||||
Update the Create button `disabled` to include `|| duplicateUsername`.
|
||||
|
||||
Show error text below the username Input when duplicate:
|
||||
```tsx
|
||||
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add duplicate check in GroupsTab**
|
||||
|
||||
Similar pattern with `duplicateGroupName` check. Disable Create button when duplicate.
|
||||
|
||||
- [ ] **Step 3: Add duplicate check in RolesTab**
|
||||
|
||||
Similar pattern with `duplicateRoleName` check (compare uppercase). Disable Create button when duplicate.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
|
||||
git commit -m "feat: add duplicate name validation to create forms"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Final verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Build the project**
|
||||
|
||||
Run: `npx vite build`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Fix any issues**
|
||||
|
||||
If build fails, fix TypeScript errors. Common issues:
|
||||
- Import path typos
|
||||
- Missing props on components
|
||||
- InfoCallout `variant` prop — check the actual prop name (may be `color` instead)
|
||||
|
||||
- [ ] **Step 4: Commit fixes if needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve build issues from admin redesign"
|
||||
```
|
||||
573
docs/superpowers/plans/2026-03-24-admin-components.md
Normal file
573
docs/superpowers/plans/2026-03-24-admin-components.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# Admin Components Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add SplitPane and EntityList composites to provide reusable master/detail layout and searchable entity list patterns, replacing ~150 lines of duplicated CSS and structure across admin RBAC tabs.
|
||||
|
||||
**Architecture:** SplitPane is a layout-only component providing a two-column grid with configurable ratio. EntityList provides a searchable, selectable list with render props for item content. They compose together naturally: EntityList slots into SplitPane's list panel.
|
||||
|
||||
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 2, 2b)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/design-system/composites/SplitPane/SplitPane.tsx` | Create | Two-column grid layout with list/detail slots and empty state |
|
||||
| `src/design-system/composites/SplitPane/SplitPane.module.css` | Create | Grid layout, scrollable panels, empty state styling |
|
||||
| `src/design-system/composites/SplitPane/SplitPane.test.tsx` | Create | 5 test cases for SplitPane |
|
||||
| `src/design-system/composites/EntityList/EntityList.tsx` | Create | Generic searchable, selectable list with render props |
|
||||
| `src/design-system/composites/EntityList/EntityList.module.css` | Create | Header, scrollable list, item hover/selected states |
|
||||
| `src/design-system/composites/EntityList/EntityList.test.tsx` | Create | 11 test cases for EntityList |
|
||||
| `src/design-system/composites/index.ts` | Modify | Add SplitPane and EntityList exports |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SplitPane composite
|
||||
|
||||
**Files:**
|
||||
- Create: `src/design-system/composites/SplitPane/SplitPane.tsx`
|
||||
- Create: `src/design-system/composites/SplitPane/SplitPane.module.css`
|
||||
- Create: `src/design-system/composites/SplitPane/SplitPane.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write SplitPane tests**
|
||||
|
||||
Create `src/design-system/composites/SplitPane/SplitPane.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { SplitPane } from './SplitPane'
|
||||
|
||||
describe('SplitPane', () => {
|
||||
it('renders list and detail content', () => {
|
||||
render(
|
||||
<SplitPane
|
||||
list={<div>User list</div>}
|
||||
detail={<div>User detail</div>}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('User list')).toBeInTheDocument()
|
||||
expect(screen.getByText('User detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows default empty message when detail is null', () => {
|
||||
render(
|
||||
<SplitPane
|
||||
list={<div>User list</div>}
|
||||
detail={null}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows custom empty message when detail is null', () => {
|
||||
render(
|
||||
<SplitPane
|
||||
list={<div>User list</div>}
|
||||
detail={null}
|
||||
emptyMessage="Pick a user to see info"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Pick a user to see info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with different ratios', () => {
|
||||
const { container, rerender } = render(
|
||||
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="1:1" />,
|
||||
)
|
||||
const pane = container.firstChild as HTMLElement
|
||||
expect(pane.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
|
||||
|
||||
rerender(
|
||||
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="2:3" />,
|
||||
)
|
||||
expect(pane.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
|
||||
})
|
||||
|
||||
it('accepts className', () => {
|
||||
const { container } = render(
|
||||
<SplitPane
|
||||
list={<div>List</div>}
|
||||
detail={<div>Detail</div>}
|
||||
className="custom"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Create SplitPane CSS module**
|
||||
|
||||
Create `src/design-system/composites/SplitPane/SplitPane.module.css`:
|
||||
|
||||
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.splitPane`, `.listPane`, `.detailPane`, `.emptyDetail`), generalized with a CSS custom property for the column ratio.
|
||||
|
||||
```css
|
||||
.splitPane {
|
||||
display: grid;
|
||||
grid-template-columns: var(--split-columns, 1fr 2fr);
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.listPane {
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detailPane {
|
||||
background: var(--bg-raised);
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
}
|
||||
|
||||
.emptyDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
font-style: italic;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create SplitPane component**
|
||||
|
||||
Create `src/design-system/composites/SplitPane/SplitPane.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './SplitPane.module.css'
|
||||
|
||||
interface SplitPaneProps {
|
||||
list: ReactNode
|
||||
detail: ReactNode | null
|
||||
emptyMessage?: string
|
||||
ratio?: '1:1' | '1:2' | '2:3'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ratioMap: Record<string, string> = {
|
||||
'1:1': '1fr 1fr',
|
||||
'1:2': '1fr 2fr',
|
||||
'2:3': '2fr 3fr',
|
||||
}
|
||||
|
||||
export function SplitPane({
|
||||
list,
|
||||
detail,
|
||||
emptyMessage = 'Select an item to view details',
|
||||
ratio = '1:2',
|
||||
className,
|
||||
}: SplitPaneProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.splitPane} ${className ?? ''}`}
|
||||
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
|
||||
>
|
||||
<div className={styles.listPane}>{list}</div>
|
||||
<div className={styles.detailPane}>
|
||||
{detail !== null ? detail : (
|
||||
<div className={styles.emptyDetail}>{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
|
||||
Expected: 5 tests PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/design-system/composites/SplitPane/SplitPane.tsx \
|
||||
src/design-system/composites/SplitPane/SplitPane.module.css \
|
||||
src/design-system/composites/SplitPane/SplitPane.test.tsx
|
||||
git commit -m "feat: add SplitPane composite for master/detail layouts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: EntityList composite
|
||||
|
||||
**Files:**
|
||||
- Create: `src/design-system/composites/EntityList/EntityList.tsx`
|
||||
- Create: `src/design-system/composites/EntityList/EntityList.module.css`
|
||||
- Create: `src/design-system/composites/EntityList/EntityList.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write EntityList tests**
|
||||
|
||||
Create `src/design-system/composites/EntityList/EntityList.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { EntityList } from './EntityList'
|
||||
|
||||
interface TestItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const items: TestItem[] = [
|
||||
{ id: '1', name: 'Alice' },
|
||||
{ id: '2', name: 'Bob' },
|
||||
{ id: '3', name: 'Charlie' },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
items,
|
||||
renderItem: (item: TestItem) => <span>{item.name}</span>,
|
||||
getItemId: (item: TestItem) => item.id,
|
||||
}
|
||||
|
||||
describe('EntityList', () => {
|
||||
it('renders all items', () => {
|
||||
render(<EntityList {...defaultProps} />)
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charlie')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect when item clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<EntityList {...defaultProps} onSelect={onSelect} />)
|
||||
await user.click(screen.getByText('Bob'))
|
||||
expect(onSelect).toHaveBeenCalledWith('2')
|
||||
})
|
||||
|
||||
it('highlights selected item', () => {
|
||||
render(<EntityList {...defaultProps} selectedId="2" />)
|
||||
const selectedOption = screen.getByText('Bob').closest('[role="option"]')
|
||||
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||
expect(selectedOption).toHaveClass(/selected/i)
|
||||
})
|
||||
|
||||
it('renders search input when onSearch provided', () => {
|
||||
render(<EntityList {...defaultProps} onSearch={vi.fn()} searchPlaceholder="Search users..." />)
|
||||
expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSearch when typing in search', async () => {
|
||||
const onSearch = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<EntityList {...defaultProps} onSearch={onSearch} />)
|
||||
await user.type(screen.getByPlaceholderText('Search...'), 'alice')
|
||||
expect(onSearch).toHaveBeenLastCalledWith('alice')
|
||||
})
|
||||
|
||||
it('renders add button when onAdd provided', () => {
|
||||
render(<EntityList {...defaultProps} onAdd={vi.fn()} addLabel="+ Add user" />)
|
||||
expect(screen.getByRole('button', { name: '+ Add user' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onAdd when add button clicked', async () => {
|
||||
const onAdd = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<EntityList {...defaultProps} onAdd={onAdd} addLabel="+ Add user" />)
|
||||
await user.click(screen.getByRole('button', { name: '+ Add user' }))
|
||||
expect(onAdd).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides header when no search or add', () => {
|
||||
const { container } = render(<EntityList {...defaultProps} />)
|
||||
// No header element should be rendered (no search input, no add button)
|
||||
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[class*="listHeader"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty message when items is empty', () => {
|
||||
render(
|
||||
<EntityList
|
||||
items={[]}
|
||||
renderItem={() => <span />}
|
||||
getItemId={() => ''}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No items found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows custom empty message', () => {
|
||||
render(
|
||||
<EntityList
|
||||
items={[]}
|
||||
renderItem={() => <span />}
|
||||
getItemId={() => ''}
|
||||
emptyMessage="No users match your search"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No users match your search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts className', () => {
|
||||
const { container } = render(<EntityList {...defaultProps} className="custom" />)
|
||||
expect(container.firstChild).toHaveClass('custom')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Create EntityList CSS module**
|
||||
|
||||
Create `src/design-system/composites/EntityList/EntityList.module.css`:
|
||||
|
||||
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.listHeader`, `.listHeaderSearch`, `.entityList`, `.entityItem`, `.entityItemSelected`), generalized for reuse.
|
||||
|
||||
```css
|
||||
.entityListRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.listHeaderSearch {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entityItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.entityItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.entityItemSelected {
|
||||
background: var(--amber-bg);
|
||||
border-left: 3px solid var(--amber);
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create EntityList component**
|
||||
|
||||
Create `src/design-system/composites/EntityList/EntityList.tsx`:
|
||||
|
||||
The component uses `role="listbox"` / `role="option"` for accessibility, matching the pattern in `UsersTab.tsx`. It delegates search input and add button to the existing `Input` and `Button` primitives.
|
||||
|
||||
```tsx
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Input } from '../../primitives/Input/Input'
|
||||
import { Button } from '../../primitives/Button/Button'
|
||||
import styles from './EntityList.module.css'
|
||||
|
||||
interface EntityListProps<T> {
|
||||
items: T[]
|
||||
renderItem: (item: T, isSelected: boolean) => ReactNode
|
||||
getItemId: (item: T) => string
|
||||
selectedId?: string
|
||||
onSelect?: (id: string) => void
|
||||
searchPlaceholder?: string
|
||||
onSearch?: (query: string) => void
|
||||
addLabel?: string
|
||||
onAdd?: () => void
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EntityList<T>({
|
||||
items,
|
||||
renderItem,
|
||||
getItemId,
|
||||
selectedId,
|
||||
onSelect,
|
||||
searchPlaceholder = 'Search...',
|
||||
onSearch,
|
||||
addLabel,
|
||||
onAdd,
|
||||
emptyMessage = 'No items found',
|
||||
className,
|
||||
}: EntityListProps<T>) {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const showHeader = !!onSearch || !!onAdd
|
||||
|
||||
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = e.target.value
|
||||
setSearchValue(value)
|
||||
onSearch?.(value)
|
||||
}
|
||||
|
||||
function handleSearchClear() {
|
||||
setSearchValue('')
|
||||
onSearch?.('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
|
||||
{showHeader && (
|
||||
<div className={styles.listHeader}>
|
||||
{onSearch && (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onClear={handleSearchClear}
|
||||
className={styles.listHeaderSearch}
|
||||
/>
|
||||
)}
|
||||
{onAdd && addLabel && (
|
||||
<Button size="sm" variant="secondary" onClick={onAdd}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list} role="listbox">
|
||||
{items.map((item) => {
|
||||
const id = getItemId(item)
|
||||
const isSelected = id === selectedId
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||
onClick={() => onSelect?.(id)}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect?.(id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderItem(item, isSelected)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<div className={styles.emptyMessage}>{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
|
||||
Expected: 11 tests PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/design-system/composites/EntityList/EntityList.tsx \
|
||||
src/design-system/composites/EntityList/EntityList.module.css \
|
||||
src/design-system/composites/EntityList/EntityList.test.tsx
|
||||
git commit -m "feat: add EntityList composite for searchable, selectable lists"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Barrel exports & full test suite
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/design-system/composites/index.ts`
|
||||
|
||||
- [ ] **Step 1: Add exports to barrel**
|
||||
|
||||
Add these lines to `src/design-system/composites/index.ts` in alphabetical position.
|
||||
|
||||
After the `DetailPanel` export (line 13), add:
|
||||
|
||||
```ts
|
||||
export { EntityList } from './EntityList/EntityList'
|
||||
```
|
||||
|
||||
After the `LineChart` export (line 19), before `LoginDialog`, add:
|
||||
|
||||
```ts
|
||||
// (no change needed here — LoginDialog is already present)
|
||||
```
|
||||
|
||||
After the `ShortcutsBar` export (line 33), before `SegmentedTabs`, add:
|
||||
|
||||
```ts
|
||||
export { SplitPane } from './SplitPane/SplitPane'
|
||||
```
|
||||
|
||||
The resulting new lines in `index.ts` (in their alphabetical positions):
|
||||
|
||||
```ts
|
||||
export { EntityList } from './EntityList/EntityList'
|
||||
```
|
||||
|
||||
```ts
|
||||
export { SplitPane } from './SplitPane/SplitPane'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full component test suite**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/SplitPane/ src/design-system/composites/EntityList/`
|
||||
Expected: All 16 tests PASS (5 SplitPane + 11 EntityList)
|
||||
|
||||
- [ ] **Step 3: Run the full project test suite to check for regressions**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/design-system/composites/index.ts
|
||||
git commit -m "feat: export SplitPane and EntityList from composites barrel"
|
||||
```
|
||||
431
docs/superpowers/plans/2026-03-24-documentation-updates.md
Normal file
431
docs/superpowers/plans/2026-03-24-documentation-updates.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# Documentation Updates Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Update COMPONENT_GUIDE.md and Inventory page with entries and demos for all new components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText, and Card title extension.
|
||||
|
||||
**Architecture:** COMPONENT_GUIDE.md gets new decision tree entries and component index rows. Inventory page gets DemoCard sections with realistic sample data for each new component.
|
||||
|
||||
**Tech Stack:** React, TypeScript, CSS Modules
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Documentation Updates section)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Update COMPONENT_GUIDE.md
|
||||
|
||||
**File:** `COMPONENT_GUIDE.md`
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **1a.** In the `"I need to show status"` decision tree (line ~34), add StatusText entry after StatusDot:
|
||||
|
||||
```markdown
|
||||
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
|
||||
```
|
||||
|
||||
- [ ] **1b.** In the `"I need to display data"` decision tree (line ~51), add three entries after the EventFeed line:
|
||||
|
||||
```markdown
|
||||
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
|
||||
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
|
||||
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
|
||||
```
|
||||
|
||||
- [ ] **1c.** In the `"I need to organize content"` decision tree (line ~62), add SplitPane entry after DetailPanel and update the Card entry:
|
||||
|
||||
After the `- Side panel inspector → **DetailPanel**` line, add:
|
||||
```markdown
|
||||
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
|
||||
```
|
||||
|
||||
Update the existing Card line from:
|
||||
```markdown
|
||||
- Grouped content box → **Card** (with optional accent)
|
||||
```
|
||||
to:
|
||||
```markdown
|
||||
- Grouped content box → **Card** (with optional accent and title)
|
||||
```
|
||||
|
||||
- [ ] **1d.** In the `"I need to display text"` decision tree (line ~72), add StatusText cross-reference:
|
||||
|
||||
```markdown
|
||||
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
|
||||
```
|
||||
|
||||
- [ ] **1e.** Add a new composition pattern after the existing "KPI dashboard" pattern (line ~113):
|
||||
|
||||
```markdown
|
||||
### Master/detail management pattern
|
||||
```
|
||||
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
|
||||
EntityList provides: search header, add button, selectable list
|
||||
SplitPane provides: responsive two-column layout with empty state
|
||||
```
|
||||
```
|
||||
|
||||
- [ ] **1f.** Add five new rows to the Component Index table (maintaining alphabetical order):
|
||||
|
||||
After the `EventFeed` row:
|
||||
```markdown
|
||||
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
|
||||
```
|
||||
|
||||
After the `KeyboardHint` row:
|
||||
```markdown
|
||||
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
||||
```
|
||||
|
||||
After the `LineChart` row:
|
||||
```markdown
|
||||
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
||||
```
|
||||
|
||||
After the `Sparkline` row:
|
||||
```markdown
|
||||
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
|
||||
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
|
||||
```
|
||||
|
||||
- [ ] **1g.** Update the existing `Card` row in the Component Index from:
|
||||
|
||||
```markdown
|
||||
| Card | primitive | Content container with optional accent border |
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```markdown
|
||||
| Card | primitive | Content container with optional accent border and title header |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add StatusText demo to PrimitivesSection
|
||||
|
||||
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **2a.** Add `StatusText` to the import from `'../../../design-system/primitives'` (insert alphabetically after `StatCard`):
|
||||
|
||||
```tsx
|
||||
StatusText,
|
||||
```
|
||||
|
||||
- [ ] **2b.** Add a new DemoCard after the StatusDot demo (after line ~560, before the Tag demo). Insert this block:
|
||||
|
||||
```tsx
|
||||
{/* 29. StatusText */}
|
||||
<DemoCard
|
||||
id="statustext"
|
||||
title="StatusText"
|
||||
description="Inline coloured text for status values — five semantic variants with optional bold."
|
||||
>
|
||||
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
|
||||
<div className={styles.demoAreaRow}>
|
||||
<StatusText variant="success">99.8% uptime</StatusText>
|
||||
<StatusText variant="warning">SLA at risk</StatusText>
|
||||
<StatusText variant="error">BREACH</StatusText>
|
||||
<StatusText variant="running">Processing</StatusText>
|
||||
<StatusText variant="muted">N/A</StatusText>
|
||||
</div>
|
||||
<div className={styles.demoAreaRow}>
|
||||
<StatusText variant="success" bold>99.8% uptime</StatusText>
|
||||
<StatusText variant="warning" bold>SLA at risk</StatusText>
|
||||
<StatusText variant="error" bold>BREACH</StatusText>
|
||||
<StatusText variant="running" bold>Processing</StatusText>
|
||||
<StatusText variant="muted" bold>N/A</StatusText>
|
||||
</div>
|
||||
</div>
|
||||
</DemoCard>
|
||||
```
|
||||
|
||||
Note: Renumber subsequent demos (Tag becomes 30, Textarea becomes 31, Toggle becomes 32, Tooltip becomes 33).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Card demo in PrimitivesSection
|
||||
|
||||
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **3a.** Update the Card DemoCard description from:
|
||||
|
||||
```tsx
|
||||
description="Surface container with optional left-border accent colour."
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
description="Surface container with optional left-border accent colour and title header."
|
||||
```
|
||||
|
||||
- [ ] **3b.** Add a title prop example to the Card demo. After the existing `Card accent="error"` line (~212), add:
|
||||
|
||||
```tsx
|
||||
<Card title="Throughput (msg/s)">
|
||||
<div style={{ padding: '8px 12px', fontSize: 13 }}>Card with title header and separator</div>
|
||||
</Card>
|
||||
<Card accent="amber" title="Error Rate">
|
||||
<div style={{ padding: '8px 12px', fontSize: 13 }}>Title + accent combined</div>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add composite demos to CompositesSection
|
||||
|
||||
**File:** `src/pages/Inventory/sections/CompositesSection.tsx`
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **4a.** Add new imports. Add `KpiStrip`, `SplitPane`, `EntityList`, `LogViewer` to the import from `'../../../design-system/composites'` (insert alphabetically):
|
||||
|
||||
```tsx
|
||||
EntityList,
|
||||
KpiStrip,
|
||||
LogViewer,
|
||||
SplitPane,
|
||||
```
|
||||
|
||||
Also add `Badge` and `Avatar` to the import from `'../../../design-system/primitives'` (needed for EntityList demo renderItem):
|
||||
|
||||
```tsx
|
||||
import { Avatar, Badge, Button } from '../../../design-system/primitives'
|
||||
```
|
||||
|
||||
- [ ] **4b.** Add sample data constants after the existing sample data section (before the `CompositesSection` function). Add:
|
||||
|
||||
```tsx
|
||||
// ── Sample data for new composites ───────────────────────────────────────────
|
||||
|
||||
const KPI_ITEMS = [
|
||||
{
|
||||
label: 'Exchanges',
|
||||
value: '12,847',
|
||||
trend: { label: '↑ +8.2%', variant: 'success' as const },
|
||||
subtitle: 'Last 24h',
|
||||
sparkline: [40, 55, 48, 62, 70, 65, 78],
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Error Rate',
|
||||
value: '0.34%',
|
||||
trend: { label: '↑ +0.12pp', variant: 'error' as const },
|
||||
subtitle: 'Above threshold',
|
||||
sparkline: [10, 12, 11, 15, 18, 22, 19],
|
||||
borderColor: 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'Avg Latency',
|
||||
value: '142ms',
|
||||
trend: { label: '↓ -12ms', variant: 'success' as const },
|
||||
subtitle: 'P95: 380ms',
|
||||
borderColor: 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'Active Routes',
|
||||
value: '37',
|
||||
trend: { label: '±0', variant: 'muted' as const },
|
||||
subtitle: '3 paused',
|
||||
borderColor: 'var(--running)',
|
||||
},
|
||||
]
|
||||
|
||||
const ENTITY_LIST_ITEMS = [
|
||||
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
|
||||
{ id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'Editor' },
|
||||
{ id: '3', name: 'Carol Smith', email: 'carol@example.com', role: 'Viewer' },
|
||||
{ id: '4', name: 'David Park', email: 'david@example.com', role: 'Editor' },
|
||||
{ id: '5', name: 'Eva Martinez', email: 'eva@example.com', role: 'Admin' },
|
||||
]
|
||||
|
||||
const LOG_ENTRIES = [
|
||||
{ timestamp: '2026-03-24T10:00:01Z', level: 'info' as const, message: 'Route timer-aggregator started successfully' },
|
||||
{ timestamp: '2026-03-24T10:00:03Z', level: 'debug' as const, message: 'Polling endpoint https://api.internal/health — 200 OK' },
|
||||
{ timestamp: '2026-03-24T10:00:15Z', level: 'warn' as const, message: 'Retry queue depth at 847 — approaching threshold (1000)' },
|
||||
{ timestamp: '2026-03-24T10:00:22Z', level: 'error' as const, message: 'Exchange failed: Connection refused to jdbc:postgresql://db-primary:5432/orders' },
|
||||
{ timestamp: '2026-03-24T10:00:23Z', level: 'info' as const, message: 'Failover activated — routing to db-secondary' },
|
||||
{ timestamp: '2026-03-24T10:00:30Z', level: 'info' as const, message: 'Exchange completed in 142ms via fallback route' },
|
||||
{ timestamp: '2026-03-24T10:00:45Z', level: 'debug' as const, message: 'Metrics flush: 328 data points written to InfluxDB' },
|
||||
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn' as const, message: 'Memory usage at 78% — GC scheduled' },
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **4c.** Add state variables inside the `CompositesSection` function for EntityList demo:
|
||||
|
||||
```tsx
|
||||
// EntityList state
|
||||
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>('1')
|
||||
const [entitySearch, setEntitySearch] = useState('')
|
||||
```
|
||||
|
||||
- [ ] **4d.** Add KpiStrip demo after the existing GroupCard demo. Insert a new DemoCard:
|
||||
|
||||
```tsx
|
||||
{/* KpiStrip */}
|
||||
<DemoCard
|
||||
id="kpistrip"
|
||||
title="KpiStrip"
|
||||
description="Horizontal row of KPI cards with coloured left border, trend indicator, subtitle, and optional sparkline."
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<KpiStrip items={KPI_ITEMS} />
|
||||
</div>
|
||||
</DemoCard>
|
||||
```
|
||||
|
||||
- [ ] **4e.** Add SplitPane demo:
|
||||
|
||||
```tsx
|
||||
{/* SplitPane */}
|
||||
<DemoCard
|
||||
id="splitpane"
|
||||
title="SplitPane"
|
||||
description="Two-column master/detail layout with configurable ratio and empty-state placeholder."
|
||||
>
|
||||
<div style={{ width: '100%', height: 200 }}>
|
||||
<SplitPane
|
||||
list={
|
||||
<div style={{ padding: 16, fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Items</div>
|
||||
<div>Item A</div>
|
||||
<div>Item B</div>
|
||||
<div>Item C</div>
|
||||
</div>
|
||||
}
|
||||
detail={
|
||||
<div style={{ padding: 16, fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Detail View</div>
|
||||
<div>Select an item on the left to see its details here.</div>
|
||||
</div>
|
||||
}
|
||||
ratio="1:2"
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
```
|
||||
|
||||
- [ ] **4f.** Add EntityList demo:
|
||||
|
||||
```tsx
|
||||
{/* EntityList */}
|
||||
<DemoCard
|
||||
id="entitylist"
|
||||
title="EntityList"
|
||||
description="Searchable, selectable entity list with add button — designed to pair with SplitPane."
|
||||
>
|
||||
<div style={{ width: '100%', height: 260 }}>
|
||||
<EntityList
|
||||
items={ENTITY_LIST_ITEMS.filter(u =>
|
||||
u.name.toLowerCase().includes(entitySearch.toLowerCase())
|
||||
)}
|
||||
renderItem={(item, isSelected) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Avatar name={item.name} size="sm" />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: isSelected ? 600 : 400 }}>{item.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.email}</div>
|
||||
</div>
|
||||
<Badge label={item.role} style={{ marginLeft: 'auto' }} />
|
||||
</div>
|
||||
)}
|
||||
getItemId={(item) => item.id}
|
||||
selectedId={selectedEntityId}
|
||||
onSelect={setSelectedEntityId}
|
||||
searchPlaceholder="Search users..."
|
||||
onSearch={setEntitySearch}
|
||||
addLabel="+ Add user"
|
||||
onAdd={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
```
|
||||
|
||||
- [ ] **4g.** Add LogViewer demo:
|
||||
|
||||
```tsx
|
||||
{/* LogViewer */}
|
||||
<DemoCard
|
||||
id="logviewer"
|
||||
title="LogViewer"
|
||||
description="Scrollable log output with timestamped, severity-coloured monospace entries and auto-scroll."
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<LogViewer entries={LOG_ENTRIES} maxHeight={240} />
|
||||
</div>
|
||||
</DemoCard>
|
||||
```
|
||||
|
||||
- [ ] **4h.** Verify all four new DemoCards are placed in alphabetical order among existing demos — EntityList after EventFeed, KpiStrip after GroupCard, LogViewer after LoginForm, SplitPane after ShortcutsBar. Adjust comment numbering accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update Inventory nav
|
||||
|
||||
**File:** `src/pages/Inventory/Inventory.tsx`
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **5a.** Add `StatusText` to the Primitives nav components array (insert alphabetically after `StatusDot`):
|
||||
|
||||
```tsx
|
||||
{ label: 'StatusText', href: '#statustext' },
|
||||
```
|
||||
|
||||
- [ ] **5b.** Add four entries to the Composites nav components array (insert alphabetically):
|
||||
|
||||
After `EventFeed`:
|
||||
```tsx
|
||||
{ label: 'EntityList', href: '#entitylist' },
|
||||
```
|
||||
|
||||
After `GroupCard`:
|
||||
```tsx
|
||||
{ label: 'KpiStrip', href: '#kpistrip' },
|
||||
```
|
||||
|
||||
After `LoginForm`:
|
||||
```tsx
|
||||
{ label: 'LogViewer', href: '#logviewer' },
|
||||
```
|
||||
|
||||
After `ShortcutsBar`:
|
||||
```tsx
|
||||
{ label: 'SplitPane', href: '#splitpane' },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Commit all documentation
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **6a.** Run `npx vitest run src/pages/Inventory` to verify Inventory page has no import/type errors (if tests exist for it).
|
||||
- [ ] **6b.** Stage changed files:
|
||||
- `COMPONENT_GUIDE.md`
|
||||
- `src/pages/Inventory/Inventory.tsx`
|
||||
- `src/pages/Inventory/sections/PrimitivesSection.tsx`
|
||||
- `src/pages/Inventory/sections/CompositesSection.tsx`
|
||||
- [ ] **6c.** Commit with message: `docs: add COMPONENT_GUIDE entries and Inventory demos for KpiStrip, SplitPane, EntityList, LogViewer, StatusText, Card title`
|
||||
|
||||
---
|
||||
|
||||
## Dependency Notes
|
||||
|
||||
- **Tasks 1-5 are independent** and can be worked in any order.
|
||||
- **Task 6 depends on Tasks 1-5** being complete.
|
||||
- **All tasks depend on the components already existing** — StatusText, Card title extension, KpiStrip, SplitPane, EntityList, and LogViewer must be built and exported from their barrel files before the Inventory demos will compile.
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `COMPONENT_GUIDE.md` | Decision tree entries + component index rows |
|
||||
| `src/pages/Inventory/Inventory.tsx` | 5 new nav entries (1 primitive + 4 composites) |
|
||||
| `src/pages/Inventory/sections/PrimitivesSection.tsx` | StatusText demo + Card title demo update |
|
||||
| `src/pages/Inventory/sections/CompositesSection.tsx` | KpiStrip, SplitPane, EntityList, LogViewer demos with sample data |
|
||||
770
docs/superpowers/plans/2026-03-24-login-dialog.md
Normal file
770
docs/superpowers/plans/2026-03-24-login-dialog.md
Normal file
@@ -0,0 +1,770 @@
|
||||
# Login Dialog Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add composable `LoginForm` and `LoginDialog` components to the Cameleer3 design system with credential + social login support, client-side validation, and full dark mode compatibility.
|
||||
|
||||
**Architecture:** `LoginForm` is the core content component with all form logic, validation, and layout. `LoginDialog` is a thin wrapper that renders `LoginForm` inside `Modal size="sm"`. Both live in `src/design-system/composites/LoginForm/` and are exported from the composites barrel.
|
||||
|
||||
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-24-login-dialog-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/design-system/composites/LoginForm/LoginForm.tsx` | Create | Core form component with validation, social providers, all layout |
|
||||
| `src/design-system/composites/LoginForm/LoginForm.module.css` | Create | All styles using design tokens |
|
||||
| `src/design-system/composites/LoginForm/LoginForm.test.tsx` | Create | 21 test cases for LoginForm |
|
||||
| `src/design-system/composites/LoginForm/LoginDialog.tsx` | Create | Thin Modal wrapper |
|
||||
| `src/design-system/composites/LoginForm/LoginDialog.test.tsx` | Create | 5 test cases for LoginDialog |
|
||||
| `src/design-system/composites/index.ts` | Modify | Add LoginForm, LoginDialog, and type exports |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: LoginForm — Rendering Tests & Basic Structure
|
||||
|
||||
**Files:**
|
||||
- Create: `src/design-system/composites/LoginForm/LoginForm.tsx`
|
||||
- Create: `src/design-system/composites/LoginForm/LoginForm.module.css`
|
||||
- Create: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write rendering tests**
|
||||
|
||||
Create `src/design-system/composites/LoginForm/LoginForm.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { LoginForm } from './LoginForm'
|
||||
|
||||
const socialProviders = [
|
||||
{ label: 'Continue with Google', onClick: vi.fn() },
|
||||
{ label: 'Continue with GitHub', onClick: vi.fn() },
|
||||
]
|
||||
|
||||
const allProps = {
|
||||
logo: <div data-testid="logo">Logo</div>,
|
||||
title: 'Welcome back',
|
||||
socialProviders,
|
||||
onSubmit: vi.fn(),
|
||||
onForgotPassword: vi.fn(),
|
||||
onSignUp: vi.fn(),
|
||||
}
|
||||
|
||||
describe('LoginForm', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all elements when all props provided', () => {
|
||||
render(<LoginForm {...allProps} />)
|
||||
expect(screen.getByTestId('logo')).toBeInTheDocument()
|
||||
expect(screen.getByText('Welcome back')).toBeInTheDocument()
|
||||
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders default title when title prop omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Sign in')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides social section when socialProviders is empty', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides social section when socialProviders is omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides forgot password link when onForgotPassword omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides sign up link when onSignUp omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides credentials section when onSubmit omitted (social only)', () => {
|
||||
render(<LoginForm socialProviders={socialProviders} />)
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
// Social buttons should still render
|
||||
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows server error Alert when error prop set', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Create LoginForm component with basic rendering**
|
||||
|
||||
Create `src/design-system/composites/LoginForm/LoginForm.module.css`:
|
||||
|
||||
```css
|
||||
.loginForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: var(--font-body);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.error {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.socialSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.socialButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dividerLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.dividerText {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rememberRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.forgotLink {
|
||||
font-size: 11px;
|
||||
color: var(--amber);
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.forgotLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signUpText {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.signUpLink {
|
||||
color: var(--amber);
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signUpLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/design-system/composites/LoginForm/LoginForm.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
|
||||
import { Button } from '../../primitives/Button/Button'
|
||||
import { Input } from '../../primitives/Input/Input'
|
||||
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
|
||||
import { FormField } from '../../primitives/FormField/FormField'
|
||||
import { Alert } from '../../primitives/Alert/Alert'
|
||||
import styles from './LoginForm.module.css'
|
||||
|
||||
export interface SocialProvider {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface LoginFormProps {
|
||||
logo?: ReactNode
|
||||
title?: string
|
||||
socialProviders?: SocialProvider[]
|
||||
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
|
||||
onForgotPassword?: () => void
|
||||
onSignUp?: () => void
|
||||
error?: string
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface FieldErrors {
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function validate(email: string, password: string): FieldErrors {
|
||||
const errors: FieldErrors = {}
|
||||
if (!email) {
|
||||
errors.email = 'Email is required'
|
||||
} else if (!EMAIL_REGEX.test(email)) {
|
||||
errors.email = 'Please enter a valid email address'
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = 'Password is required'
|
||||
} else if (password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters'
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
logo,
|
||||
title = 'Sign in',
|
||||
socialProviders,
|
||||
onSubmit,
|
||||
onForgotPassword,
|
||||
onSignUp,
|
||||
error,
|
||||
loading = false,
|
||||
className,
|
||||
}: LoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const emailRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Auto-focus first input on mount
|
||||
useEffect(() => {
|
||||
emailRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Reset submitted flag when error prop changes (new server error from re-attempt)
|
||||
useEffect(() => {
|
||||
if (error) setSubmitted(false)
|
||||
}, [error])
|
||||
|
||||
// Server error is shown from prop, hidden after next submit attempt
|
||||
const showServerError = error && !submitted
|
||||
|
||||
const hasSocial = socialProviders && socialProviders.length > 0
|
||||
const hasCredentials = !!onSubmit
|
||||
const showDivider = hasSocial && hasCredentials
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
const errors = validate(email, password)
|
||||
setFieldErrors(errors)
|
||||
if (Object.keys(errors).length === 0) {
|
||||
onSubmit?.({ email, password, remember })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.loginForm} ${className ?? ''}`}>
|
||||
{logo && <div className={styles.logo}>{logo}</div>}
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
|
||||
{showServerError && (
|
||||
<div className={styles.error}>
|
||||
<Alert variant="error">{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSocial && (
|
||||
<div className={styles.socialSection}>
|
||||
{socialProviders.map((provider) => (
|
||||
<Button
|
||||
key={provider.label}
|
||||
variant="secondary"
|
||||
className={styles.socialButton}
|
||||
onClick={provider.onClick}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
{provider.icon}
|
||||
{provider.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDivider && (
|
||||
<div className={styles.divider}>
|
||||
<div className={styles.dividerLine} />
|
||||
<span className={styles.dividerText}>or</span>
|
||||
<div className={styles.dividerLine} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredentials && (
|
||||
<form
|
||||
className={styles.fields}
|
||||
onSubmit={handleSubmit}
|
||||
aria-label="Sign in"
|
||||
noValidate
|
||||
>
|
||||
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
|
||||
<Input
|
||||
ref={emailRef}
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.rememberRow}>
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
{onForgotPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.forgotLink}
|
||||
onClick={onForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
{onSignUp && (
|
||||
<div className={styles.signUpText}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signUpLink}
|
||||
onClick={onSignUp}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!hasCredentials && onSignUp && (
|
||||
<div className={styles.signUpText}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signUpLink}
|
||||
onClick={onSignUp}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||
Expected: 8 tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/design-system/composites/LoginForm/LoginForm.tsx \
|
||||
src/design-system/composites/LoginForm/LoginForm.module.css \
|
||||
src/design-system/composites/LoginForm/LoginForm.test.tsx
|
||||
git commit -m "feat: add LoginForm component with rendering tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: LoginForm — Validation Tests & Behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add validation and interaction tests**
|
||||
|
||||
Append to the `describe('LoginForm')` block in `LoginForm.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
// Add these inside the existing describe('LoginForm') block, after the rendering describe:
|
||||
|
||||
describe('validation', () => {
|
||||
it('validates required email', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Email is required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates email format', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.type(screen.getByLabelText(/email/i), 'notanemail')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates required password', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Password is required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates password minimum length', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'short')
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears field errors on typing', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Email is required')).toBeInTheDocument()
|
||||
await user.type(screen.getByLabelText(/email/i), 't')
|
||||
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSubmit with credentials when valid', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={onSubmit} />)
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByLabelText(/remember me/i))
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
remember: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call onSubmit when validation fails', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={onSubmit} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('disables form inputs when loading', () => {
|
||||
render(<LoginForm {...allProps} loading />)
|
||||
expect(screen.getByLabelText(/email/i)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/password/i)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows spinner on submit button when loading', () => {
|
||||
render(<LoginForm {...allProps} loading />)
|
||||
const submitBtn = screen.getByRole('button', { name: 'Sign in' })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
// Button component renders Spinner when loading=true
|
||||
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables social buttons when loading', () => {
|
||||
render(<LoginForm {...allProps} loading />)
|
||||
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('calls social provider onClick when clicked', async () => {
|
||||
const onClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onForgotPassword when link clicked', async () => {
|
||||
const onForgotPassword = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
|
||||
await user.click(screen.getByText(/forgot password/i))
|
||||
expect(onForgotPassword).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onSignUp when link clicked', async () => {
|
||||
const onSignUp = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
|
||||
await user.click(screen.getByText(/sign up/i))
|
||||
expect(onSignUp).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||
Expected: 21 tests PASS (8 rendering + 7 validation + 3 loading + 3 callbacks)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/design-system/composites/LoginForm/LoginForm.test.tsx
|
||||
git commit -m "test: add validation and interaction tests for LoginForm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: LoginDialog — Component & Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `src/design-system/composites/LoginForm/LoginDialog.tsx`
|
||||
- Create: `src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write LoginDialog tests**
|
||||
|
||||
Create `src/design-system/composites/LoginForm/LoginDialog.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { LoginDialog } from './LoginDialog'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
}
|
||||
|
||||
describe('LoginDialog', () => {
|
||||
it('renders Modal with LoginForm when open', () => {
|
||||
render(<LoginDialog {...defaultProps} />)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sign in')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<LoginDialog {...defaultProps} open={false} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose on Esc', async () => {
|
||||
const onClose = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on backdrop click', async () => {
|
||||
const onClose = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||
await user.click(screen.getByTestId('modal-backdrop'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes LoginForm props through', () => {
|
||||
render(
|
||||
<LoginDialog
|
||||
{...defaultProps}
|
||||
title="Welcome"
|
||||
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
|
||||
error="Bad credentials"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Welcome')).toBeInTheDocument()
|
||||
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Create LoginDialog component**
|
||||
|
||||
Create `src/design-system/composites/LoginForm/LoginDialog.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Modal } from '../Modal/Modal'
|
||||
import { LoginForm, type LoginFormProps } from './LoginForm'
|
||||
|
||||
export interface LoginDialogProps extends LoginFormProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||||
<LoginForm {...formProps} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||
Expected: 5 tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/design-system/composites/LoginForm/LoginDialog.tsx \
|
||||
src/design-system/composites/LoginForm/LoginDialog.test.tsx
|
||||
git commit -m "feat: add LoginDialog modal wrapper component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Barrel Exports & Full Test Suite
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/design-system/composites/index.ts`
|
||||
|
||||
- [ ] **Step 1: Add exports to barrel**
|
||||
|
||||
Add these lines to `src/design-system/composites/index.ts` in alphabetical position (after the `LineChart` export, before `MenuItem`):
|
||||
|
||||
```ts
|
||||
export { LoginForm } from './LoginForm/LoginForm'
|
||||
export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
|
||||
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||
export type { LoginDialogProps } from './LoginForm/LoginDialog'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run: `npx vitest run src/design-system/composites/LoginForm/`
|
||||
Expected: All tests PASS (21 LoginForm + 5 LoginDialog = 26 tests)
|
||||
|
||||
- [ ] **Step 3: Run the full project test suite to check for regressions**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/design-system/composites/index.ts
|
||||
git commit -m "feat: export LoginForm and LoginDialog from composites barrel"
|
||||
```
|
||||
703
docs/superpowers/plans/2026-03-24-metrics-components.md
Normal file
703
docs/superpowers/plans/2026-03-24-metrics-components.md
Normal file
@@ -0,0 +1,703 @@
|
||||
# Metrics Components Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add StatusText primitive, Card title prop, and KpiStrip composite to eliminate ~320 lines of duplicated KPI layout code across Dashboard, Routes, and AgentHealth pages.
|
||||
|
||||
**Architecture:** StatusText is a tiny inline span primitive with semantic color variants. Card gets an optional title prop for a header row. KpiStrip is a new composite that renders a horizontal row of metric cards with labels, values, trends, subtitles, and sparklines.
|
||||
|
||||
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 1, 5, 6)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File | Task |
|
||||
|--------|------|------|
|
||||
| CREATE | `src/design-system/primitives/StatusText/StatusText.tsx` | 1 |
|
||||
| CREATE | `src/design-system/primitives/StatusText/StatusText.module.css` | 1 |
|
||||
| CREATE | `src/design-system/primitives/StatusText/StatusText.test.tsx` | 1 |
|
||||
| MODIFY | `src/design-system/primitives/index.ts` | 1 |
|
||||
| MODIFY | `src/design-system/primitives/Card/Card.tsx` | 2 |
|
||||
| MODIFY | `src/design-system/primitives/Card/Card.module.css` | 2 |
|
||||
| CREATE | `src/design-system/primitives/Card/Card.test.tsx` | 2 |
|
||||
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.tsx` | 3 |
|
||||
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.module.css` | 3 |
|
||||
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.test.tsx` | 3 |
|
||||
| MODIFY | `src/design-system/composites/index.ts` | 3 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: StatusText Primitive
|
||||
|
||||
**Files:**
|
||||
- CREATE `src/design-system/primitives/StatusText/StatusText.tsx`
|
||||
- CREATE `src/design-system/primitives/StatusText/StatusText.module.css`
|
||||
- CREATE `src/design-system/primitives/StatusText/StatusText.test.tsx`
|
||||
- MODIFY `src/design-system/primitives/index.ts`
|
||||
|
||||
### Step 1.1 — Write test (RED)
|
||||
|
||||
- [ ] Create `src/design-system/primitives/StatusText/StatusText.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { StatusText } from './StatusText'
|
||||
|
||||
describe('StatusText', () => {
|
||||
it('renders children text', () => {
|
||||
render(<StatusText variant="success">OK</StatusText>)
|
||||
expect(screen.getByText('OK')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders as a span element', () => {
|
||||
render(<StatusText variant="success">OK</StatusText>)
|
||||
expect(screen.getByText('OK').tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('applies variant class', () => {
|
||||
render(<StatusText variant="error">BREACH</StatusText>)
|
||||
expect(screen.getByText('BREACH')).toHaveClass('error')
|
||||
})
|
||||
|
||||
it('applies bold class when bold=true', () => {
|
||||
render(<StatusText variant="warning" bold>HIGH</StatusText>)
|
||||
expect(screen.getByText('HIGH')).toHaveClass('bold')
|
||||
})
|
||||
|
||||
it('does not apply bold class by default', () => {
|
||||
render(<StatusText variant="muted">idle</StatusText>)
|
||||
expect(screen.getByText('idle')).not.toHaveClass('bold')
|
||||
})
|
||||
|
||||
it('accepts custom className', () => {
|
||||
render(<StatusText variant="running" className="custom">active</StatusText>)
|
||||
expect(screen.getByText('active')).toHaveClass('custom')
|
||||
})
|
||||
|
||||
it('renders all variant classes correctly', () => {
|
||||
const { rerender } = render(<StatusText variant="success">text</StatusText>)
|
||||
expect(screen.getByText('text')).toHaveClass('success')
|
||||
|
||||
rerender(<StatusText variant="warning">text</StatusText>)
|
||||
expect(screen.getByText('text')).toHaveClass('warning')
|
||||
|
||||
rerender(<StatusText variant="error">text</StatusText>)
|
||||
expect(screen.getByText('text')).toHaveClass('error')
|
||||
|
||||
rerender(<StatusText variant="running">text</StatusText>)
|
||||
expect(screen.getByText('text')).toHaveClass('running')
|
||||
|
||||
rerender(<StatusText variant="muted">text</StatusText>)
|
||||
expect(screen.getByText('text')).toHaveClass('muted')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] Run test — expect FAIL (module not found):
|
||||
|
||||
```bash
|
||||
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
|
||||
```
|
||||
|
||||
### Step 1.2 — Implement (GREEN)
|
||||
|
||||
- [ ] Create `src/design-system/primitives/StatusText/StatusText.module.css`:
|
||||
|
||||
```css
|
||||
.statusText {
|
||||
/* Inherits font-size from parent */
|
||||
}
|
||||
|
||||
.success { color: var(--success); }
|
||||
.warning { color: var(--warning); }
|
||||
.error { color: var(--error); }
|
||||
.running { color: var(--running); }
|
||||
.muted { color: var(--text-muted); }
|
||||
|
||||
.bold { font-weight: 600; }
|
||||
```
|
||||
|
||||
- [ ] Create `src/design-system/primitives/StatusText/StatusText.tsx`:
|
||||
|
||||
```tsx
|
||||
import styles from './StatusText.module.css'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface StatusTextProps {
|
||||
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
|
||||
bold?: boolean
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
|
||||
const classes = [
|
||||
styles.statusText,
|
||||
styles[variant],
|
||||
bold ? styles.bold : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return <span className={classes}>{children}</span>
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Run test — expect PASS:
|
||||
|
||||
```bash
|
||||
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
|
||||
```
|
||||
|
||||
### Step 1.3 — Barrel export
|
||||
|
||||
- [ ] Add to `src/design-system/primitives/index.ts` (alphabetical, after `StatusDot`):
|
||||
|
||||
```ts
|
||||
export { StatusText } from './StatusText/StatusText'
|
||||
```
|
||||
|
||||
### Step 1.4 — Commit
|
||||
|
||||
```bash
|
||||
git add src/design-system/primitives/StatusText/ src/design-system/primitives/index.ts
|
||||
git commit -m "feat: add StatusText primitive with semantic color variants"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Card Title Extension
|
||||
|
||||
**Files:**
|
||||
- MODIFY `src/design-system/primitives/Card/Card.tsx`
|
||||
- MODIFY `src/design-system/primitives/Card/Card.module.css`
|
||||
- CREATE `src/design-system/primitives/Card/Card.test.tsx`
|
||||
|
||||
### Step 2.1 — Write test (RED)
|
||||
|
||||
- [ ] Create `src/design-system/primitives/Card/Card.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Card } from './Card'
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders children', () => {
|
||||
render(<Card>Card content</Card>)
|
||||
expect(screen.getByText('Card content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders title when provided', () => {
|
||||
render(<Card title="Section Title">content</Card>)
|
||||
expect(screen.getByText('Section Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render title header when title is omitted', () => {
|
||||
const { container } = render(<Card>content</Card>)
|
||||
expect(container.querySelector('.titleHeader')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wraps children in body div when title is provided', () => {
|
||||
render(<Card title="Header">body text</Card>)
|
||||
const body = screen.getByText('body text').closest('div')
|
||||
expect(body).toHaveClass('body')
|
||||
})
|
||||
|
||||
it('renders with accent and title together', () => {
|
||||
const { container } = render(
|
||||
<Card accent="success" title="Status">
|
||||
details
|
||||
</Card>
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('accent-success')
|
||||
expect(screen.getByText('Status')).toBeInTheDocument()
|
||||
expect(screen.getByText('details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts className prop', () => {
|
||||
const { container } = render(<Card className="custom">content</Card>)
|
||||
expect(container.firstChild).toHaveClass('custom')
|
||||
})
|
||||
|
||||
it('renders children directly when no title (no wrapper div)', () => {
|
||||
const { container } = render(<Card><span data-testid="direct">hi</span></Card>)
|
||||
expect(screen.getByTestId('direct')).toBeInTheDocument()
|
||||
// Should not have a body wrapper when there is no title
|
||||
expect(container.querySelector('.body')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] Run test — expect FAIL (title prop not supported yet, body class missing):
|
||||
|
||||
```bash
|
||||
npx vitest run src/design-system/primitives/Card/Card.test.tsx
|
||||
```
|
||||
|
||||
### Step 2.2 — Implement (GREEN)
|
||||
|
||||
- [ ] Add to `src/design-system/primitives/Card/Card.module.css` (append after existing rules):
|
||||
|
||||
```css
|
||||
.titleHeader {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.titleText {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Replace `src/design-system/primitives/Card/Card.tsx` with:
|
||||
|
||||
```tsx
|
||||
import styles from './Card.module.css'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Card({ children, accent = 'none', title, className }: CardProps) {
|
||||
const classes = [
|
||||
styles.card,
|
||||
accent !== 'none' ? styles[`accent-${accent}`] : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{title && (
|
||||
<div className={styles.titleHeader}>
|
||||
<h3 className={styles.titleText}>{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
{title ? <div className={styles.body}>{children}</div> : children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Run test — expect PASS:
|
||||
|
||||
```bash
|
||||
npx vitest run src/design-system/primitives/Card/Card.test.tsx
|
||||
```
|
||||
|
||||
### Step 2.3 — Commit
|
||||
|
||||
```bash
|
||||
git add src/design-system/primitives/Card/
|
||||
git commit -m "feat: add optional title prop to Card primitive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: KpiStrip Composite
|
||||
|
||||
**Files:**
|
||||
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.tsx`
|
||||
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.module.css`
|
||||
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`
|
||||
- MODIFY `src/design-system/composites/index.ts`
|
||||
|
||||
### Step 3.1 — Write test (RED)
|
||||
|
||||
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { KpiStrip } from './KpiStrip'
|
||||
|
||||
const sampleItems = [
|
||||
{
|
||||
label: 'Total Throughput',
|
||||
value: '12,847',
|
||||
trend: { label: '\u25B2 +8%', variant: 'success' as const },
|
||||
subtitle: '35.7 msg/s',
|
||||
sparkline: [44, 46, 45, 47, 48, 46, 47],
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Error Rate',
|
||||
value: '0.42%',
|
||||
trend: { label: '\u25BC -0.1%', variant: 'success' as const },
|
||||
subtitle: '54 errors / 12,847 total',
|
||||
},
|
||||
{
|
||||
label: 'Active Routes',
|
||||
value: 14,
|
||||
},
|
||||
]
|
||||
|
||||
describe('KpiStrip', () => {
|
||||
it('renders all items', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
expect(screen.getByText('Total Throughput')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error Rate')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active Routes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders labels and values', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
expect(screen.getByText('12,847')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.42%')).toBeInTheDocument()
|
||||
expect(screen.getByText('14')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders trend with correct text', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
expect(screen.getByText('\u25B2 +8%')).toBeInTheDocument()
|
||||
expect(screen.getByText('\u25BC -0.1%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies variant class to trend', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
const trend = screen.getByText('\u25B2 +8%')
|
||||
expect(trend).toHaveClass('trendSuccess')
|
||||
})
|
||||
|
||||
it('hides trend when omitted', () => {
|
||||
render(<KpiStrip items={[{ label: 'Routes', value: 14 }]} />)
|
||||
// Should only have label and value, no trend element
|
||||
const card = screen.getByText('Routes').closest('[class*="kpiCard"]')
|
||||
expect(card?.querySelector('[class*="trend"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders subtitle', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
expect(screen.getByText('35.7 msg/s')).toBeInTheDocument()
|
||||
expect(screen.getByText('54 errors / 12,847 total')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders sparkline when data provided', () => {
|
||||
const { container } = render(<KpiStrip items={sampleItems} />)
|
||||
// Sparkline renders an SVG with aria-hidden
|
||||
const svgs = container.querySelectorAll('svg[aria-hidden="true"]')
|
||||
expect(svgs.length).toBe(1) // Only first item has sparkline
|
||||
})
|
||||
|
||||
it('accepts className prop', () => {
|
||||
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
|
||||
expect(container.firstChild).toHaveClass('custom')
|
||||
})
|
||||
|
||||
it('handles empty items array', () => {
|
||||
const { container } = render(<KpiStrip items={[]} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
// No cards rendered
|
||||
expect(container.querySelectorAll('[class*="kpiCard"]').length).toBe(0)
|
||||
})
|
||||
|
||||
it('uses default border color when borderColor is omitted', () => {
|
||||
const { container } = render(
|
||||
<KpiStrip items={[{ label: 'Test', value: 100 }]} />
|
||||
)
|
||||
const card = container.querySelector('[class*="kpiCard"]')
|
||||
expect(card).toBeInTheDocument()
|
||||
// The default borderColor is applied via inline style
|
||||
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--amber)' })
|
||||
})
|
||||
|
||||
it('applies custom borderColor', () => {
|
||||
const { container } = render(
|
||||
<KpiStrip items={[{ label: 'Errors', value: 5, borderColor: 'var(--error)' }]} />
|
||||
)
|
||||
const card = container.querySelector('[class*="kpiCard"]')
|
||||
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--error)' })
|
||||
})
|
||||
|
||||
it('renders trend with muted variant by default', () => {
|
||||
render(
|
||||
<KpiStrip items={[{ label: 'Test', value: 1, trend: { label: '~ stable' } }]} />
|
||||
)
|
||||
const trend = screen.getByText('~ stable')
|
||||
expect(trend).toHaveClass('trendMuted')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] Run test — expect FAIL (module not found):
|
||||
|
||||
```bash
|
||||
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
|
||||
```
|
||||
|
||||
### Step 3.2 — Implement (GREEN)
|
||||
|
||||
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.module.css`:
|
||||
|
||||
```css
|
||||
/* KpiStrip — horizontal row of metric cards */
|
||||
.kpiStrip {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── Individual card ─────────────────────────────────────────────── */
|
||||
.kpiCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 18px 12px;
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.kpiCard:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Top gradient border — color driven by CSS custom property */
|
||||
.kpiCard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
|
||||
}
|
||||
|
||||
/* ── Label ───────────────────────────────────────────────────────── */
|
||||
.label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ── Value row ───────────────────────────────────────────────────── */
|
||||
.valueRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Trend ────────────────────────────────────────────────────────── */
|
||||
.trend {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.trendSuccess { color: var(--success); }
|
||||
.trendWarning { color: var(--warning); }
|
||||
.trendError { color: var(--error); }
|
||||
.trendMuted { color: var(--text-muted); }
|
||||
|
||||
/* ── Subtitle ─────────────────────────────────────────────────────── */
|
||||
.subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Sparkline ────────────────────────────────────────────────────── */
|
||||
.sparkline {
|
||||
margin-top: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.tsx`:
|
||||
|
||||
```tsx
|
||||
import styles from './KpiStrip.module.css'
|
||||
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
export interface KpiItem {
|
||||
label: string
|
||||
value: string | number
|
||||
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||
subtitle?: string
|
||||
sparkline?: number[]
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
export interface KpiStripProps {
|
||||
items: KpiItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const trendClassMap: Record<string, string> = {
|
||||
success: styles.trendSuccess,
|
||||
warning: styles.trendWarning,
|
||||
error: styles.trendError,
|
||||
muted: styles.trendMuted,
|
||||
}
|
||||
|
||||
export function KpiStrip({ items, className }: KpiStripProps) {
|
||||
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
|
||||
const gridStyle: CSSProperties = {
|
||||
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={stripClasses} style={gridStyle}>
|
||||
{items.map((item) => {
|
||||
const borderColor = item.borderColor ?? 'var(--amber)'
|
||||
const cardStyle: CSSProperties & Record<string, string> = {
|
||||
'--kpi-border-color': borderColor,
|
||||
}
|
||||
const trendVariant = item.trend?.variant ?? 'muted'
|
||||
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
|
||||
|
||||
return (
|
||||
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
|
||||
<div className={styles.label}>{item.label}</div>
|
||||
<div className={styles.valueRow}>
|
||||
<span className={styles.value}>{item.value}</span>
|
||||
{item.trend && (
|
||||
<span className={`${styles.trend} ${trendClass}`}>
|
||||
{item.trend.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.subtitle && (
|
||||
<div className={styles.subtitle}>{item.subtitle}</div>
|
||||
)}
|
||||
{item.sparkline && item.sparkline.length >= 2 && (
|
||||
<div className={styles.sparkline}>
|
||||
<Sparkline
|
||||
data={item.sparkline}
|
||||
color={borderColor}
|
||||
width={200}
|
||||
height={32}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Run test — expect PASS:
|
||||
|
||||
```bash
|
||||
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
|
||||
```
|
||||
|
||||
### Step 3.3 — Barrel export
|
||||
|
||||
- [ ] Add to `src/design-system/composites/index.ts` (alphabetical, after `GroupCard`):
|
||||
|
||||
```ts
|
||||
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||
```
|
||||
|
||||
### Step 3.4 — Commit
|
||||
|
||||
```bash
|
||||
git add src/design-system/composites/KpiStrip/ src/design-system/composites/index.ts
|
||||
git commit -m "feat: add KpiStrip composite for reusable metric card rows"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Barrel Exports Verification & Full Test Run
|
||||
|
||||
**Files:**
|
||||
- VERIFY `src/design-system/primitives/index.ts` (modified in Task 1)
|
||||
- VERIFY `src/design-system/composites/index.ts` (modified in Task 3)
|
||||
|
||||
### Step 4.1 — Verify barrel exports
|
||||
|
||||
- [ ] Confirm `src/design-system/primitives/index.ts` contains:
|
||||
|
||||
```ts
|
||||
export { StatusText } from './StatusText/StatusText'
|
||||
```
|
||||
|
||||
- [ ] Confirm `src/design-system/composites/index.ts` contains:
|
||||
|
||||
```ts
|
||||
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||
```
|
||||
|
||||
### Step 4.2 — Run full test suite
|
||||
|
||||
- [ ] Run all tests to confirm nothing is broken:
|
||||
|
||||
```bash
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
- [ ] Verify zero failures. If any test fails, fix and re-run before proceeding.
|
||||
|
||||
### Step 4.3 — Final commit (if barrel-only changes remain)
|
||||
|
||||
If the barrel export changes were not already committed in their respective tasks:
|
||||
|
||||
```bash
|
||||
git add src/design-system/primitives/index.ts src/design-system/composites/index.ts
|
||||
git commit -m "chore: add StatusText and KpiStrip to barrel exports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Expected Barrel Export Additions
|
||||
|
||||
**`src/design-system/primitives/index.ts`** — insert after `StatusDot` line:
|
||||
```ts
|
||||
export { StatusText } from './StatusText/StatusText'
|
||||
```
|
||||
|
||||
**`src/design-system/composites/index.ts`** — insert after `GroupCard` line:
|
||||
```ts
|
||||
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Commands Quick Reference
|
||||
|
||||
| Scope | Command |
|
||||
|-------|---------|
|
||||
| StatusText only | `npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx` |
|
||||
| Card only | `npx vitest run src/design-system/primitives/Card/Card.test.tsx` |
|
||||
| KpiStrip only | `npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx` |
|
||||
| All tests | `npx vitest run` |
|
||||
506
docs/superpowers/plans/2026-03-24-observability-components.md
Normal file
506
docs/superpowers/plans/2026-03-24-observability-components.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# Observability Components Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add LogViewer composite for log display and refactor AgentHealth to use DataTable instead of raw HTML tables.
|
||||
|
||||
**Architecture:** LogViewer is a scrollable log display with timestamped, severity-colored entries and auto-scroll behavior. The AgentHealth refactor replaces raw `<table>` elements with the existing DataTable composite.
|
||||
|
||||
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 3, 4)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: LogViewer composite
|
||||
|
||||
Create a new composite component that renders a scrollable log viewer with timestamped, severity-colored entries. This replaces the custom log rendering in `AgentInstance.tsx`.
|
||||
|
||||
### Files
|
||||
|
||||
- **Create** `src/design-system/composites/LogViewer/LogViewer.tsx`
|
||||
- **Create** `src/design-system/composites/LogViewer/LogViewer.module.css`
|
||||
- **Create** `src/design-system/composites/LogViewer/LogViewer.test.tsx`
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **1.1** Create `src/design-system/composites/LogViewer/LogViewer.tsx` with the component and exported types
|
||||
- [ ] **1.2** Create `src/design-system/composites/LogViewer/LogViewer.module.css` with all styles
|
||||
- [ ] **1.3** Create `src/design-system/composites/LogViewer/LogViewer.test.tsx` with tests
|
||||
- [ ] **1.4** Run `npx vitest run src/design-system/composites/LogViewer` and fix any failures
|
||||
|
||||
### API
|
||||
|
||||
```tsx
|
||||
export interface LogEntry {
|
||||
timestamp: string
|
||||
level: 'info' | 'warn' | 'error' | 'debug'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LogViewerProps {
|
||||
entries: LogEntry[]
|
||||
maxHeight?: number | string // Default: 400
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Component implementation — `LogViewer.tsx`
|
||||
|
||||
```tsx
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import styles from './LogViewer.module.css'
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string
|
||||
level: 'info' | 'warn' | 'error' | 'debug'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LogViewerProps {
|
||||
entries: LogEntry[]
|
||||
maxHeight?: number | string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
||||
info: styles.levelInfo,
|
||||
warn: styles.levelWarn,
|
||||
error: styles.levelError,
|
||||
debug: styles.levelDebug,
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
// Consider "at bottom" when within 20px of the end
|
||||
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to bottom when entries change, but only if user hasn't scrolled up
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && isAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [entries])
|
||||
|
||||
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={[styles.container, className].filter(Boolean).join(' ')}
|
||||
style={{ maxHeight: heightStyle }}
|
||||
onScroll={handleScroll}
|
||||
role="log"
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<div key={i} className={styles.line}>
|
||||
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
|
||||
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
||||
{entry.level.toUpperCase()}
|
||||
</span>
|
||||
<span className={styles.message}>{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<div className={styles.empty}>No log entries.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Styles — `LogViewer.module.css`
|
||||
|
||||
```css
|
||||
/* Scrollable container */
|
||||
.container {
|
||||
overflow-y: auto;
|
||||
background: var(--bg-inset);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Each log line */
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 3px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.line:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Timestamp */
|
||||
.timestamp {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
/* Level badge — pill with tinted background */
|
||||
.levelBadge {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.levelInfo {
|
||||
color: var(--running);
|
||||
background: color-mix(in srgb, var(--running) 12%, transparent);
|
||||
}
|
||||
|
||||
.levelWarn {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
}
|
||||
|
||||
.levelError {
|
||||
color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||
}
|
||||
|
||||
.levelDebug {
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Message text */
|
||||
.message {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
```
|
||||
|
||||
### Tests — `LogViewer.test.tsx`
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { LogViewer, type LogEntry } from './LogViewer'
|
||||
import { ThemeProvider } from '../../providers/ThemeProvider'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<ThemeProvider>{ui}</ThemeProvider>)
|
||||
|
||||
const sampleEntries: LogEntry[] = [
|
||||
{ timestamp: '2026-03-24T10:00:00Z', level: 'info', message: 'Server started' },
|
||||
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn', message: 'Slow query detected' },
|
||||
{ timestamp: '2026-03-24T10:02:00Z', level: 'error', message: 'Connection refused' },
|
||||
{ timestamp: '2026-03-24T10:03:00Z', level: 'debug', message: 'Cache hit ratio: 0.95' },
|
||||
]
|
||||
|
||||
describe('LogViewer', () => {
|
||||
it('renders entries with timestamps and messages', () => {
|
||||
wrap(<LogViewer entries={sampleEntries} />)
|
||||
expect(screen.getByText('Server started')).toBeInTheDocument()
|
||||
expect(screen.getByText('Slow query detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('Connection refused')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cache hit ratio: 0.95')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders level badges with correct text', () => {
|
||||
wrap(<LogViewer entries={sampleEntries} />)
|
||||
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom maxHeight', () => {
|
||||
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight={200} />)
|
||||
const el = container.querySelector('[role="log"]')
|
||||
expect(el).toHaveStyle({ maxHeight: '200px' })
|
||||
})
|
||||
|
||||
it('renders with string maxHeight', () => {
|
||||
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight="50vh" />)
|
||||
const el = container.querySelector('[role="log"]')
|
||||
expect(el).toHaveStyle({ maxHeight: '50vh' })
|
||||
})
|
||||
|
||||
it('handles empty entries', () => {
|
||||
wrap(<LogViewer entries={[]} />)
|
||||
expect(screen.getByText('No log entries.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts className prop', () => {
|
||||
const { container } = wrap(<LogViewer entries={sampleEntries} className="custom-class" />)
|
||||
const el = container.querySelector('[role="log"]')
|
||||
expect(el?.className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('has role="log" for accessibility', () => {
|
||||
wrap(<LogViewer entries={sampleEntries} />)
|
||||
expect(screen.getByRole('log')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Key design decisions
|
||||
|
||||
- **Auto-scroll behavior:** Uses a `useRef` to track whether the user is at the bottom of the scroll container. On new entries (via `useEffect` on `entries`), scrolls to bottom only if `isAtBottomRef.current` is `true`. Pauses when user scrolls up (more than 20px from bottom). Resumes when user scrolls back to bottom.
|
||||
- **Level colors:** Map to existing design tokens: `info` -> `var(--running)`, `warn` -> `var(--warning)`, `error` -> `var(--error)`, `debug` -> `var(--text-muted)`. Pill backgrounds use `color-mix` with 12% opacity tint.
|
||||
- **No Badge dependency:** The level badge is a styled `<span>` rather than using the `Badge` primitive. This avoids pulling in `hashColor`/`useTheme` and keeps the badge styling tightly scoped (9px pill vs Badge's larger size). The spec calls for a very compact pill at 9px mono — a custom element is cleaner.
|
||||
- **`role="log"`** on the container for accessibility (indicates a log region to screen readers).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Barrel exports for LogViewer
|
||||
|
||||
Add LogViewer and its types to the composites barrel export.
|
||||
|
||||
### Files
|
||||
|
||||
- **Modify** `src/design-system/composites/index.ts`
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **2.1** Add LogViewer export and type exports to `src/design-system/composites/index.ts`
|
||||
|
||||
### Changes
|
||||
|
||||
Add these lines to `src/design-system/composites/index.ts`, in alphabetical position (after the `LineChart` export):
|
||||
|
||||
```ts
|
||||
export { LogViewer } from './LogViewer/LogViewer'
|
||||
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
||||
```
|
||||
|
||||
The full insertion point — after line 19 (`export { LineChart } from './LineChart/LineChart'`) and before line 20 (`export { LoginDialog } from './LoginForm/LoginDialog'`):
|
||||
|
||||
```ts
|
||||
export { LineChart } from './LineChart/LineChart'
|
||||
export { LogViewer } from './LogViewer/LogViewer'
|
||||
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
||||
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: AgentHealth DataTable refactor
|
||||
|
||||
Replace the raw HTML `<table>` in `AgentHealth.tsx` with the existing `DataTable` composite. This is a **page-level refactor** — no design system components are changed.
|
||||
|
||||
### Files
|
||||
|
||||
- **Modify** `src/pages/AgentHealth/AgentHealth.tsx` — replace `<table>` with `<DataTable>`
|
||||
- **Modify** `src/pages/AgentHealth/AgentHealth.module.css` — remove table CSS
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **3.1** Add `DataTable` and `Column` imports to `AgentHealth.tsx`
|
||||
- [ ] **3.2** Define the instance columns array
|
||||
- [ ] **3.3** Replace the `<table>` block inside each `<GroupCard>` with `<DataTable>`
|
||||
- [ ] **3.4** Remove unused table CSS classes from `AgentHealth.module.css`
|
||||
- [ ] **3.5** Visually verify the page looks identical (run dev server, navigate to `/agents`)
|
||||
|
||||
### 3.1 — Add imports
|
||||
|
||||
Add to the composites import block in `AgentHealth.tsx`:
|
||||
|
||||
```tsx
|
||||
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
||||
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||
```
|
||||
|
||||
### 3.2 — Define columns
|
||||
|
||||
Add a column definition constant above the `AgentHealth` component function. The columns mirror the existing `<th>` headers. Custom `render` functions handle the StatusDot and Badge cells.
|
||||
|
||||
**Important:** DataTable requires rows with an `id: string` field. The `AgentHealthData` type already has `id`, so no transformation is needed.
|
||||
|
||||
```tsx
|
||||
const instanceColumns: Column<AgentHealthData>[] = [
|
||||
{
|
||||
key: 'status',
|
||||
header: '',
|
||||
width: '12px',
|
||||
render: (_value, row) => (
|
||||
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Instance',
|
||||
render: (_value, row) => (
|
||||
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
header: 'State',
|
||||
render: (_value, row) => (
|
||||
<Badge
|
||||
label={row.status.toUpperCase()}
|
||||
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
|
||||
variant="filled"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'uptime',
|
||||
header: 'Uptime',
|
||||
render: (_value, row) => (
|
||||
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tps',
|
||||
header: 'TPS',
|
||||
render: (_value, row) => (
|
||||
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'errorRate',
|
||||
header: 'Errors',
|
||||
render: (_value, row) => (
|
||||
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||
{row.errorRate ?? '0 err/h'}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastSeen',
|
||||
header: 'Heartbeat',
|
||||
render: (_value, row) => (
|
||||
<MonoText size="xs" className={
|
||||
row.status === 'dead' ? styles.instanceHeartbeatDead :
|
||||
row.status === 'stale' ? styles.instanceHeartbeatStale :
|
||||
styles.instanceMeta
|
||||
}>
|
||||
{row.lastSeen}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### 3.3 — Replace `<table>` with `<DataTable>`
|
||||
|
||||
Replace the entire `<table className={styles.instanceTable}>...</table>` block (lines 365-423 of `AgentHealth.tsx`) inside each `<GroupCard>` with:
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
columns={instanceColumns}
|
||||
data={group.instances}
|
||||
flush
|
||||
selectedId={selectedInstance?.id}
|
||||
onRowClick={handleInstanceClick}
|
||||
pageSize={50}
|
||||
/>
|
||||
```
|
||||
|
||||
Key props:
|
||||
- `flush` — strips DataTable's outer border/radius/shadow so it sits seamlessly inside the GroupCard
|
||||
- `selectedId` — highlights the currently selected row (replaces the manual `instanceRowActive` CSS class)
|
||||
- `onRowClick` — replaces the manual `onClick` on `<tr>` elements
|
||||
- `pageSize={50}` — high enough to avoid pagination for typical instance counts per app group
|
||||
|
||||
### 3.4 — Remove unused CSS
|
||||
|
||||
Remove these CSS classes from `AgentHealth.module.css` (they were only used by the raw `<table>`):
|
||||
|
||||
```
|
||||
.instanceTable
|
||||
.instanceTable thead th
|
||||
.thStatus
|
||||
.tdStatus
|
||||
.instanceRow
|
||||
.instanceRow td
|
||||
.instanceRow:last-child td
|
||||
.instanceRow:hover td
|
||||
.instanceRowActive td
|
||||
.instanceRowActive td:first-child
|
||||
```
|
||||
|
||||
**Keep** these classes (still used by DataTable `render` functions):
|
||||
|
||||
```
|
||||
.instanceName
|
||||
.instanceMeta
|
||||
.instanceError
|
||||
.instanceHeartbeatStale
|
||||
.instanceHeartbeatDead
|
||||
```
|
||||
|
||||
### Visual verification checklist
|
||||
|
||||
After the refactor, verify at `/agents`:
|
||||
- [ ] StatusDot column renders colored dots in the first column
|
||||
- [ ] Instance name renders in mono bold
|
||||
- [ ] State column shows Badge with correct color variant
|
||||
- [ ] Uptime, TPS, Errors, Heartbeat columns show muted mono text
|
||||
- [ ] Error values show in `var(--error)` red
|
||||
- [ ] Stale/dead heartbeat timestamps show warning/error colors
|
||||
- [ ] Row click opens the DetailPanel
|
||||
- [ ] Selected row is visually highlighted
|
||||
- [ ] Table sits flush inside GroupCard (no double borders)
|
||||
- [ ] Alert banner still renders below the table for groups with dead instances
|
||||
|
||||
---
|
||||
|
||||
## Execution order
|
||||
|
||||
1. **Task 1** — LogViewer composite (no dependencies)
|
||||
2. **Task 2** — Barrel exports (depends on Task 1)
|
||||
3. **Task 3** — AgentHealth DataTable refactor (independent of Tasks 1-2)
|
||||
|
||||
Tasks 1+2 and Task 3 can be parallelized since they touch different parts of the codebase.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Run LogViewer tests
|
||||
npx vitest run src/design-system/composites/LogViewer
|
||||
|
||||
# Run all tests to check nothing broke
|
||||
npx vitest run
|
||||
|
||||
# Start dev server for visual verification
|
||||
npm run dev
|
||||
# Then navigate to /agents and /agents/{appId}/{instanceId}
|
||||
```
|
||||
249
docs/superpowers/specs/2026-03-18-admin-pages-design.md
Normal file
249
docs/superpowers/specs/2026-03-18-admin-pages-design.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Admin Pages + New Components Design
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Scope:** 3 new design system components + 3 admin example pages
|
||||
|
||||
## Overview
|
||||
|
||||
Transfer the admin section from cameleer3-server UI to the design system project as example pages. Add three new reusable components to the design system that are needed by these pages and useful generally.
|
||||
|
||||
## New Design System Components
|
||||
|
||||
### 1. MultiSelect (composite)
|
||||
|
||||
Dropdown trigger that opens a positioned panel with searchable checkbox list and "Apply" action.
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface MultiSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: MultiSelectOption[]
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string // default: "Select..."
|
||||
searchable?: boolean // default: true
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Click trigger → panel opens below with search input + checkbox list + "Apply (N)" footer
|
||||
- Search filters options by label (case-insensitive)
|
||||
- Checkboxes toggle selection; changes are local until "Apply" is clicked
|
||||
- Apply calls `onChange` with selected values and closes panel
|
||||
- Click outside or Escape closes without applying (discards pending changes)
|
||||
- Trigger shows count: "2 selected" or placeholder when empty
|
||||
- Arrow keys navigate checkbox list, Space toggles focused item, Tab moves between search/list/apply
|
||||
- Panel has max-height with scroll for long option lists
|
||||
|
||||
**Accessibility:**
|
||||
- Trigger: `role="combobox"`, `aria-expanded`, `aria-haspopup="listbox"`
|
||||
- Option list: `role="listbox"`, options have `role="option"` with `aria-selected`
|
||||
- Search input: `aria-label="Filter options"`
|
||||
|
||||
**Implementation:**
|
||||
- New directory: `src/design-system/composites/MultiSelect/`
|
||||
- Manages its own open/close state and positioning (does NOT wrap Popover — needs controlled close behavior to distinguish apply vs. discard)
|
||||
- Uses portal for the dropdown panel to avoid overflow clipping
|
||||
- CSS Modules with design tokens
|
||||
|
||||
### 2. ConfirmDialog (composite)
|
||||
|
||||
Modal dialog requiring the user to type a confirmation string before a destructive action proceeds.
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title?: string // default: "Confirm Deletion"
|
||||
message: string // e.g., "Delete user 'alice'? This cannot be undone."
|
||||
confirmText: string // text the user must type to enable confirm button (must be non-empty)
|
||||
confirmLabel?: string // default: "Delete"
|
||||
cancelLabel?: string // default: "Cancel"
|
||||
variant?: 'danger' | 'warning' | 'info' // default: 'danger'
|
||||
loading?: boolean // default: false — disables buttons, shows pending state
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Built on Modal (size="sm")
|
||||
- Shows title, message, text input with label "Type '{confirmText}' to confirm"
|
||||
- Confirm button disabled until input matches `confirmText` exactly
|
||||
- Input clears on open
|
||||
- Enter submits when enabled; Escape closes
|
||||
- Confirm button uses danger/warning/info variant styling (matches AlertDialog pattern)
|
||||
- When `loading` is true, both buttons are disabled
|
||||
|
||||
**Implementation:**
|
||||
- New directory: `src/design-system/composites/ConfirmDialog/`
|
||||
- Reuses Modal internally (same pattern as AlertDialog)
|
||||
- Auto-focuses input on open
|
||||
|
||||
### 3. InlineEdit (primitive)
|
||||
|
||||
Click-to-edit text field that toggles between display and edit modes.
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface InlineEditProps {
|
||||
value: string
|
||||
onSave: (value: string) => void
|
||||
placeholder?: string // shown when value is empty in display mode
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- **Display mode:** Shows value as text with subtle edit icon (pencil). Clicking text or icon enters edit mode.
|
||||
- **Edit mode:** Input field with current value. Enter saves. Escape cancels (reverts to original value). Blur cancels (same as Escape — prevents accidental saves when clicking away).
|
||||
- If saved value is empty and placeholder exists, display mode shows placeholder in muted style.
|
||||
- No save/cancel buttons — Enter saves, Escape/blur cancels (lightweight inline pattern).
|
||||
|
||||
**Implementation:**
|
||||
- New directory: `src/design-system/primitives/InlineEdit/`
|
||||
- No forwardRef — the component manages its own input internally (the input only exists in edit mode, so a forwarded ref would be null in display mode)
|
||||
- Manages internal editing state with useState
|
||||
|
||||
## Admin Pages
|
||||
|
||||
### Route Structure
|
||||
|
||||
```
|
||||
/admin → redirects to /admin/rbac
|
||||
/admin/audit → AuditLog page
|
||||
/admin/oidc → OidcConfig page
|
||||
/admin/rbac → UserManagement page (tabs: Users | Groups | Roles)
|
||||
```
|
||||
|
||||
All pages use the standard AppShell + Sidebar + TopBar layout with breadcrumbs.
|
||||
|
||||
### Router Integration
|
||||
|
||||
Update `App.tsx` to replace the single `/admin` route with nested routes:
|
||||
```tsx
|
||||
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||||
<Route path="/admin/audit" element={<AuditLog />} />
|
||||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||
```
|
||||
|
||||
### Sidebar Integration
|
||||
|
||||
Keep the existing single "Admin" bottom link in the Sidebar. The admin pages handle their own sub-navigation internally via a secondary nav bar at the top of each admin page (links to Audit Log, OIDC, User Management). This avoids cluttering the main sidebar with admin sub-entries.
|
||||
|
||||
### Barrel Export Updates
|
||||
|
||||
- Add `MultiSelect` and `MultiSelectOption` type to `src/design-system/composites/index.ts`
|
||||
- Add `ConfirmDialog` and `ConfirmDialogProps` type to `src/design-system/composites/index.ts`
|
||||
- Add `InlineEdit` and `InlineEditProps` type to `src/design-system/primitives/index.ts`
|
||||
|
||||
### Page: Audit Log (`src/pages/Admin/AuditLog/`)
|
||||
|
||||
**Layout:** Full-width content area (no split pane).
|
||||
|
||||
**Sections:**
|
||||
1. **Header** — "Audit Log" title + event count badge
|
||||
2. **Filter bar** — DateRangePicker (from/to), Input (user), Select (category: INFRA/AUTH/USER_MGMT/CONFIG), Input (search)
|
||||
3. **Data table** — DataTable with columns: Timestamp (monospace), User, Category (Badge), Action, Target, Result (Badge with success/error color)
|
||||
4. **Expandable rows** — Clicking a row reveals detail section with IP address, user agent, and JSON detail (CodeBlock)
|
||||
5. **Pagination** — Pagination component below table
|
||||
|
||||
**Mock data:** ~30 audit events with varied categories, actions, results.
|
||||
|
||||
### Page: OIDC Config (`src/pages/Admin/OidcConfig/`)
|
||||
|
||||
**Layout:** Single-column form layout, max-width constrained.
|
||||
|
||||
**Sections:**
|
||||
1. **Header** — "OIDC Configuration" + Save/Test/Delete buttons
|
||||
2. **Behavior** — Two Toggle fields (Enabled, Auto Sign-Up) wrapped in FormField
|
||||
3. **Provider Settings** — FormField-wrapped Inputs for Issuer URI, Client ID, Client Secret (type=password)
|
||||
4. **Claim Mapping** — FormField-wrapped Inputs for Roles Claim, Display Name Claim with hint text
|
||||
5. **Default Roles** — Tag list (removable) + Input + Button to add new roles
|
||||
6. **Delete** — Button (danger) that opens ConfirmDialog
|
||||
|
||||
**Mock data:** Pre-filled form state with sample OIDC config.
|
||||
|
||||
### Page: User Management (`src/pages/Admin/UserManagement/`)
|
||||
|
||||
**Layout:** Tabs component at top (Users | Groups | Roles). Each tab has a CSS grid split-pane (roughly 52/48).
|
||||
|
||||
#### Users Tab
|
||||
- **Left pane:** Input search + "Add user" Button + scrollable user list. Each item: Avatar + name + provider Badge + meta (email, group path) + Tag list (roles: amber, groups: green, inherited: dashed Badge).
|
||||
- **Inline create form:** Appears at top of list when "Add user" clicked. Input fields for username, display, email, password. Cancel/Create buttons.
|
||||
- **Right pane (detail):** Large Avatar + InlineEdit (display name) + email + Delete Button. Metadata fields (Status, ID as MonoText, Created). SectionHeader "Group membership" + Tag list (removable) + MultiSelect to add groups. SectionHeader "Effective roles" + Tag list (direct: solid, inherited: dashed with source label) + MultiSelect to add roles.
|
||||
- **Delete:** ConfirmDialog (type username to confirm).
|
||||
|
||||
#### Groups Tab
|
||||
- **Left pane:** Same pattern — search + "Add group" + group list. Each item: Avatar (square) + name + meta (parent, child count, member count) + role Tags.
|
||||
- **Inline create form:** Name input + parent Select (top-level or existing group).
|
||||
- **Right pane:** Avatar + InlineEdit (name) + hierarchy label. Metadata (ID, Parent — editable via Select). SectionHeader "Members" + Tag list. SectionHeader "Child groups" + Tag list. SectionHeader "Assigned roles" + removable Tags + MultiSelect. SectionHeader "Hierarchy" with indented tree display.
|
||||
- **Delete:** ConfirmDialog.
|
||||
|
||||
#### Roles Tab
|
||||
- **Left pane:** Search + "Add role" + role list. Each item: Avatar (square) + name + lock icon if system + meta (description, assignment count) + Tags.
|
||||
- **Inline create form:** Name, Description, Scope inputs.
|
||||
- **Right pane:** Avatar + role name (non-editable for system roles) + description. Metadata (ID, Scope, Type). SectionHeader "Assigned to groups" (read-only list). SectionHeader "Assigned to users (direct)" (read-only). SectionHeader "Effective principals" with inherited entries in dashed style.
|
||||
- **Delete:** ConfirmDialog (only for non-system roles).
|
||||
|
||||
**Mock data:** ~8 users, ~4 groups (with nesting), ~6 roles (including system roles ADMIN, USER). Realistic role/group assignments with inheritance.
|
||||
|
||||
### Inventory Updates
|
||||
|
||||
Add demos for all three new components:
|
||||
|
||||
- **MultiSelect** → CompositesSection: Demo showing multi-select with sample options, displaying selected count
|
||||
- **InlineEdit** → PrimitivesSection: Demo showing display/edit toggle with a sample name
|
||||
- **ConfirmDialog** → CompositesSection: Demo with a "Delete item" button that opens the dialog
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/design-system/composites/MultiSelect/
|
||||
MultiSelect.tsx
|
||||
MultiSelect.module.css
|
||||
MultiSelect.test.tsx
|
||||
|
||||
src/design-system/primitives/InlineEdit/
|
||||
InlineEdit.tsx
|
||||
InlineEdit.module.css
|
||||
InlineEdit.test.tsx
|
||||
|
||||
src/design-system/composites/ConfirmDialog/
|
||||
ConfirmDialog.tsx
|
||||
ConfirmDialog.module.css
|
||||
ConfirmDialog.test.tsx
|
||||
|
||||
src/pages/Admin/
|
||||
AuditLog/
|
||||
AuditLog.tsx
|
||||
AuditLog.module.css
|
||||
auditMocks.ts
|
||||
OidcConfig/
|
||||
OidcConfig.tsx
|
||||
OidcConfig.module.css
|
||||
UserManagement/
|
||||
UserManagement.tsx
|
||||
UserManagement.module.css
|
||||
UsersTab.tsx
|
||||
GroupsTab.tsx
|
||||
RolesTab.tsx
|
||||
rbacMocks.ts
|
||||
```
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- No backend API integration (static mock data with useState)
|
||||
- No persistence across page refresh
|
||||
- No access control / role gating in the example app
|
||||
- Dashboard tab from RBAC is excluded per user request
|
||||
- Split pane is page-local CSS, not a design system component
|
||||
207
docs/superpowers/specs/2026-03-18-admin-redesign.md
Normal file
207
docs/superpowers/specs/2026-03-18-admin-redesign.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Admin Section Redesign Spec
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Scope:** UX/UI consistency overhaul of AuditLog, OidcConfig, UserManagement admin pages
|
||||
|
||||
## Overview
|
||||
|
||||
Three expert reviews identified critical bugs, consistency gaps, and usability issues in the admin section. This spec covers all changes organized by priority tier.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: Critical Bugs
|
||||
|
||||
### 1.1 Replace nonexistent `--bg-base` token
|
||||
|
||||
`--bg-base` is referenced 3 times but does not exist in `tokens.css`. Dark mode is broken.
|
||||
|
||||
**Files:**
|
||||
- `Admin.module.css` line 6: `.adminNav`
|
||||
- `UserManagement.module.css` lines 13, 19: `.listPane`, `.detailPane`
|
||||
|
||||
**Fix:** Replace all `var(--bg-base)` with `var(--bg-surface)`.
|
||||
|
||||
### 1.2 Change AuditEvent `id` to string
|
||||
|
||||
`DataTable` requires `T extends { id: string }`. Current `AuditEvent.id` is `number`.
|
||||
|
||||
**Files:**
|
||||
- `auditMocks.ts`: change `id: number` to `id: string`, update all IDs to `'audit-1'`, `'audit-2'`, etc.
|
||||
- `AuditLog.tsx`: update `expandedId` state from `number | null` to `string | null`
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: High-Impact Consistency
|
||||
|
||||
### 2.1 Replace admin nav with Tabs composite
|
||||
|
||||
The hand-rolled admin nav in `Admin.tsx` lacks ARIA roles and has subtle color differences from the Tabs composite.
|
||||
|
||||
**Fix:** Replace the custom `<nav>` block with:
|
||||
```tsx
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: 'User Management', value: '/admin/rbac' },
|
||||
{ label: 'Audit Log', value: '/admin/audit' },
|
||||
{ label: 'OIDC', value: '/admin/oidc' },
|
||||
]}
|
||||
active={location.pathname}
|
||||
onChange={(path) => navigate(path)}
|
||||
/>
|
||||
```
|
||||
|
||||
Delete `.adminNav`, `.adminTab`, `.adminTabActive` from `Admin.module.css`.
|
||||
|
||||
### 2.2 Remove duplicate page titles
|
||||
|
||||
Breadcrumb + active tab + h2 heading all show the same label. Remove the h2.
|
||||
|
||||
**Files:**
|
||||
- `AuditLog.tsx`: remove the `.header` div with `<h2>Audit Log</h2>`. Move the event count badge into a toolbar row.
|
||||
- `OidcConfig.tsx`: remove the `.header` div with `<h2>`. Keep Save/Test buttons in a compact toolbar row below the tabs.
|
||||
|
||||
Delete `.header`, `.title` CSS from both `AuditLog.module.css` and `OidcConfig.module.css`.
|
||||
|
||||
### 2.3 Migrate AuditLog to DataTable
|
||||
|
||||
Replace the hand-built `<table>` with the DataTable composite.
|
||||
|
||||
**Column definitions:**
|
||||
- Timestamp: render with `MonoText`, width `'170px'`
|
||||
- User: render with bold text
|
||||
- Category: render with `Badge color="auto"`
|
||||
- Action: plain text
|
||||
- Target: render with ellipsis style
|
||||
- Result: render with `Badge color={row.result === 'SUCCESS' ? 'success' : 'error'}`
|
||||
|
||||
**Features to enable:**
|
||||
- `sortable` on Timestamp, User, Category, Result columns
|
||||
- `rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}` — red left-border on failures
|
||||
- `expandedContent={(row) => <detail block with IP, user agent, CodeBlock>}`
|
||||
- `pageSize={10}`
|
||||
- `flush` prop (table sits inside a card wrapper)
|
||||
|
||||
**Card wrapper:** Wrap DataTable in a section with `background: var(--bg-surface)`, `border: 1px solid var(--border-subtle)`, `border-radius: var(--radius-lg)`, `box-shadow: var(--shadow-card)`. Add a header row with title + event count badge, matching Dashboard's `.tableSection` pattern.
|
||||
|
||||
**Delete from AuditLog.module.css:** `.tableWrap`, `.table`, `.th`, `.row`, `.td`, `.userCell`, `.target`, `.empty`, `.detailRow`, `.detailCell`, `.detailGrid`, `.detailField`, `.detailLabel`, `.detailValue`, `.detailJson`. Also remove the separate `Pagination` import — DataTable handles pagination internally.
|
||||
|
||||
### 2.4 Fix content padding
|
||||
|
||||
`Admin.module.css` `.adminContent`: change `padding: 20px` to `padding: 20px 24px 40px`.
|
||||
|
||||
### 2.5 Center OIDC form
|
||||
|
||||
`OidcConfig.module.css` `.page`: add `margin: 0 auto` to center the 640px max-width form.
|
||||
|
||||
### 2.6 Replace inline style
|
||||
|
||||
`UserManagement.tsx` line 20: replace `style={{ marginTop: 16 }}` with a CSS class `.tabContent { margin-top: 16px; }` in `UserManagement.module.css`.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Usability Improvements
|
||||
|
||||
### 3.1 Add toast notifications to RBAC mutations
|
||||
|
||||
Import `useToast` into `UsersTab.tsx`, `GroupsTab.tsx`, `RolesTab.tsx`. Fire toasts on:
|
||||
- Create user/group/role: `variant: 'success'`
|
||||
- Delete user/group/role: `variant: 'warning'`
|
||||
- Role assigned/removed: `variant: 'success'`
|
||||
- Group added/removed: `variant: 'success'`
|
||||
|
||||
### 3.2 Rework user creation form
|
||||
|
||||
Replace the flat inline form with a provider-aware two-step form.
|
||||
|
||||
**Form structure:**
|
||||
1. **Provider selection** — RadioGroup with "Local" and "OIDC" options. Default: "Local".
|
||||
2. **Fields (always shown):** Username (required), Display name (optional), Email (optional)
|
||||
3. **Fields (Local only):** Password (required)
|
||||
4. **OIDC info callout:** When OIDC selected, show an InfoCallout: "OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login."
|
||||
|
||||
**Components used:** RadioGroup + RadioItem (existing primitive), Input, InfoCallout (existing primitive), Button.
|
||||
|
||||
**Create handler:** Set `provider` based on RadioGroup selection. Only validate password when provider is 'local'.
|
||||
|
||||
The form should use the existing inline pattern (appears at the top of the list pane), but use a proper card-like treatment (the existing `.createForm` background is fine).
|
||||
|
||||
### 3.3 Add password management to user detail pane
|
||||
|
||||
Add a "Security" section (using `SectionHeader`) in the user detail pane, below the metadata grid.
|
||||
|
||||
**For local users:**
|
||||
- Show "Password: ••••••••" with a "Reset password" Button
|
||||
- Clicking "Reset password" reveals an inline form: Input (type=password, placeholder "New password") + Cancel/Set buttons
|
||||
- Setting fires a success toast: "Password updated"
|
||||
|
||||
**For OIDC users:**
|
||||
- Show "Authentication: OIDC ({provider})"
|
||||
- Show InfoCallout: "Password managed by the identity provider."
|
||||
- No password reset option
|
||||
|
||||
### 3.4 Add ConfirmDialog to cascading removals
|
||||
|
||||
When removing a group from a user (which may strip inherited roles), show a confirmation dialog if the group grants roles.
|
||||
|
||||
When removing a role from a group (which affects all members), show a confirmation dialog.
|
||||
|
||||
Direct role removal from a user does not need confirmation (low risk).
|
||||
|
||||
### 3.5 Make entity list items keyboard accessible
|
||||
|
||||
Add to each `.entityItem` div:
|
||||
- `role="option"`
|
||||
- `tabIndex={0}`
|
||||
- `aria-selected={selectedId === item.id}`
|
||||
- `onKeyDown`: Enter/Space to select, ArrowUp/ArrowDown to navigate
|
||||
|
||||
Add `role="listbox"` and `aria-label` to each `.entityList` container.
|
||||
|
||||
### 3.6 Add expand/collapse affordance to AuditLog
|
||||
|
||||
After DataTable migration (2.3), add a first column with a chevron indicator (`>` / `v`) that rotates when the row is expanded. Width: `'40px'`. This makes the expandable row pattern discoverable.
|
||||
|
||||
### 3.7 Add duplicate name validation
|
||||
|
||||
Before creating, check for existing names:
|
||||
- Users: `users.some(u => u.username === newUsername.trim())`
|
||||
- Groups: `groups.some(g => g.name.toLowerCase() === newName.trim().toLowerCase())`
|
||||
- Roles: `roles.some(r => r.name === newName.trim().toUpperCase())`
|
||||
|
||||
Show inline error using state + red text below the name field. Disable Create button.
|
||||
|
||||
### 3.8 Partial FilterBar migration for AuditLog
|
||||
|
||||
After DataTable migration, use FilterBar for search + category filters:
|
||||
- Search input maps to FilterBar's built-in search
|
||||
- Categories (INFRA, AUTH, USER_MGMT, CONFIG) become FilterPill toggles
|
||||
- Keep DateRangePicker and user filter Input alongside FilterBar in a row
|
||||
|
||||
### 3.9 Add empty-search states to entity lists
|
||||
|
||||
When search returns no results in Users/Groups/Roles lists, show centered muted text: "No users match your search" (etc.) inside the `.entityList` area.
|
||||
|
||||
---
|
||||
|
||||
## Tier 4: Polish
|
||||
|
||||
### 4.1 Replace lock emoji with Badge
|
||||
|
||||
`RolesTab.tsx`: replace `🔒` with `<Badge label="system" color="auto" variant="outlined" />`.
|
||||
|
||||
### 4.2 Fix split-pane border radius
|
||||
|
||||
`UserManagement.module.css`: change `border-radius: var(--radius-md)` to `var(--radius-lg)` on `.splitPane`, `.listPane`, `.detailPane`.
|
||||
|
||||
### 4.3 Add shadow to split-pane
|
||||
|
||||
`UserManagement.module.css`: add `box-shadow: var(--shadow-card)` to `.splitPane`.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Replacing split-pane with DataTable+DetailPanel (not appropriate for dense editing)
|
||||
- EventFeed as alternative audit view (future enhancement)
|
||||
- Tabs inside user detail pane (not needed until more sections are added)
|
||||
- FilterBar extension to support DateRangePicker slots (separate design system ticket)
|
||||
173
docs/superpowers/specs/2026-03-24-login-dialog-design.md
Normal file
173
docs/superpowers/specs/2026-03-24-login-dialog-design.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Login Dialog Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
A composable login component for the Cameleer3 design system. Provides a `LoginForm` content component and a `LoginDialog` wrapper that puts it inside a Modal. Supports username/password credentials, configurable social/SSO providers, and built-in client-side validation.
|
||||
|
||||
## Components
|
||||
|
||||
### LoginForm
|
||||
|
||||
Core form component. Lives in `src/design-system/composites/LoginForm/`.
|
||||
|
||||
```tsx
|
||||
interface SocialProvider {
|
||||
label: string // e.g. "Continue with Google"
|
||||
icon?: ReactNode // SVG icon, optional
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
interface LoginFormProps {
|
||||
logo?: ReactNode
|
||||
title?: string // Default: "Sign in"
|
||||
socialProviders?: SocialProvider[] // Omit or [] to hide social section + divider
|
||||
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void // Omit to hide credentials section
|
||||
onForgotPassword?: () => void // Omit to hide link
|
||||
onSignUp?: () => void // Omit to hide "Don't have an account?"
|
||||
error?: string // Server-side error, rendered as Alert
|
||||
loading?: boolean // Disables form, spinner on submit button
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
### LoginDialog
|
||||
|
||||
Thin wrapper — passes all `LoginFormProps` through to `LoginForm`, adds Modal control.
|
||||
|
||||
```tsx
|
||||
interface LoginDialogProps extends LoginFormProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
```
|
||||
|
||||
Uses `Modal` with `size="sm"` (400px).
|
||||
|
||||
## Layout
|
||||
|
||||
Social-first ordering, top to bottom:
|
||||
|
||||
1. **Logo slot** — optional `ReactNode` rendered centered above title
|
||||
2. **Title** — "Sign in" default, centered
|
||||
3. **Server error** — `Alert variant="error"` shown when `error` prop is set, between title and social buttons
|
||||
4. **Social buttons** — stacked vertically, each is a `Button variant="secondary"` with icon + label. Hidden when `socialProviders` is empty/omitted.
|
||||
5. **Divider** — horizontal rule with "or" text, centered. Hidden when social section is hidden.
|
||||
6. **Email field** — `FormField` + `Input`, required, placeholder "you@example.com"
|
||||
7. **Password field** — `FormField` + `Input type="password"`, required, placeholder "••••••••"
|
||||
8. **Remember me / Forgot password row** — `Checkbox` on the left, amber link on the right. Forgot password link hidden when `onForgotPassword` omitted.
|
||||
9. **Submit button** — `Button variant="primary"`, full width, label "Sign in"
|
||||
10. **Sign up link** — "Don't have an account? Sign up" centered below. Hidden when `onSignUp` omitted.
|
||||
|
||||
### Configuration Variants
|
||||
|
||||
The form adapts automatically based on props:
|
||||
|
||||
- **Full** — `socialProviders` + `onSubmit` both provided. Social buttons, divider, and credentials all shown.
|
||||
- **Credentials only** — `onSubmit` provided, no `socialProviders`. Social section and divider hidden.
|
||||
- **Social only** — `socialProviders` provided, `onSubmit` omitted. Credentials section (email, password, remember me, submit button) and divider hidden.
|
||||
|
||||
## Validation
|
||||
|
||||
Client-side, triggered on form submit (not on blur):
|
||||
|
||||
| Field | Rule | Error message |
|
||||
|----------|---------------------------------------------------|----------------------------------------|
|
||||
| Email | Required | "Email is required" |
|
||||
| Email | Basic format: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` | "Please enter a valid email address" |
|
||||
| Password | Required | "Password is required" |
|
||||
| Password | Minimum 8 characters | "Password must be at least 8 characters" |
|
||||
|
||||
- `onSubmit` only fires when all validation passes
|
||||
- Field errors displayed inline below each input using `FormField` error pattern (red border + message)
|
||||
- Field errors clear when the user starts typing in that field
|
||||
- Server `error` prop clears automatically on next submit attempt
|
||||
|
||||
## States
|
||||
|
||||
### Loading
|
||||
|
||||
When `loading={true}`:
|
||||
- All inputs disabled
|
||||
- All social buttons disabled
|
||||
- Submit button shows `Spinner` component, text hidden (matches existing `Button loading` pattern)
|
||||
- Form cannot be submitted
|
||||
|
||||
### Error
|
||||
|
||||
- Server error: `Alert variant="error"` rendered between title and social buttons
|
||||
- Field errors: inline below each input via `FormField` error styling (red border, error text)
|
||||
|
||||
## Styling
|
||||
|
||||
- CSS Modules: `LoginForm.module.css`
|
||||
- All colors via CSS custom properties from `tokens.css`
|
||||
- Dark mode works automatically — no extra overrides needed
|
||||
- Social buttons: `var(--bg-surface)` background, `var(--border)` border, hover uses `var(--bg-hover)`
|
||||
- Divider: `var(--border)` line, `var(--text-muted)` "or" text
|
||||
- Forgot password + Sign up links: `var(--amber)` color, `font-weight: 500`
|
||||
- Form gap: 14px between fields
|
||||
- Social button gap: 8px between buttons
|
||||
|
||||
## Accessibility
|
||||
|
||||
- `<form>` element with `aria-label="Sign in"`
|
||||
- Labels tied to inputs via `htmlFor`/`id`
|
||||
- Error messages linked with `aria-describedby`
|
||||
- First input auto-focused on mount
|
||||
- `LoginDialog` traps focus via Modal
|
||||
- Social buttons are `<button>` elements, keyboard-navigable
|
||||
- Alert uses `role="alert"` for screen readers
|
||||
- Enter key submits form (standard `<form onSubmit>`)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/design-system/composites/LoginForm/
|
||||
LoginForm.tsx
|
||||
LoginForm.module.css
|
||||
LoginForm.test.tsx
|
||||
LoginDialog.tsx
|
||||
LoginDialog.test.tsx
|
||||
```
|
||||
|
||||
Exports added to `src/design-system/composites/index.ts`.
|
||||
|
||||
## Primitives Reused
|
||||
|
||||
- `FormField` — label + error display wrapper
|
||||
- `Input` — email and password fields
|
||||
- `Checkbox` — remember me
|
||||
- `Button` — submit (primary) + social buttons (secondary)
|
||||
- `Alert` — server error display
|
||||
- `Spinner` — loading state in submit button
|
||||
- `Modal` — LoginDialog wrapper
|
||||
|
||||
## Testing
|
||||
|
||||
Tests with Vitest + React Testing Library, wrapped in `ThemeProvider`.
|
||||
|
||||
### LoginForm tests:
|
||||
- Renders all elements when all props provided
|
||||
- Hides social section when `socialProviders` is empty
|
||||
- Hides divider when no social providers
|
||||
- Hides forgot password link when `onForgotPassword` omitted
|
||||
- Hides sign up link when `onSignUp` omitted
|
||||
- Shows server error Alert when `error` prop set
|
||||
- Validates required email
|
||||
- Validates email format
|
||||
- Validates required password
|
||||
- Validates password minimum length
|
||||
- Clears field errors on typing
|
||||
- Calls `onSubmit` with credentials when valid
|
||||
- Does not call `onSubmit` when validation fails
|
||||
- Disables form when `loading={true}`
|
||||
- Shows spinner on submit button when loading
|
||||
- Calls social provider `onClick` when clicked
|
||||
- Calls `onForgotPassword` when link clicked
|
||||
- Calls `onSignUp` when link clicked
|
||||
|
||||
### LoginDialog tests:
|
||||
- Renders Modal with LoginForm when `open={true}`
|
||||
- Does not render when `open={false}`
|
||||
- Calls `onClose` on backdrop click / Esc
|
||||
- Passes all LoginForm props through
|
||||
295
docs/superpowers/specs/2026-03-24-mock-deviations-design.md
Normal file
295
docs/superpowers/specs/2026-03-24-mock-deviations-design.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Mock UI Deviations — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
The mock pages in `src/pages/` build several UI patterns using raw CSS and inline HTML that should either be promoted into the design system or refactored to use existing components. This spec captures each deviation and its resolution to minimize rework when transitioning to the real application.
|
||||
|
||||
## Decision Framework
|
||||
|
||||
A pattern is promoted to the design system when it:
|
||||
- Appears on 2+ pages with the same structure
|
||||
- Is visually distinctive and would be inconsistent if reimplemented
|
||||
- Will be needed by the real application
|
||||
|
||||
A pattern stays in the pages when it is page-specific composition or a one-off layout.
|
||||
|
||||
---
|
||||
|
||||
## 1. KpiStrip — New Composite
|
||||
|
||||
**Problem:** Dashboard, Routes, and AgentHealth each build a custom KPI header strip (~320 lines of duplicated layout code). Same visual structure: horizontal row of cards with colored left border, uppercase label, large value, trend indicator, subtitle, and optional sparkline.
|
||||
|
||||
**Solution:** New composite `KpiStrip`.
|
||||
|
||||
```tsx
|
||||
interface KpiItem {
|
||||
label: string
|
||||
value: string | number
|
||||
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||
subtitle?: string
|
||||
sparkline?: number[]
|
||||
borderColor?: string // CSS token, e.g. "var(--success)"
|
||||
}
|
||||
|
||||
interface KpiStripProps {
|
||||
items: KpiItem[]
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:**
|
||||
- Horizontal flex row with equal-width cards
|
||||
- Each card: 3px left border (colored via `borderColor`, default `var(--amber)`), padding 16px 20px
|
||||
- Card surface: `var(--bg-surface)`, border: `var(--border-subtle)`, radius: `var(--radius-md)`
|
||||
- Label: 11px uppercase, monospace weight 500, `var(--text-muted)`
|
||||
- Value: 28px, weight 700, `var(--text-primary)`
|
||||
- Trend: inline next to value, 11px. Color controlled by `trend.variant` (maps to semantic tokens). Default `'muted'`. The caller decides what color a trend should be — "↑ +12%" on error count is `'error'`, on throughput is `'success'`.
|
||||
- Subtitle: 11px, `var(--text-secondary)`
|
||||
- Sparkline: existing `Sparkline` primitive rendered top-right of card
|
||||
|
||||
**Note:** KpiStrip builds its own card-like containers internally. It does NOT reuse the `Card` primitive because `Card` uses a top accent border while KpiStrip needs a left border. The visual surface (bg, border, radius, shadow) uses the same tokens but the layout is distinct.
|
||||
|
||||
**File location:** `src/design-system/composites/KpiStrip/`
|
||||
|
||||
**Pages to refactor:** Dashboard.tsx, Routes.tsx, AgentHealth.tsx — replace inline `KpiHeader` functions with `<KpiStrip items={[...]} />`.
|
||||
|
||||
---
|
||||
|
||||
## 2. SplitPane — New Composite
|
||||
|
||||
**Problem:** Admin RBAC tabs (UsersTab, GroupsTab, RolesTab) each build a custom CSS grid split-pane layout with scrollable list, detail panel, and empty state placeholder.
|
||||
|
||||
**Solution:** New composite `SplitPane`.
|
||||
|
||||
```tsx
|
||||
interface SplitPaneProps {
|
||||
list: ReactNode
|
||||
detail: ReactNode | null // null renders empty state
|
||||
emptyMessage?: string // Default: "Select an item to view details"
|
||||
ratio?: '1:1' | '1:2' | '2:3' // Default: '1:2'
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:**
|
||||
- CSS grid with two columns at the specified ratio
|
||||
- Left panel: scrollable, `var(--bg-surface)` background, right border `var(--border-subtle)`
|
||||
- Right panel: scrollable, `var(--bg-raised)` background
|
||||
- Empty state: centered text, `var(--text-muted)`, italic
|
||||
- Both panels fill available height (the parent controls the overall height)
|
||||
|
||||
**File location:** `src/design-system/composites/SplitPane/`
|
||||
|
||||
**Pages to refactor:** UsersTab.tsx, GroupsTab.tsx, RolesTab.tsx — replace custom grid CSS with `<SplitPane>`.
|
||||
|
||||
---
|
||||
|
||||
## 2b. EntityList — New Composite
|
||||
|
||||
**Problem:** The left-side list panels in UsersTab, GroupsTab, and RolesTab all build the same frame: a search input + "Add" button header, a scrollable list of items (avatar + text + badges), and selection highlighting. Each tab re-implements this frame with ~50 lines of identical structure.
|
||||
|
||||
**Solution:** New composite `EntityList`.
|
||||
|
||||
```tsx
|
||||
interface EntityListProps<T> {
|
||||
items: T[]
|
||||
renderItem: (item: T, isSelected: boolean) => ReactNode
|
||||
getItemId: (item: T) => string
|
||||
selectedId?: string
|
||||
onSelect?: (id: string) => void
|
||||
searchPlaceholder?: string // Default: "Search..."
|
||||
onSearch?: (query: string) => void
|
||||
addLabel?: string // e.g. "+ Add user" — omit to hide button
|
||||
onAdd?: () => void
|
||||
emptyMessage?: string // Default: "No items found"
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:**
|
||||
- Header row: `Input` (search, with icon) on the left, `Button variant="secondary" size="sm"` (add) on the right. Header hidden when both `onSearch` and `onAdd` are omitted.
|
||||
- Scrollable list below header, `var(--bg-surface)` background
|
||||
- Each item: clickable row with `var(--bg-hover)` on hover, `var(--amber-bg)` + left amber border when selected
|
||||
- Items rendered via `renderItem` — the component provides the clickable row wrapper, the caller provides the content
|
||||
- `role="listbox"` on the list, `role="option"` on each item for accessibility
|
||||
- Empty state: centered `emptyMessage` text when `items` is empty
|
||||
|
||||
**Typical item content (provided by caller via `renderItem`):**
|
||||
- Avatar + name + subtitle + badge tags — but this is not prescribed by EntityList. The component is agnostic about item content.
|
||||
|
||||
**Combined usage with SplitPane:**
|
||||
```tsx
|
||||
<SplitPane
|
||||
list={
|
||||
<EntityList
|
||||
items={filteredUsers}
|
||||
renderItem={(user, isSelected) => (
|
||||
<>
|
||||
<Avatar name={user.name} size="sm" />
|
||||
<div>
|
||||
<div>{user.name}</div>
|
||||
<div>{user.email}</div>
|
||||
<div>{user.roles.map(r => <Badge key={r} label={r} />)}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
getItemId={(u) => u.id}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
searchPlaceholder="Search users..."
|
||||
onSearch={setSearchQuery}
|
||||
addLabel="+ Add user"
|
||||
onAdd={() => setAddDialogOpen(true)}
|
||||
/>
|
||||
}
|
||||
detail={selectedUser ? <UserDetail user={selectedUser} /> : null}
|
||||
/>
|
||||
```
|
||||
|
||||
**File location:** `src/design-system/composites/EntityList/`
|
||||
|
||||
**Pages to refactor:** UsersTab.tsx, GroupsTab.tsx, RolesTab.tsx — replace custom list rendering with `<EntityList>`. Combined with SplitPane, each tab reduces from ~200 lines to ~50 lines of domain-specific render logic.
|
||||
|
||||
---
|
||||
|
||||
## 3. Refactor AgentHealth Instance Table to DataTable
|
||||
|
||||
**Problem:** AgentHealth builds instance tables using raw HTML `<table>` elements instead of the existing `DataTable` composite.
|
||||
|
||||
**Solution:** Refactor to use `DataTable` with column definitions and custom cell renderers. No design system changes needed.
|
||||
|
||||
**Refactor scope:**
|
||||
- Replace `<table>` blocks in AgentHealth.tsx (~60 lines) with `<DataTable>` using `flush` prop
|
||||
- Define columns with `render` functions for State (Badge) and StatusDot columns
|
||||
- Remove associated table CSS from AgentHealth.module.css
|
||||
|
||||
---
|
||||
|
||||
## 4. LogViewer — New Composite
|
||||
|
||||
**Problem:** AgentInstance renders log entries as custom HTML with inline styling — timestamped lines with severity levels in monospace.
|
||||
|
||||
**Solution:** New composite `LogViewer`.
|
||||
|
||||
```tsx
|
||||
interface LogEntry {
|
||||
timestamp: string
|
||||
level: 'info' | 'warn' | 'error' | 'debug'
|
||||
message: string
|
||||
}
|
||||
|
||||
interface LogViewerProps {
|
||||
entries: LogEntry[]
|
||||
maxHeight?: number | string // Default: 400
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:**
|
||||
- Scrollable container with `max-height`, `var(--bg-inset)` background, `var(--radius-md)` border-radius
|
||||
- Each line: flex row with timestamp (muted, monospace, 11px) + level badge + message (monospace, 12px)
|
||||
- Level badge colors: info=`var(--running)`, warn=`var(--warning)`, error=`var(--error)`, debug=`var(--text-muted)`
|
||||
- Level badge: uppercase, 9px, `var(--font-mono)`, pill-shaped with tinted background
|
||||
- Auto-scroll to bottom on new entries; pauses when user scrolls up; resumes on scroll-to-bottom
|
||||
|
||||
**File location:** `src/design-system/composites/LogViewer/`
|
||||
|
||||
**Pages to refactor:** AgentInstance.tsx — replace custom log rendering with `<LogViewer entries={logs} />`.
|
||||
|
||||
---
|
||||
|
||||
## 5. StatusText — New Primitive
|
||||
|
||||
**Problem:** Dashboard and Routes use inline `style={{ color: 'var(--error)', fontWeight: 600 }}` for status values like "BREACH", "OK", colored percentages.
|
||||
|
||||
**Solution:** New primitive `StatusText`.
|
||||
|
||||
```tsx
|
||||
interface StatusTextProps {
|
||||
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
|
||||
bold?: boolean // Default: false
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
- Inline `<span>` element
|
||||
- Color mapped to semantic tokens: success=`var(--success)`, warning=`var(--warning)`, error=`var(--error)`, running=`var(--running)`, muted=`var(--text-muted)`
|
||||
- `bold` adds `font-weight: 600`
|
||||
- Inherits font-size from parent
|
||||
|
||||
**File location:** `src/design-system/primitives/StatusText/`
|
||||
|
||||
**Pages to refactor:** Dashboard.tsx, Routes.tsx — replace inline style attributes with `<StatusText>`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Card Title Extension
|
||||
|
||||
**Problem:** Routes page wraps charts in custom divs with uppercase titles. The existing `Card` component has no title support.
|
||||
|
||||
**Solution:** Add optional `title` prop to existing `Card` primitive.
|
||||
|
||||
```tsx
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
accent?: string // Existing
|
||||
title?: string // NEW
|
||||
className?: string // Existing
|
||||
}
|
||||
```
|
||||
|
||||
**When `title` is provided:**
|
||||
- Renders a header div inside the card, above children
|
||||
- Title: 11px uppercase, `var(--font-mono)`, weight 600, `var(--text-secondary)`, letter-spacing 0.5px
|
||||
- Separated from content by 1px `var(--border-subtle)` bottom border and 12px padding-bottom
|
||||
- Content area gets 16px padding-top
|
||||
|
||||
**File location:** Modify existing `src/design-system/primitives/Card/Card.tsx`
|
||||
|
||||
**Pages to refactor:** Routes.tsx — replace custom chart wrapper divs with `<Card title="Throughput (msg/s)">`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **KpiStrip** — highest impact, 3 pages, ~320 lines eliminated
|
||||
2. **StatusText** — smallest scope, quick win, unblocks cleaner page code
|
||||
3. **Card title** — small change to existing component, unblocks Routes cleanup
|
||||
4. **SplitPane + EntityList** — 3 admin tabs, clean pattern. Build together since EntityList is the natural content for SplitPane's list slot.
|
||||
5. **LogViewer** — 1 page but important for real app
|
||||
6. **AgentHealth DataTable refactor** — pure page cleanup, no DS changes
|
||||
|
||||
## Testing
|
||||
|
||||
All new components tested with Vitest + React Testing Library, co-located test files. Page refactors verified by running existing tests + visual check that pages look identical before and after.
|
||||
|
||||
## Barrel Exports
|
||||
|
||||
New components added to respective barrel exports:
|
||||
- `src/design-system/primitives/index.ts` — StatusText
|
||||
- `src/design-system/composites/index.ts` — KpiStrip, SplitPane, EntityList, LogViewer
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
### COMPONENT_GUIDE.md
|
||||
|
||||
Add entries for each new component to the appropriate decision trees:
|
||||
|
||||
- **Data Display section:** Add KpiStrip — "Use KpiStrip for a row of summary metrics at the top of a page (exchanges, error rate, latency, etc.)"
|
||||
- **Data Display section:** Add LogViewer — "Use LogViewer for scrollable log output with timestamped, severity-colored entries"
|
||||
- **Layout section:** Add SplitPane — "Use SplitPane for master/detail layouts: selectable list on the left, detail view on the right"
|
||||
- **Data Display section:** Add EntityList — "Use EntityList for searchable, selectable lists of entities (users, groups, roles, etc.). Combine with SplitPane for CRUD management screens."
|
||||
- **Text & Labels section:** Add StatusText — "Use StatusText for inline colored status values (success rates, SLA status, trend indicators). Use StatusDot for colored dot indicators."
|
||||
- **Card section:** Document new `title` prop — "Pass `title` to Card for a titled content container (e.g., chart cards). Title renders as an uppercase header with separator."
|
||||
|
||||
### Inventory Page
|
||||
|
||||
Add demos for each new component to `src/pages/Inventory/sections/`:
|
||||
|
||||
- **CompositesSection.tsx:** Add KpiStrip, SplitPane, EntityList, LogViewer demos with realistic sample data
|
||||
- **PrimitivesSection.tsx:** Add StatusText demo showing all variants
|
||||
- **Card demo:** Update existing Card demo to show the `title` prop variant
|
||||
|
||||
Each demo follows the existing DemoCard pattern with `id` anchors, and nav entries are added to `Inventory.tsx`.
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "cameleer3",
|
||||
"version": "0.0.0",
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cameleer3",
|
||||
"version": "0.0.0",
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -24,6 +25,11 @@
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -925,6 +931,22 @@
|
||||
"resolve": "~1.22.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2874,6 +2896,53 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"main": "./dist/index.es.js",
|
||||
"module": "./dist/index.es.js",
|
||||
@@ -12,8 +12,12 @@
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"sideEffects": ["*.css"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||
},
|
||||
@@ -27,7 +31,8 @@
|
||||
"build:lib": "vite build --config vite.lib.config.ts",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
@@ -40,6 +45,7 @@
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -1,12 +1,14 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Dashboard } from './pages/Dashboard/Dashboard'
|
||||
import { Metrics } from './pages/Metrics/Metrics'
|
||||
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
||||
import { Routes as RoutesPage } from './pages/Routes/Routes'
|
||||
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
|
||||
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
|
||||
import { AgentInstance } from './pages/AgentInstance/AgentInstance'
|
||||
import { Inventory } from './pages/Inventory/Inventory'
|
||||
import { Admin } from './pages/Admin/Admin'
|
||||
import { AuditLog } from './pages/Admin/AuditLog/AuditLog'
|
||||
import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig'
|
||||
import { UserManagement } from './pages/Admin/UserManagement/UserManagement'
|
||||
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
|
||||
|
||||
import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette'
|
||||
@@ -17,32 +19,31 @@ import { buildSearchData } from './mocks/searchData'
|
||||
import { exchanges } from './mocks/exchanges'
|
||||
import { routes } from './mocks/routes'
|
||||
import { agents } from './mocks/agents'
|
||||
import { SIDEBAR_APPS } from './mocks/sidebar'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
|
||||
|
||||
const routeToApp = buildRouteToAppMap()
|
||||
|
||||
/** Compute which sidebar path to reveal for a given search result */
|
||||
function computeSidebarRevealPath(result: SearchResult): string | undefined {
|
||||
if (!result.path) return undefined
|
||||
|
||||
if (result.category === 'application') {
|
||||
// /apps/:id — already a sidebar node path
|
||||
return result.path
|
||||
}
|
||||
|
||||
if (result.category === 'route') {
|
||||
// /routes/:id — already a sidebar node path
|
||||
return result.path
|
||||
}
|
||||
|
||||
if (result.category === 'agent') {
|
||||
// /agents/:appId/:agentId — already a sidebar node path
|
||||
return result.path
|
||||
}
|
||||
|
||||
if (result.category === 'exchange') {
|
||||
// /exchanges/:id — no sidebar entry; resolve to the parent route
|
||||
const exchange = exchanges.find((e) => e.id === result.id)
|
||||
if (exchange) {
|
||||
return `/routes/${exchange.route}`
|
||||
const appId = routeToApp.get(exchange.route)
|
||||
if (appId) return `/apps/${appId}/${exchange.route}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,11 +81,17 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="/apps" element={<Dashboard />} />
|
||||
<Route path="/apps/:id" element={<Dashboard />} />
|
||||
<Route path="/metrics" element={<Metrics />} />
|
||||
<Route path="/routes/:id" element={<RouteDetail />} />
|
||||
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/routes/:appId" element={<RoutesPage />} />
|
||||
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
|
||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
|
||||
<Route path="/agents/*" element={<AgentHealth />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||||
<Route path="/admin/audit" element={<AuditLog />} />
|
||||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||
<Route path="/api-docs" element={<ApiDocs />} />
|
||||
<Route path="/inventory" element={<Inventory />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
message: 'Delete user "alice"? This cannot be undone.',
|
||||
confirmText: 'alice',
|
||||
}
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('renders title and message when open', () => {
|
||||
render(<ConfirmDialog {...defaultProps} />)
|
||||
expect(screen.getByText('Confirm Deletion')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete user "alice"? This cannot be undone.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<ConfirmDialog {...defaultProps} open={false} />)
|
||||
expect(screen.queryByText('Confirm Deletion')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom title', () => {
|
||||
render(<ConfirmDialog {...defaultProps} title="Remove item" />)
|
||||
expect(screen.getByText('Remove item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows confirm instruction text', () => {
|
||||
render(<ConfirmDialog {...defaultProps} />)
|
||||
expect(screen.getByText(/Type "alice" to confirm/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables confirm button until text matches', () => {
|
||||
render(<ConfirmDialog {...defaultProps} />)
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables confirm button when text matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ConfirmDialog {...defaultProps} />)
|
||||
await user.type(screen.getByRole('textbox'), 'alice')
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('calls onConfirm when confirm button is clicked after typing', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||||
await user.type(screen.getByRole('textbox'), 'alice')
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onClose when cancel button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ConfirmDialog {...defaultProps} onClose={onClose} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onConfirm on Enter when text matches', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||||
await user.type(screen.getByRole('textbox'), 'alice')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call onConfirm on Enter when text does not match', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||||
await user.type(screen.getByRole('textbox'), 'alic')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables both buttons when loading', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ConfirmDialog {...defaultProps} loading />)
|
||||
await user.type(screen.getByRole('textbox'), 'alice')
|
||||
const buttons = screen.getAllByRole('button')
|
||||
for (const btn of buttons) {
|
||||
expect(btn).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears input when opened', async () => {
|
||||
const { rerender } = render(<ConfirmDialog {...defaultProps} open={false} />)
|
||||
rerender(<ConfirmDialog {...defaultProps} open={true} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-focuses input on open', async () => {
|
||||
render(<ConfirmDialog {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders custom button labels', () => {
|
||||
render(<ConfirmDialog {...defaultProps} confirmLabel="Remove" cancelLabel="Keep" />)
|
||||
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
97
src/design-system/composites/ConfirmDialog/ConfirmDialog.tsx
Normal file
97
src/design-system/composites/ConfirmDialog/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Modal } from '../Modal/Modal'
|
||||
import { Button } from '../../primitives/Button/Button'
|
||||
import styles from './ConfirmDialog.module.css'
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title?: string
|
||||
message: string
|
||||
confirmText: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = 'Confirm Deletion',
|
||||
message,
|
||||
confirmText,
|
||||
confirmLabel = 'Delete',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'danger',
|
||||
loading = false,
|
||||
className,
|
||||
}: ConfirmDialogProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const matches = input === confirmText
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInput('')
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' && matches && !loading) {
|
||||
e.preventDefault()
|
||||
onConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmButtonVariant = variant === 'danger' ? 'danger' : 'primary'
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||||
<div className={styles.content}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<p className={styles.message}>{message}</p>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label} htmlFor="confirm-input">
|
||||
{`Type "${confirmText}" to confirm`}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="confirm-input"
|
||||
className={styles.input}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmButtonVariant}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
disabled={!matches || loading}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
|
||||
rowAccent,
|
||||
expandedContent,
|
||||
flush = false,
|
||||
onSortChange,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
@@ -31,14 +32,16 @@ export function DataTable<T extends { id: string }>({
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
// When onSortChange is provided (controlled mode), skip client-side sorting
|
||||
const sorted = useMemo(() => {
|
||||
if (onSortChange) return data
|
||||
if (!sortKey) return data
|
||||
return [...data].sort((a, b) => {
|
||||
const av = (a as Record<string, unknown>)[sortKey]
|
||||
const bv = (b as Record<string, unknown>)[sortKey]
|
||||
return compareValues(av, bv, sortDir)
|
||||
})
|
||||
}, [data, sortKey, sortDir])
|
||||
}, [data, sortKey, sortDir, onSortChange])
|
||||
|
||||
const totalRows = sorted.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
||||
@@ -52,13 +55,17 @@ export function DataTable<T extends { id: string }>({
|
||||
|
||||
function handleHeaderClick(col: Column<T>) {
|
||||
if (!sortable && !col.sortable) return
|
||||
let newDir: SortDir
|
||||
if (sortKey === col.key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
newDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||
setSortDir(newDir)
|
||||
} else {
|
||||
newDir = 'asc'
|
||||
setSortKey(col.key)
|
||||
setSortDir('asc')
|
||||
setSortDir(newDir)
|
||||
}
|
||||
setPage(1)
|
||||
onSortChange?.(col.key, newDir)
|
||||
}
|
||||
|
||||
function handleRowClick(row: T) {
|
||||
|
||||
@@ -20,4 +20,8 @@ export interface DataTableProps<T extends { id: string }> {
|
||||
expandedContent?: (row: T) => ReactNode | null
|
||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||
flush?: boolean
|
||||
/** Controlled sort: called when the user clicks a sortable column header.
|
||||
* When provided, the component skips client-side sorting — the caller is
|
||||
* responsible for providing `data` in the desired order. */
|
||||
onSortChange?: (key: string, dir: 'asc' | 'desc') => void
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './DetailPanel.module.css'
|
||||
|
||||
interface Tab {
|
||||
@@ -11,17 +12,18 @@ interface DetailPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
tabs: Tab[]
|
||||
tabs?: Tab[]
|
||||
children?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DetailPanel({ open, onClose, title, tabs, actions, className }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(tabs[0]?.value ?? '')
|
||||
export function DetailPanel({ open, onClose, title, tabs, children, actions, className }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(tabs?.[0]?.value ?? '')
|
||||
|
||||
const activeContent = tabs.find((t) => t.value === activeTab)?.content
|
||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||
|
||||
return (
|
||||
const panel = (
|
||||
<aside
|
||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||
aria-hidden={!open}
|
||||
@@ -38,7 +40,7 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tabs.length > 0 && (
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
@@ -54,7 +56,7 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
|
||||
)}
|
||||
|
||||
<div className={styles.body}>
|
||||
{activeContent}
|
||||
{children ?? activeContent}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
@@ -64,4 +66,8 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
|
||||
// Portal to AppShell level if target exists, otherwise render in place
|
||||
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||
return portalTarget ? createPortal(panel, portalTarget) : panel
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.entityListRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.listHeaderSearch {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entityItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.entityItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.entityItemSelected {
|
||||
background: var(--amber-bg);
|
||||
border-left: 3px solid var(--amber);
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
167
src/design-system/composites/EntityList/EntityList.test.tsx
Normal file
167
src/design-system/composites/EntityList/EntityList.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { EntityList } from './EntityList'
|
||||
|
||||
interface TestItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const items: TestItem[] = [
|
||||
{ id: '1', name: 'Alpha' },
|
||||
{ id: '2', name: 'Beta' },
|
||||
{ id: '3', name: 'Gamma' },
|
||||
]
|
||||
|
||||
describe('EntityList', () => {
|
||||
it('renders all items', () => {
|
||||
render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect when item clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByText('Beta'))
|
||||
expect(onSelect).toHaveBeenCalledWith('2')
|
||||
})
|
||||
|
||||
it('highlights selected item (aria-selected="true" and has selected class)', () => {
|
||||
render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
selectedId="2"
|
||||
/>
|
||||
)
|
||||
const selectedOption = screen.getByText('Beta').closest('[role="option"]')
|
||||
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
const unselectedOption = screen.getByText('Alpha').closest('[role="option"]')
|
||||
expect(unselectedOption).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
it('renders search input when onSearch provided', () => {
|
||||
render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
onSearch={() => {}}
|
||||
searchPlaceholder="Filter items..."
|
||||
/>
|
||||
)
|
||||
expect(screen.getByPlaceholderText('Filter items...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSearch when typing in search', async () => {
|
||||
const onSearch = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
)
|
||||
const input = screen.getByPlaceholderText('Search...')
|
||||
await user.type(input, 'test')
|
||||
expect(onSearch).toHaveBeenLastCalledWith('test')
|
||||
})
|
||||
|
||||
it('renders add button when onAdd provided', () => {
|
||||
render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
onAdd={() => {}}
|
||||
addLabel="Add Item"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Add Item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onAdd when add button clicked', async () => {
|
||||
const onAdd = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
onAdd={onAdd}
|
||||
addLabel="Add Item"
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByText('Add Item'))
|
||||
expect(onAdd).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides header when no search or add', () => {
|
||||
const { container } = render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
/>
|
||||
)
|
||||
// No input or button should be present in the header area
|
||||
expect(container.querySelector('input')).toBeNull()
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows empty message when items is empty', () => {
|
||||
render(
|
||||
<EntityList
|
||||
items={[]}
|
||||
renderItem={(item: TestItem) => <span>{item.name}</span>}
|
||||
getItemId={(item: TestItem) => item.id}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('No items found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows custom empty message', () => {
|
||||
render(
|
||||
<EntityList
|
||||
items={[]}
|
||||
renderItem={(item: TestItem) => <span>{item.name}</span>}
|
||||
getItemId={(item: TestItem) => item.id}
|
||||
emptyMessage="Nothing here"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts className', () => {
|
||||
const { container } = render(
|
||||
<EntityList
|
||||
items={items}
|
||||
renderItem={(item) => <span>{item.name}</span>}
|
||||
getItemId={(item) => item.id}
|
||||
className="custom-class"
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
97
src/design-system/composites/EntityList/EntityList.tsx
Normal file
97
src/design-system/composites/EntityList/EntityList.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Input } from '../../primitives/Input/Input'
|
||||
import { Button } from '../../primitives/Button/Button'
|
||||
import styles from './EntityList.module.css'
|
||||
|
||||
interface EntityListProps<T> {
|
||||
items: T[]
|
||||
renderItem: (item: T, isSelected: boolean) => ReactNode
|
||||
getItemId: (item: T) => string
|
||||
selectedId?: string
|
||||
onSelect?: (id: string) => void
|
||||
searchPlaceholder?: string
|
||||
onSearch?: (query: string) => void
|
||||
addLabel?: string
|
||||
onAdd?: () => void
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EntityList<T>({
|
||||
items,
|
||||
renderItem,
|
||||
getItemId,
|
||||
selectedId,
|
||||
onSelect,
|
||||
searchPlaceholder = 'Search...',
|
||||
onSearch,
|
||||
addLabel,
|
||||
onAdd,
|
||||
emptyMessage = 'No items found',
|
||||
className,
|
||||
}: EntityListProps<T>) {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const showHeader = !!onSearch || !!onAdd
|
||||
|
||||
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = e.target.value
|
||||
setSearchValue(value)
|
||||
onSearch?.(value)
|
||||
}
|
||||
|
||||
function handleSearchClear() {
|
||||
setSearchValue('')
|
||||
onSearch?.('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
|
||||
{showHeader && (
|
||||
<div className={styles.listHeader}>
|
||||
{onSearch && (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onClear={handleSearchClear}
|
||||
className={styles.listHeaderSearch}
|
||||
/>
|
||||
)}
|
||||
{onAdd && addLabel && (
|
||||
<Button size="sm" variant="secondary" onClick={onAdd}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list} role="listbox">
|
||||
{items.map((item) => {
|
||||
const id = getItemId(item)
|
||||
const isSelected = id === selectedId
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||
onClick={() => onSelect?.(id)}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect?.(id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderItem(item, isSelected)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<div className={styles.emptyMessage}>{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import styles from './EventFeed.module.css'
|
||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
|
||||
export interface FeedEvent {
|
||||
id: string
|
||||
@@ -53,6 +54,13 @@ const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
||||
running: '\u2699', // ⚙
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
||||
error: 'var(--error)',
|
||||
warning: 'var(--warning)',
|
||||
success: 'var(--success)',
|
||||
running: 'var(--running)',
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<SeverityFilter, string> = {
|
||||
error: 'Error',
|
||||
warning: 'Warning',
|
||||
@@ -133,18 +141,15 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.filters}>
|
||||
{allSeverities.map((sev) => {
|
||||
const count = events.filter((e) => e.severity === sev).length
|
||||
return (
|
||||
<FilterPill
|
||||
key={sev}
|
||||
label={SEVERITY_LABELS[sev]}
|
||||
count={count}
|
||||
active={activeFilters.has(sev)}
|
||||
onClick={() => toggleFilter(sev)}
|
||||
<ButtonGroup
|
||||
items={allSeverities.map((sev): ButtonGroupItem => ({
|
||||
value: sev,
|
||||
label: SEVERITY_LABELS[sev],
|
||||
color: SEVERITY_COLORS[sev],
|
||||
}))}
|
||||
value={activeFilters as Set<string>}
|
||||
onChange={(next) => setActiveFilters(next as Set<SeverityFilter>)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{activeFilters.size > 0 && (
|
||||
<button
|
||||
className={styles.clearBtn}
|
||||
|
||||
79
src/design-system/composites/KpiStrip/KpiStrip.module.css
Normal file
79
src/design-system/composites/KpiStrip/KpiStrip.module.css
Normal file
@@ -0,0 +1,79 @@
|
||||
.kpiStrip {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kpiCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 18px 12px;
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.kpiCard:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.kpiCard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.valueRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trend {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.trendSuccess { color: var(--success); }
|
||||
.trendWarning { color: var(--warning); }
|
||||
.trendError { color: var(--error); }
|
||||
.trendMuted { color: var(--text-muted); }
|
||||
|
||||
.subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sparkline {
|
||||
margin-top: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
86
src/design-system/composites/KpiStrip/KpiStrip.test.tsx
Normal file
86
src/design-system/composites/KpiStrip/KpiStrip.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { KpiStrip } from './KpiStrip'
|
||||
import type { KpiItem } from './KpiStrip'
|
||||
|
||||
const sampleItems: KpiItem[] = [
|
||||
{ label: 'Total', value: 42 },
|
||||
{ label: 'Active', value: '18', trend: { label: '+3', variant: 'success' } },
|
||||
{ label: 'Errors', value: 5, subtitle: 'last 24h', sparkline: [1, 3, 2, 5, 4] },
|
||||
]
|
||||
|
||||
describe('KpiStrip', () => {
|
||||
it('renders all items', () => {
|
||||
const { container } = render(<KpiStrip items={sampleItems} />)
|
||||
const cards = container.querySelectorAll('[class*="kpiCard"]')
|
||||
expect(cards).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders labels and values', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
expect(screen.getByText('Total')).toBeInTheDocument()
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
expect(screen.getByText('18')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders trend with correct text', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
expect(screen.getByText('+3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies variant class to trend (trendSuccess)', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
const trend = screen.getByText('+3')
|
||||
expect(trend.className).toContain('trendSuccess')
|
||||
})
|
||||
|
||||
it('hides trend when omitted', () => {
|
||||
render(<KpiStrip items={[{ label: 'No Trend', value: 10 }]} />)
|
||||
const { container } = render(<KpiStrip items={[{ label: 'No Trend2', value: 10 }]} />)
|
||||
const trends = container.querySelectorAll('[class*="trend"]')
|
||||
expect(trends).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders subtitle', () => {
|
||||
render(<KpiStrip items={sampleItems} />)
|
||||
expect(screen.getByText('last 24h')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders sparkline when data provided', () => {
|
||||
const { container } = render(<KpiStrip items={sampleItems} />)
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('accepts className prop', () => {
|
||||
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
|
||||
expect(container.firstChild).toHaveClass('custom')
|
||||
})
|
||||
|
||||
it('handles empty items array', () => {
|
||||
const { container } = render(<KpiStrip items={[]} />)
|
||||
const cards = container.querySelectorAll('[class*="kpiCard"]')
|
||||
expect(cards).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses default border color (--amber) when borderColor omitted', () => {
|
||||
const { container } = render(<KpiStrip items={[{ label: 'Default', value: 1 }]} />)
|
||||
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
|
||||
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--amber)')
|
||||
})
|
||||
|
||||
it('applies custom borderColor', () => {
|
||||
const items: KpiItem[] = [{ label: 'Custom', value: 1, borderColor: 'var(--teal)' }]
|
||||
const { container } = render(<KpiStrip items={items} />)
|
||||
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
|
||||
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--teal)')
|
||||
})
|
||||
|
||||
it('renders trend with muted variant by default', () => {
|
||||
const items: KpiItem[] = [{ label: 'Muted', value: 1, trend: { label: '0%' } }]
|
||||
render(<KpiStrip items={items} />)
|
||||
const trend = screen.getByText('0%')
|
||||
expect(trend.className).toContain('trendMuted')
|
||||
})
|
||||
})
|
||||
71
src/design-system/composites/KpiStrip/KpiStrip.tsx
Normal file
71
src/design-system/composites/KpiStrip/KpiStrip.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import styles from './KpiStrip.module.css'
|
||||
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
export interface KpiItem {
|
||||
label: string
|
||||
value: string | number
|
||||
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||
subtitle?: string
|
||||
sparkline?: number[]
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
export interface KpiStripProps {
|
||||
items: KpiItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const trendClassMap: Record<string, string> = {
|
||||
success: styles.trendSuccess,
|
||||
warning: styles.trendWarning,
|
||||
error: styles.trendError,
|
||||
muted: styles.trendMuted,
|
||||
}
|
||||
|
||||
export function KpiStrip({ items, className }: KpiStripProps) {
|
||||
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
|
||||
const gridStyle: CSSProperties = {
|
||||
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={stripClasses} style={gridStyle}>
|
||||
{items.map((item) => {
|
||||
const borderColor = item.borderColor ?? 'var(--amber)'
|
||||
const cardStyle: CSSProperties & Record<string, string> = {
|
||||
'--kpi-border-color': borderColor,
|
||||
}
|
||||
const trendVariant = item.trend?.variant ?? 'muted'
|
||||
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
|
||||
|
||||
return (
|
||||
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
|
||||
<div className={styles.label}>{item.label}</div>
|
||||
<div className={styles.valueRow}>
|
||||
<span className={styles.value}>{item.value}</span>
|
||||
{item.trend && (
|
||||
<span className={`${styles.trend} ${trendClass}`}>
|
||||
{item.trend.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.subtitle && (
|
||||
<div className={styles.subtitle}>{item.subtitle}</div>
|
||||
)}
|
||||
{item.sparkline && item.sparkline.length >= 2 && (
|
||||
<div className={styles.sparkline}>
|
||||
<Sparkline
|
||||
data={item.sparkline}
|
||||
color={borderColor}
|
||||
width={200}
|
||||
height={32}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
src/design-system/composites/LogViewer/LogViewer.module.css
Normal file
75
src/design-system/composites/LogViewer/LogViewer.module.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.container {
|
||||
overflow-y: auto;
|
||||
background: var(--bg-inset);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 3px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.line:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.levelBadge {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.levelInfo {
|
||||
color: var(--running);
|
||||
background: color-mix(in srgb, var(--running) 12%, transparent);
|
||||
}
|
||||
|
||||
.levelWarn {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
}
|
||||
|
||||
.levelError {
|
||||
color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||
}
|
||||
|
||||
.levelDebug {
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
56
src/design-system/composites/LogViewer/LogViewer.test.tsx
Normal file
56
src/design-system/composites/LogViewer/LogViewer.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { LogViewer, type LogEntry } from './LogViewer'
|
||||
|
||||
const entries: LogEntry[] = [
|
||||
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' },
|
||||
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
||||
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
||||
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
||||
]
|
||||
|
||||
describe('LogViewer', () => {
|
||||
it('renders entries with timestamps and messages', () => {
|
||||
render(<LogViewer entries={entries} />)
|
||||
expect(screen.getByText('Server started')).toBeInTheDocument()
|
||||
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
|
||||
render(<LogViewer entries={entries} />)
|
||||
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom maxHeight (number)', () => {
|
||||
const { container } = render(<LogViewer entries={entries} maxHeight={300} />)
|
||||
const el = container.firstElementChild as HTMLElement
|
||||
expect(el.style.maxHeight).toBe('300px')
|
||||
})
|
||||
|
||||
it('renders with string maxHeight', () => {
|
||||
const { container } = render(<LogViewer entries={entries} maxHeight="50vh" />)
|
||||
const el = container.firstElementChild as HTMLElement
|
||||
expect(el.style.maxHeight).toBe('50vh')
|
||||
})
|
||||
|
||||
it('handles empty entries', () => {
|
||||
render(<LogViewer entries={[]} />)
|
||||
expect(screen.getByText('No log entries.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts className prop', () => {
|
||||
const { container } = render(<LogViewer entries={entries} className="custom-class" />)
|
||||
const el = container.firstElementChild as HTMLElement
|
||||
expect(el.classList.contains('custom-class')).toBe(true)
|
||||
})
|
||||
|
||||
it('has role="log" for accessibility', () => {
|
||||
render(<LogViewer entries={entries} />)
|
||||
expect(screen.getByRole('log')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
77
src/design-system/composites/LogViewer/LogViewer.tsx
Normal file
77
src/design-system/composites/LogViewer/LogViewer.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import styles from './LogViewer.module.css'
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string
|
||||
level: 'info' | 'warn' | 'error' | 'debug'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LogViewerProps {
|
||||
entries: LogEntry[]
|
||||
maxHeight?: number | string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
||||
info: styles.levelInfo,
|
||||
warn: styles.levelWarn,
|
||||
error: styles.levelError,
|
||||
debug: styles.levelDebug,
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && isAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [entries])
|
||||
|
||||
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={[styles.container, className].filter(Boolean).join(' ')}
|
||||
style={{ maxHeight: heightStyle }}
|
||||
onScroll={handleScroll}
|
||||
role="log"
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<div key={i} className={styles.line}>
|
||||
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
|
||||
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
||||
{entry.level.toUpperCase()}
|
||||
</span>
|
||||
<span className={styles.message}>{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<div className={styles.empty}>No log entries.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
src/design-system/composites/LoginForm/LoginDialog.test.tsx
Normal file
54
src/design-system/composites/LoginForm/LoginDialog.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { LoginDialog } from './LoginDialog'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
}
|
||||
|
||||
describe('LoginDialog', () => {
|
||||
it('renders Modal with LoginForm when open', () => {
|
||||
render(<LoginDialog {...defaultProps} />)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<LoginDialog {...defaultProps} open={false} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose on Esc', async () => {
|
||||
const onClose = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on backdrop click', async () => {
|
||||
const onClose = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||
await user.click(screen.getByTestId('modal-backdrop'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes LoginForm props through', () => {
|
||||
render(
|
||||
<LoginDialog
|
||||
{...defaultProps}
|
||||
title="Welcome"
|
||||
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
|
||||
error="Bad credentials"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Welcome')).toBeInTheDocument()
|
||||
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
15
src/design-system/composites/LoginForm/LoginDialog.tsx
Normal file
15
src/design-system/composites/LoginForm/LoginDialog.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Modal } from '../Modal/Modal'
|
||||
import { LoginForm, type LoginFormProps } from './LoginForm'
|
||||
|
||||
export interface LoginDialogProps extends LoginFormProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||||
<LoginForm {...formProps} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
111
src/design-system/composites/LoginForm/LoginForm.module.css
Normal file
111
src/design-system/composites/LoginForm/LoginForm.module.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.loginForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: var(--font-body);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.error {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.socialSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.socialButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dividerLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.dividerText {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rememberRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.forgotLink {
|
||||
font-size: 11px;
|
||||
color: var(--amber);
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.forgotLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signUpText {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.signUpLink {
|
||||
color: var(--amber);
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signUpLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
193
src/design-system/composites/LoginForm/LoginForm.test.tsx
Normal file
193
src/design-system/composites/LoginForm/LoginForm.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { LoginForm } from './LoginForm'
|
||||
|
||||
const socialProviders = [
|
||||
{ label: 'Continue with Google', onClick: vi.fn() },
|
||||
{ label: 'Continue with GitHub', onClick: vi.fn() },
|
||||
]
|
||||
|
||||
const allProps = {
|
||||
logo: <div data-testid="logo">Logo</div>,
|
||||
title: 'Welcome back',
|
||||
socialProviders,
|
||||
onSubmit: vi.fn(),
|
||||
onForgotPassword: vi.fn(),
|
||||
onSignUp: vi.fn(),
|
||||
}
|
||||
|
||||
describe('LoginForm', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all elements when all props provided', () => {
|
||||
render(<LoginForm {...allProps} />)
|
||||
expect(screen.getByTestId('logo')).toBeInTheDocument()
|
||||
expect(screen.getByText('Welcome back')).toBeInTheDocument()
|
||||
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders default title when title prop omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides social section when socialProviders is empty', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides social section when socialProviders is omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides forgot password link when onForgotPassword omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides sign up link when onSignUp omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides credentials section when onSubmit omitted (social only)', () => {
|
||||
render(<LoginForm socialProviders={socialProviders} />)
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
// Social buttons should still render
|
||||
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows server error Alert when error prop set', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation', () => {
|
||||
it('validates required email', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Email is required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates email format', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.type(screen.getByLabelText(/email/i), 'notanemail')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates required password', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Password is required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates password minimum length', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'short')
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears field errors on typing', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Email is required')).toBeInTheDocument()
|
||||
await user.type(screen.getByLabelText(/email/i), 't')
|
||||
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSubmit with credentials when valid', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={onSubmit} />)
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByLabelText(/remember me/i))
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
remember: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call onSubmit when validation fails', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={onSubmit} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('disables form inputs when loading', () => {
|
||||
render(<LoginForm {...allProps} loading />)
|
||||
expect(screen.getByLabelText(/email/i)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/password/i)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows spinner on submit button when loading', () => {
|
||||
render(<LoginForm {...allProps} loading />)
|
||||
const submitBtn = screen.getByRole('button', { name: /sign in/i })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
// Button component renders Spinner when loading=true
|
||||
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables social buttons when loading', () => {
|
||||
render(<LoginForm {...allProps} loading />)
|
||||
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('calls social provider onClick when clicked', async () => {
|
||||
const onClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onForgotPassword when link clicked', async () => {
|
||||
const onForgotPassword = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
|
||||
await user.click(screen.getByText(/forgot password/i))
|
||||
expect(onForgotPassword).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onSignUp when link clicked', async () => {
|
||||
const onSignUp = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
|
||||
await user.click(screen.getByText(/sign up/i))
|
||||
expect(onSignUp).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
223
src/design-system/composites/LoginForm/LoginForm.tsx
Normal file
223
src/design-system/composites/LoginForm/LoginForm.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
|
||||
import { Button } from '../../primitives/Button/Button'
|
||||
import { Input } from '../../primitives/Input/Input'
|
||||
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
|
||||
import { FormField } from '../../primitives/FormField/FormField'
|
||||
import { Alert } from '../../primitives/Alert/Alert'
|
||||
import styles from './LoginForm.module.css'
|
||||
|
||||
export interface SocialProvider {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface LoginFormProps {
|
||||
logo?: ReactNode
|
||||
title?: string
|
||||
socialProviders?: SocialProvider[]
|
||||
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
|
||||
onForgotPassword?: () => void
|
||||
onSignUp?: () => void
|
||||
error?: string
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface FieldErrors {
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function validate(email: string, password: string): FieldErrors {
|
||||
const errors: FieldErrors = {}
|
||||
if (!email) {
|
||||
errors.email = 'Email is required'
|
||||
} else if (!EMAIL_REGEX.test(email)) {
|
||||
errors.email = 'Please enter a valid email address'
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = 'Password is required'
|
||||
} else if (password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters'
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
logo,
|
||||
title = 'Sign in',
|
||||
socialProviders,
|
||||
onSubmit,
|
||||
onForgotPassword,
|
||||
onSignUp,
|
||||
error,
|
||||
loading = false,
|
||||
className,
|
||||
}: LoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const emailRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Auto-focus first input on mount
|
||||
useEffect(() => {
|
||||
emailRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Reset submitted flag when error prop changes (new server error from re-attempt)
|
||||
useEffect(() => {
|
||||
if (error) setSubmitted(false)
|
||||
}, [error])
|
||||
|
||||
// Server error is shown from prop, hidden after next submit attempt
|
||||
const showServerError = error && !submitted
|
||||
|
||||
const hasSocial = socialProviders && socialProviders.length > 0
|
||||
const hasCredentials = !!onSubmit
|
||||
const showDivider = hasSocial && hasCredentials
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
const errors = validate(email, password)
|
||||
setFieldErrors(errors)
|
||||
if (Object.keys(errors).length === 0) {
|
||||
onSubmit?.({ email, password, remember })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.loginForm} ${className ?? ''}`}>
|
||||
{logo && <div className={styles.logo}>{logo}</div>}
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
|
||||
{showServerError && (
|
||||
<div className={styles.error}>
|
||||
<Alert variant="error">{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSocial && (
|
||||
<div className={styles.socialSection}>
|
||||
{socialProviders.map((provider) => (
|
||||
<Button
|
||||
key={provider.label}
|
||||
variant="secondary"
|
||||
className={styles.socialButton}
|
||||
onClick={provider.onClick}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
{provider.icon}
|
||||
{provider.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDivider && (
|
||||
<div className={styles.divider}>
|
||||
<div className={styles.dividerLine} />
|
||||
<span className={styles.dividerText}>or</span>
|
||||
<div className={styles.dividerLine} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredentials && (
|
||||
<form
|
||||
className={styles.fields}
|
||||
onSubmit={handleSubmit}
|
||||
aria-label="Sign in"
|
||||
noValidate
|
||||
>
|
||||
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
|
||||
<Input
|
||||
ref={emailRef}
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.rememberRow}>
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
{onForgotPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.forgotLink}
|
||||
onClick={onForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
{onSignUp && (
|
||||
<div className={styles.signUpText}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signUpLink}
|
||||
onClick={onSignUp}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!hasCredentials && onSignUp && (
|
||||
<div className={styles.signUpText}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signUpLink}
|
||||
onClick={onSignUp}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
.item:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: #E8DFD4;
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
@@ -69,5 +69,5 @@
|
||||
|
||||
.item.active .count {
|
||||
background: rgba(198, 130, 14, 0.2);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
148
src/design-system/composites/MultiSelect/MultiSelect.module.css
Normal file
148
src/design-system/composites/MultiSelect/MultiSelect.module.css
Normal file
@@ -0,0 +1,148 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
border-color: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||
}
|
||||
|
||||
.trigger[aria-disabled="true"] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.triggerText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerPlaceholder {
|
||||
color: var(--text-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: panelIn 0.12s ease-out;
|
||||
}
|
||||
|
||||
@keyframes panelIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.optionList {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-primary);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
accent-color: var(--amber);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
padding: 4px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--amber);
|
||||
color: var(--bg-base);
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.applyBtn:hover {
|
||||
background: var(--amber-hover);
|
||||
}
|
||||
109
src/design-system/composites/MultiSelect/MultiSelect.test.tsx
Normal file
109
src/design-system/composites/MultiSelect/MultiSelect.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MultiSelect } from './MultiSelect'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'admin', label: 'ADMIN' },
|
||||
{ value: 'editor', label: 'EDITOR' },
|
||||
{ value: 'viewer', label: 'VIEWER' },
|
||||
{ value: 'operator', label: 'OPERATOR' },
|
||||
]
|
||||
|
||||
describe('MultiSelect', () => {
|
||||
it('renders trigger with placeholder', () => {
|
||||
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||||
expect(screen.getByText('Select...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders trigger with custom placeholder', () => {
|
||||
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} placeholder="Add roles..." />)
|
||||
expect(screen.getByText('Add roles...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows selected count on trigger', () => {
|
||||
render(<MultiSelect options={OPTIONS} value={['admin', 'editor']} onChange={vi.fn()} />)
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens dropdown on trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
expect(screen.getByText('ADMIN')).toBeInTheDocument()
|
||||
expect(screen.getByText('EDITOR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows checkboxes for pre-selected values', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
const adminCheckbox = screen.getByRole('checkbox', { name: 'ADMIN' })
|
||||
expect(adminCheckbox).toBeChecked()
|
||||
})
|
||||
|
||||
it('filters options by search text', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.type(screen.getByPlaceholderText('Search...'), 'adm')
|
||||
expect(screen.getByText('ADMIN')).toBeInTheDocument()
|
||||
expect(screen.queryByText('EDITOR')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange with selected values on Apply', async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'VIEWER' }))
|
||||
await user.click(screen.getByRole('button', { name: /Apply/ }))
|
||||
expect(onChange).toHaveBeenCalledWith(['admin', 'viewer'])
|
||||
})
|
||||
|
||||
it('discards pending changes on Escape', async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes dropdown on outside click without applying', async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<div>
|
||||
<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />
|
||||
<button>Outside</button>
|
||||
</div>
|
||||
)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||||
await user.click(screen.getByText('Outside'))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables trigger when disabled prop is set', () => {
|
||||
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} disabled />)
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('hides search input when searchable is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} searchable={false} />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Apply button with count of pending changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'EDITOR' }))
|
||||
expect(screen.getByRole('button', { name: /Apply \(2\)/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
162
src/design-system/composites/MultiSelect/MultiSelect.tsx
Normal file
162
src/design-system/composites/MultiSelect/MultiSelect.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './MultiSelect.module.css'
|
||||
|
||||
export interface MultiSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: MultiSelectOption[]
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
searchable?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
searchable = true,
|
||||
disabled = false,
|
||||
className,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [pending, setPending] = useState<string[]>(value)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
|
||||
// Sync pending with value when opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPending(value)
|
||||
setSearch('')
|
||||
}
|
||||
}, [open, value])
|
||||
|
||||
// Position the panel below the trigger
|
||||
useEffect(() => {
|
||||
if (open && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
setPos({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
panelRef.current && !panelRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current && !triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [open])
|
||||
|
||||
function toggleOption(optValue: string) {
|
||||
setPending((prev) =>
|
||||
prev.includes(optValue) ? prev.filter((v) => v !== optValue) : [...prev, optValue]
|
||||
)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
onChange(pending)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const filtered = options.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const triggerLabel = value.length > 0 ? `${value.length} selected` : placeholder
|
||||
|
||||
return (
|
||||
<div className={`${styles.wrap} ${className ?? ''}`}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className={styles.trigger}
|
||||
onClick={() => !disabled && setOpen(!open)}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
<span className={value.length > 0 ? styles.triggerText : styles.triggerPlaceholder}>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
<span className={styles.chevron} aria-hidden="true">▾</span>
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={styles.panel}
|
||||
style={{ top: pos.top, left: pos.left, width: Math.max(pos.width, 200) }}
|
||||
>
|
||||
{searchable && (
|
||||
<input
|
||||
className={styles.search}
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<div className={styles.optionList} role="listbox">
|
||||
{filtered.map((opt) => (
|
||||
<label key={opt.value} className={styles.option} role="option" aria-selected={pending.includes(opt.value)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.checkbox}
|
||||
checked={pending.includes(opt.value)}
|
||||
onChange={() => toggleOption(opt.value)}
|
||||
aria-label={opt.label}
|
||||
/>
|
||||
<span className={styles.optionLabel}>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className={styles.empty}>No matches</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<button
|
||||
className={styles.applyBtn}
|
||||
onClick={handleApply}
|
||||
type="button"
|
||||
>
|
||||
Apply ({pending.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -69,15 +69,15 @@
|
||||
}
|
||||
|
||||
.ok {
|
||||
background: rgba(61, 124, 71, 0.5);
|
||||
background: color-mix(in srgb, var(--success) 50%, transparent);
|
||||
}
|
||||
|
||||
.slow {
|
||||
background: rgba(194, 117, 22, 0.5);
|
||||
background: color-mix(in srgb, var(--warning) 50%, transparent);
|
||||
}
|
||||
|
||||
.fail {
|
||||
background: rgba(192, 57, 43, 0.5);
|
||||
background: color-mix(in srgb, var(--error) 50%, transparent);
|
||||
}
|
||||
|
||||
.dur {
|
||||
@@ -89,6 +89,13 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.selectedRow {
|
||||
background: var(--amber-bg);
|
||||
border-left: 3px solid var(--amber);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 0 2px 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -11,7 +11,8 @@ export interface ProcessorStep {
|
||||
interface ProcessorTimelineProps {
|
||||
processors: ProcessorStep[]
|
||||
totalMs: number
|
||||
onProcessorClick?: (processor: ProcessorStep) => void
|
||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||
selectedIndex?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ export function ProcessorTimeline({
|
||||
processors,
|
||||
totalMs,
|
||||
onProcessorClick,
|
||||
selectedIndex,
|
||||
className,
|
||||
}: ProcessorTimelineProps) {
|
||||
const safeTotal = totalMs || 1
|
||||
@@ -49,17 +51,19 @@ export function ProcessorTimeline({
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const isSelected = selectedIndex === i
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.row} ${onProcessorClick ? styles.clickable : ''}`}
|
||||
onClick={() => onProcessorClick?.(proc)}
|
||||
className={`${styles.row} ${onProcessorClick ? styles.clickable : ''} ${isSelected ? styles.selectedRow : ''}`}
|
||||
onClick={() => onProcessorClick?.(proc, i)}
|
||||
role={onProcessorClick ? 'button' : undefined}
|
||||
tabIndex={onProcessorClick ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (onProcessorClick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
onProcessorClick(proc)
|
||||
onProcessorClick(proc, i)
|
||||
}
|
||||
}}
|
||||
aria-label={`${proc.name}: ${formatDuration(proc.durationMs)} (${proc.status})`}
|
||||
|
||||
204
src/design-system/composites/RouteFlow/RouteFlow.module.css
Normal file
204
src/design-system/composites/RouteFlow/RouteFlow.module.css
Normal file
@@ -0,0 +1,204 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* Processor node */
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
cursor: default;
|
||||
transition: all 0.12s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
|
||||
.nodeHealthy {
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.nodeSlow {
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
.nodeError {
|
||||
border-left: 3px solid var(--error);
|
||||
}
|
||||
|
||||
.nodeBottleneck {
|
||||
border-left: 3px solid var(--error);
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconFrom {
|
||||
background: var(--running-bg);
|
||||
color: var(--running);
|
||||
}
|
||||
|
||||
.iconProcess {
|
||||
background: var(--amber-bg);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.iconTo {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.iconChoice {
|
||||
background: var(--purple-bg);
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.iconErrorHandler {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Node info */
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Node stats */
|
||||
.stats {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.durFast {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.durNormal {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.durSlow {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.durBreach {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Connector */
|
||||
.connector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.connectorLine {
|
||||
width: 1px;
|
||||
flex: 1;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.connectorArrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid var(--border);
|
||||
}
|
||||
|
||||
/* Error handler section */
|
||||
.errorSection {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.errorLabel {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--error);
|
||||
margin-bottom: 6px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* Selected node */
|
||||
.nodeSelected {
|
||||
box-shadow: 0 0 0 2px var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
/* Clickable node */
|
||||
.nodeClickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodeClickable:focus-visible {
|
||||
outline: 2px solid var(--amber);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Bottleneck badge */
|
||||
.bottleneckBadge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
133
src/design-system/composites/RouteFlow/RouteFlow.tsx
Normal file
133
src/design-system/composites/RouteFlow/RouteFlow.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import styles from './RouteFlow.module.css'
|
||||
|
||||
export interface RouteNode {
|
||||
name: string
|
||||
type: 'from' | 'process' | 'to' | 'choice' | 'error-handler'
|
||||
durationMs: number
|
||||
status: 'ok' | 'slow' | 'fail'
|
||||
isBottleneck?: boolean
|
||||
}
|
||||
|
||||
interface RouteFlowProps {
|
||||
nodes: RouteNode[]
|
||||
onNodeClick?: (node: RouteNode, index: number) => void
|
||||
selectedIndex?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
function durationClass(ms: number, status: string): string {
|
||||
if (status === 'fail') return styles.durBreach
|
||||
if (ms < 50) return styles.durFast
|
||||
if (ms < 150) return styles.durNormal
|
||||
if (ms < 300) return styles.durSlow
|
||||
return styles.durBreach
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
'from': '\u25B6',
|
||||
'process': '\u2699',
|
||||
'to': '\u25A2',
|
||||
'choice': '\u25C6',
|
||||
'error-handler': '\u26A0',
|
||||
}
|
||||
|
||||
const ICON_CLASSES: Record<string, string> = {
|
||||
'from': styles.iconFrom,
|
||||
'process': styles.iconProcess,
|
||||
'to': styles.iconTo,
|
||||
'choice': styles.iconChoice,
|
||||
'error-handler': styles.iconErrorHandler,
|
||||
}
|
||||
|
||||
function nodeStatusClass(node: RouteNode): string {
|
||||
if (node.isBottleneck) return styles.nodeBottleneck
|
||||
if (node.status === 'fail') return styles.nodeError
|
||||
if (node.status === 'slow') return styles.nodeSlow
|
||||
return styles.nodeHealthy
|
||||
}
|
||||
|
||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
|
||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
||||
|
||||
// Map from mainNodes index back to original nodes index
|
||||
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
|
||||
if (n.type !== 'error-handler') acc.push(idx)
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||
{mainNodes.map((node, i) => {
|
||||
const originalIndex = mainNodeOriginalIndices[i]
|
||||
const isSelected = selectedIndex === originalIndex
|
||||
const isClickable = !!onNodeClick
|
||||
|
||||
return (
|
||||
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
{i > 0 && (
|
||||
<div className={styles.connector}>
|
||||
<div className={styles.connectorLine} />
|
||||
<div className={styles.connectorArrow} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
|
||||
onClick={() => onNodeClick?.(node, originalIndex)}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
onNodeClick?.(node, originalIndex)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
|
||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.type}>{node.type}</div>
|
||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||
{formatDuration(node.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{errorHandlers.length > 0 && (
|
||||
<div className={styles.errorSection}>
|
||||
<div className={styles.errorLabel}>Error Handler</div>
|
||||
{errorHandlers.map((node, i) => (
|
||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||
{TYPE_ICONS['error-handler']}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.type}>{node.type}</div>
|
||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||
{formatDuration(node.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
.bar {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-inset);
|
||||
padding: 2px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* Sliding indicator behind the active tab */
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: left 0.2s ease, width 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 3px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.active .count {
|
||||
background: var(--amber-bg);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* Trailing tab — a div, not a button, hosting custom content */
|
||||
.trailingTab {
|
||||
cursor: default;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { SegmentedTabs } from './SegmentedTabs'
|
||||
|
||||
const TABS = [
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: 'Groups', value: 'groups', count: 4 },
|
||||
{ label: 'Roles', value: 'roles' },
|
||||
]
|
||||
|
||||
describe('SegmentedTabs', () => {
|
||||
it('renders all tabs', () => {
|
||||
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
|
||||
expect(screen.getByRole('tab', { name: /Users/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: /Groups/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: /Roles/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the active tab with aria-selected', () => {
|
||||
render(<SegmentedTabs tabs={TABS} active="groups" onChange={vi.fn()} />)
|
||||
expect(screen.getByRole('tab', { name: /Groups/ })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tab', { name: /Users/ })).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
it('calls onChange when a tab is clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<SegmentedTabs tabs={TABS} active="users" onChange={onChange} />)
|
||||
await user.click(screen.getByRole('tab', { name: /Roles/ }))
|
||||
expect(onChange).toHaveBeenCalledWith('roles')
|
||||
})
|
||||
|
||||
it('renders count badge when provided', () => {
|
||||
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has tablist role on container', () => {
|
||||
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
|
||||
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
101
src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx
Normal file
101
src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useRef, useEffect, useState as useLocalState, useCallback, useMemo, type ReactNode } from 'react'
|
||||
import styles from './SegmentedTabs.module.css'
|
||||
|
||||
interface TabItem {
|
||||
label: ReactNode
|
||||
count?: number
|
||||
value: string
|
||||
}
|
||||
|
||||
interface SegmentedTabsProps {
|
||||
tabs: TabItem[]
|
||||
active: string
|
||||
onChange: (value: string) => void
|
||||
/** Extra element rendered as the last "tab" — participates in indicator animation.
|
||||
* Use `trailingValue` to assign it a value for active state matching. */
|
||||
trailing?: ReactNode
|
||||
trailingValue?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SegmentedTabs({ tabs, active, onChange, trailing, trailingValue, className }: SegmentedTabsProps) {
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const tabRefs = useRef<Map<string, HTMLElement>>(new Map())
|
||||
const [indicator, setIndicator] = useLocalState<{ left: number; width: number } | null>(null)
|
||||
|
||||
// Recalculate when labels change (e.g. dynamic date range text)
|
||||
const tabsKey = useMemo(() => tabs.map((t) => `${t.value}:${typeof t.label === 'string' ? t.label : ''}`).join('|'), [tabs])
|
||||
|
||||
const updateIndicator = useCallback(() => {
|
||||
const bar = barRef.current
|
||||
const activeEl = tabRefs.current.get(active)
|
||||
if (!bar || !activeEl) return
|
||||
const barRect = bar.getBoundingClientRect()
|
||||
const elRect = activeEl.getBoundingClientRect()
|
||||
setIndicator({
|
||||
left: elRect.left - barRect.left,
|
||||
width: elRect.width,
|
||||
})
|
||||
}, [active, tabsKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(updateIndicator)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [updateIndicator])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', updateIndicator)
|
||||
return () => window.removeEventListener('resize', updateIndicator)
|
||||
}, [updateIndicator])
|
||||
|
||||
// Observe DOM mutations (e.g. trailing content text changes) to resize indicator
|
||||
useEffect(() => {
|
||||
const bar = barRef.current
|
||||
if (!bar) return
|
||||
const observer = new MutationObserver(() => {
|
||||
requestAnimationFrame(updateIndicator)
|
||||
})
|
||||
observer.observe(bar, { childList: true, subtree: true, characterData: true })
|
||||
return () => observer.disconnect()
|
||||
}, [updateIndicator])
|
||||
|
||||
const trailingActive = trailingValue !== undefined && active === trailingValue
|
||||
|
||||
return (
|
||||
<div ref={barRef} className={`${styles.bar} ${className ?? ''}`} role="tablist">
|
||||
{indicator && (
|
||||
<span
|
||||
className={styles.indicator}
|
||||
style={{ left: indicator.left, width: indicator.width }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
ref={(el) => { if (el) tabRefs.current.set(tab.value, el); else tabRefs.current.delete(tab.value) }}
|
||||
role="tab"
|
||||
aria-selected={tab.value === active}
|
||||
className={`${styles.tab} ${tab.value === active ? styles.active : ''}`}
|
||||
onClick={() => onChange(tab.value)}
|
||||
type="button"
|
||||
>
|
||||
<span className={styles.label}>{tab.label}</span>
|
||||
{tab.count !== undefined && (
|
||||
<span className={styles.count}>{tab.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{trailing && trailingValue && (
|
||||
<div
|
||||
ref={(el) => { if (el) tabRefs.current.set(trailingValue, el); else tabRefs.current.delete(trailingValue) }}
|
||||
role="tab"
|
||||
aria-selected={trailingActive}
|
||||
className={`${styles.tab} ${styles.trailingTab} ${trailingActive ? styles.active : ''}`}
|
||||
>
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/design-system/composites/SplitPane/SplitPane.module.css
Normal file
37
src/design-system/composites/SplitPane/SplitPane.module.css
Normal file
@@ -0,0 +1,37 @@
|
||||
.splitPane {
|
||||
display: grid;
|
||||
grid-template-columns: var(--split-columns, 1fr 2fr);
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.listPane {
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detailPane {
|
||||
background: var(--bg-raised);
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
}
|
||||
|
||||
.emptyDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
font-style: italic;
|
||||
}
|
||||
69
src/design-system/composites/SplitPane/SplitPane.test.tsx
Normal file
69
src/design-system/composites/SplitPane/SplitPane.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { SplitPane } from './SplitPane'
|
||||
|
||||
describe('SplitPane', () => {
|
||||
it('renders list and detail content', () => {
|
||||
render(
|
||||
<SplitPane
|
||||
list={<div>List items</div>}
|
||||
detail={<div>Detail content</div>}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('List items')).toBeInTheDocument()
|
||||
expect(screen.getByText('Detail content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows default empty message when detail is null', () => {
|
||||
render(
|
||||
<SplitPane
|
||||
list={<div>List items</div>}
|
||||
detail={null}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows custom empty message', () => {
|
||||
render(
|
||||
<SplitPane
|
||||
list={<div>List items</div>}
|
||||
detail={null}
|
||||
emptyMessage="Pick something"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Pick something')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with different ratios (checks --split-columns CSS property)', () => {
|
||||
const { container, rerender } = render(
|
||||
<SplitPane
|
||||
list={<div>List</div>}
|
||||
detail={<div>Detail</div>}
|
||||
ratio="1:1"
|
||||
/>
|
||||
)
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
|
||||
|
||||
rerender(
|
||||
<SplitPane
|
||||
list={<div>List</div>}
|
||||
detail={<div>Detail</div>}
|
||||
ratio="2:3"
|
||||
/>
|
||||
)
|
||||
expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
|
||||
})
|
||||
|
||||
it('accepts className', () => {
|
||||
const { container } = render(
|
||||
<SplitPane
|
||||
list={<div>List</div>}
|
||||
detail={<div>Detail</div>}
|
||||
className="custom-class"
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
38
src/design-system/composites/SplitPane/SplitPane.tsx
Normal file
38
src/design-system/composites/SplitPane/SplitPane.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './SplitPane.module.css'
|
||||
|
||||
interface SplitPaneProps {
|
||||
list: ReactNode
|
||||
detail: ReactNode | null
|
||||
emptyMessage?: string
|
||||
ratio?: '1:1' | '1:2' | '2:3'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ratioMap: Record<string, string> = {
|
||||
'1:1': '1fr 1fr',
|
||||
'1:2': '1fr 2fr',
|
||||
'2:3': '2fr 3fr',
|
||||
}
|
||||
|
||||
export function SplitPane({
|
||||
list,
|
||||
detail,
|
||||
emptyMessage = 'Select an item to view details',
|
||||
ratio = '1:2',
|
||||
className,
|
||||
}: SplitPaneProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.splitPane} ${className ?? ''}`}
|
||||
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
|
||||
>
|
||||
<div className={styles.listPane}>{list}</div>
|
||||
<div className={styles.detailPane}>
|
||||
{detail !== null ? detail : (
|
||||
<div className={styles.emptyDetail}>{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,21 +6,38 @@ export { BarChart } from './BarChart/BarChart'
|
||||
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
|
||||
export { CommandPalette } from './CommandPalette/CommandPalette'
|
||||
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
|
||||
export { ConfirmDialog } from './ConfirmDialog/ConfirmDialog'
|
||||
export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog'
|
||||
export { DataTable } from './DataTable/DataTable'
|
||||
export type { Column, DataTableProps } from './DataTable/types'
|
||||
export { DetailPanel } from './DetailPanel/DetailPanel'
|
||||
export { EntityList } from './EntityList/EntityList'
|
||||
export { Dropdown } from './Dropdown/Dropdown'
|
||||
export { EventFeed } from './EventFeed/EventFeed'
|
||||
export { GroupCard } from './GroupCard/GroupCard'
|
||||
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||
export type { FeedEvent } from './EventFeed/EventFeed'
|
||||
export { FilterBar } from './FilterBar/FilterBar'
|
||||
export { LineChart } from './LineChart/LineChart'
|
||||
export { LogViewer } from './LogViewer/LogViewer'
|
||||
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
||||
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||
export type { LoginDialogProps } from './LoginForm/LoginDialog'
|
||||
export { LoginForm } from './LoginForm/LoginForm'
|
||||
export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
|
||||
export { MenuItem } from './MenuItem/MenuItem'
|
||||
export { Modal } from './Modal/Modal'
|
||||
export { MultiSelect } from './MultiSelect/MultiSelect'
|
||||
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||
export { Popover } from './Popover/Popover'
|
||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||
export { SplitPane } from './SplitPane/SplitPane'
|
||||
export { Tabs } from './Tabs/Tabs'
|
||||
export { ToastProvider, useToast } from './Toast/Toast'
|
||||
export { TreeView } from './TreeView/TreeView'
|
||||
|
||||
@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
|
||||
interface AppShellProps {
|
||||
sidebar: ReactNode
|
||||
children: ReactNode
|
||||
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
|
||||
detail?: ReactNode
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, children, detail }: AppShellProps) {
|
||||
export function AppShell({ sidebar, children }: AppShellProps) {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
{sidebar}
|
||||
<div className={styles.main}>
|
||||
{children}
|
||||
</div>
|
||||
{detail}
|
||||
<div id="cameleer-detail-panel-root" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
.logoImg {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
filter: brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
|
||||
.item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
}
|
||||
|
||||
.item.active .navIcon {
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.routeArrow {
|
||||
@@ -197,8 +197,9 @@
|
||||
/* ── SidebarTree styles ──────────────────────────────────────────────────── */
|
||||
|
||||
.treeSection {
|
||||
padding: 0 6px;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 6px 6px;
|
||||
margin-bottom: 2px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.treeSectionLabel {
|
||||
@@ -214,9 +215,9 @@
|
||||
.treeSectionToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 12px 4px;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.treeSectionChevronBtn {
|
||||
@@ -248,11 +249,11 @@
|
||||
}
|
||||
|
||||
.treeSectionLabel:hover {
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.treeSectionLabelActive {
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.tree {
|
||||
@@ -289,13 +290,13 @@
|
||||
|
||||
.treeRowActive {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.treeRowActive .treeBadge {
|
||||
background: rgba(198, 130, 14, 0.2);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* Chevron */
|
||||
@@ -379,7 +380,7 @@
|
||||
}
|
||||
|
||||
.treeStar:hover {
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ── Starred section ─────────────────────────────────────────────────────── */
|
||||
@@ -499,7 +500,7 @@
|
||||
|
||||
.bottomItemActive {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Agents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Metrics nav link', () => {
|
||||
it('renders Routes nav link', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Metrics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Routes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders bottom links', () => {
|
||||
@@ -87,9 +87,9 @@ describe('Sidebar', () => {
|
||||
|
||||
it('renders app names in the Applications tree', () => {
|
||||
renderSidebar()
|
||||
// order-service appears in both Applications and Agents trees
|
||||
// order-service appears in Applications, Routes, and Agents trees
|
||||
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('payment-svc')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders exchange count badges', () => {
|
||||
@@ -130,8 +130,8 @@ describe('Sidebar', () => {
|
||||
const searchInput = screen.getByPlaceholderText('Filter...')
|
||||
await user.type(searchInput, 'payment')
|
||||
|
||||
// payment-svc should still be visible
|
||||
expect(screen.getByText('payment-svc')).toBeInTheDocument()
|
||||
// payment-svc should still be visible (may appear in multiple trees)
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('expands tree to show children when chevron is clicked', async () => {
|
||||
|
||||
@@ -57,7 +57,30 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
label: route.name,
|
||||
icon: <span className={styles.routeArrow}>▸</span>,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${route.id}`,
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.routes.length > 0)
|
||||
.map((app) => ({
|
||||
id: `routes:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: `${app.routes.length} routes`,
|
||||
path: `/routes/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `routes:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `routestat:${app.id}:${route.id}`,
|
||||
starKey: `routes:${app.id}:${route.id}`,
|
||||
label: route.name,
|
||||
icon: <span className={styles.routeArrow}>▸</span>,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
@@ -95,7 +118,7 @@ interface StarredItem {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
path: string
|
||||
type: 'application' | 'route' | 'agent'
|
||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
||||
parentApp?: string
|
||||
}
|
||||
|
||||
@@ -118,24 +141,57 @@ function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): Starr
|
||||
items.push({
|
||||
starKey: key,
|
||||
label: route.name,
|
||||
path: `/routes/${route.id}`,
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
type: 'route',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
const agentsAppKey = `agents:${app.id}`
|
||||
if (starredIds.has(agentsAppKey)) {
|
||||
items.push({
|
||||
starKey: agentsAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/agents/${app.id}`,
|
||||
type: 'agent',
|
||||
})
|
||||
}
|
||||
for (const agent of app.agents) {
|
||||
const key = `${app.id}:${agent.id}`
|
||||
if (starredIds.has(key)) {
|
||||
items.push({
|
||||
starKey: key,
|
||||
label: agent.name,
|
||||
path: `/agents/${agent.id}`,
|
||||
path: `/agents/${app.id}/${agent.id}`,
|
||||
type: 'agent',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Routes tree starred items
|
||||
const routesAppKey = `routes:${app.id}`
|
||||
if (starredIds.has(routesAppKey)) {
|
||||
items.push({
|
||||
starKey: routesAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/routes/${app.id}`,
|
||||
type: 'routestat',
|
||||
})
|
||||
}
|
||||
for (const route of app.routes) {
|
||||
const routeKey = `routes:${app.id}:${route.id}`
|
||||
if (starredIds.has(routeKey)) {
|
||||
items.push({
|
||||
starKey: routeKey,
|
||||
label: route.name,
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
type: 'routestat',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
@@ -196,6 +252,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
|
||||
|
||||
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setAppsCollapsed((prev) => {
|
||||
@@ -212,6 +269,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setRoutesCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||
@@ -219,6 +284,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
// Build tree data
|
||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
||||
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
|
||||
|
||||
// Sidebar reveal from Cmd-K navigation (passed via location state)
|
||||
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||
@@ -254,6 +320,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
const starredApps = starredItems.filter((i) => i.type === 'application')
|
||||
const starredRoutes = starredItems.filter((i) => i.type === 'route')
|
||||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||||
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
|
||||
const hasStarred = starredItems.length > 0
|
||||
|
||||
// For exchange detail pages, use the reveal path for sidebar selection so
|
||||
@@ -374,23 +441,38 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flat nav links */}
|
||||
<div className={styles.items}>
|
||||
<div
|
||||
className={[
|
||||
styles.item,
|
||||
location.pathname === '/metrics' ? styles.active : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => navigate('/metrics')}
|
||||
{/* Routes tree (collapsible, label navigates to /routes) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setRoutesCollapsed((v) => !v)}
|
||||
aria-expanded={!routesCollapsed}
|
||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||
>
|
||||
{routesCollapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => navigate('/routes')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
||||
>
|
||||
<span className={styles.navIcon}>▤</span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>Metrics</div>
|
||||
</div>
|
||||
Routes
|
||||
</span>
|
||||
</div>
|
||||
{!routesCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={routeNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:routes"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No results message */}
|
||||
@@ -429,6 +511,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredRouteStats.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRouteStats}
|
||||
onNavigate={navigate}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -439,7 +529,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
location.pathname === '/admin' ? styles.bottomItemActive : '',
|
||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => navigate('/admin')}
|
||||
role="button"
|
||||
|
||||
@@ -81,6 +81,77 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.liveToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.liveToggle:hover {
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
|
||||
.liveToggleActive {
|
||||
color: var(--success);
|
||||
border-color: var(--success-border);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.liveToggleActive:hover {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.liveToggleActive .liveDot {
|
||||
background: var(--success);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.themeToggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.themeToggle:hover {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.env {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
@@ -100,6 +171,7 @@
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.userName {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import styles from './TopBar.module.css'
|
||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||
import { Avatar } from '../../primitives/Avatar/Avatar'
|
||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
|
||||
import { useGlobalFilters, type ExchangeStatus } from '../../providers/GlobalFilterProvider'
|
||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
||||
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||
import { useTheme } from '../../providers/ThemeProvider'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
@@ -15,24 +18,27 @@ interface TopBarProps {
|
||||
breadcrumb: BreadcrumbItem[]
|
||||
environment?: string
|
||||
user?: { name: string }
|
||||
onLogout?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const STATUS_PILLS: { status: ExchangeStatus; label: string }[] = [
|
||||
{ status: 'completed', label: 'OK' },
|
||||
{ status: 'warning', label: 'Warn' },
|
||||
{ status: 'failed', label: 'Error' },
|
||||
{ status: 'running', label: 'Running' },
|
||||
const STATUS_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'completed', label: 'OK', color: 'var(--success)' },
|
||||
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
|
||||
{ value: 'failed', label: 'Error', color: 'var(--error)' },
|
||||
{ value: 'running', label: 'Running', color: 'var(--running)' },
|
||||
]
|
||||
|
||||
export function TopBar({
|
||||
breadcrumb,
|
||||
environment,
|
||||
user,
|
||||
onLogout,
|
||||
className,
|
||||
}: TopBarProps) {
|
||||
const globalFilters = useGlobalFilters()
|
||||
const commandPalette = useCommandPalette()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||
@@ -56,17 +62,21 @@ export function TopBar({
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
</button>
|
||||
|
||||
{/* Status pills */}
|
||||
<div className={styles.filters}>
|
||||
{STATUS_PILLS.map(({ status, label }) => (
|
||||
<FilterPill
|
||||
key={status}
|
||||
label={label}
|
||||
active={globalFilters.statusFilters.has(status)}
|
||||
onClick={() => globalFilters.toggleStatus(status)}
|
||||
{/* Status filter group */}
|
||||
<ButtonGroup
|
||||
items={STATUS_ITEMS}
|
||||
value={globalFilters.statusFilters}
|
||||
onChange={(selected) => {
|
||||
// Sync with global filter by toggling the diff
|
||||
const current = globalFilters.statusFilters
|
||||
for (const v of selected) {
|
||||
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
||||
}
|
||||
for (const v of current) {
|
||||
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time range pills */}
|
||||
<TimeRangeDropdown
|
||||
@@ -74,16 +84,42 @@ export function TopBar({
|
||||
onChange={globalFilters.setTimeRange}
|
||||
/>
|
||||
|
||||
{/* Right: env badge, user */}
|
||||
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
|
||||
<div className={styles.right}>
|
||||
<button
|
||||
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
|
||||
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
|
||||
type="button"
|
||||
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
|
||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||
>
|
||||
<span className={styles.liveDot} />
|
||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
onClick={toggleTheme}
|
||||
type="button"
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? '\u263E' : '\u2600'}
|
||||
</button>
|
||||
{environment && (
|
||||
<span className={styles.env}>{environment}</span>
|
||||
)}
|
||||
{user && (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<div className={styles.user}>
|
||||
<span className={styles.userName}>{user.name}</span>
|
||||
<Avatar name={user.name} size="md" />
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
}
|
||||
|
||||
.dashed {
|
||||
background: transparent !important;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +1,59 @@
|
||||
.group {
|
||||
display: inline-flex;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Horizontal (default) */
|
||||
.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.horizontal > :global(*) {
|
||||
border-radius: 0;
|
||||
margin-left: -1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.horizontal > :global(*:first-child) {
|
||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.horizontal > :global(*:last-child) {
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
.horizontal > :global(*:only-child) {
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* Vertical */
|
||||
.vertical {
|
||||
flex-direction: column;
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vertical > :global(*) {
|
||||
border-radius: 0;
|
||||
margin-top: -1px;
|
||||
position: relative;
|
||||
.btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.vertical > :global(*:first-child) {
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
margin-top: 0;
|
||||
.btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vertical > :global(*:last-child) {
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
}
|
||||
|
||||
.vertical > :global(*:only-child) {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Active/hovered items sit above siblings so their borders win */
|
||||
.group > :global(*:hover),
|
||||
.group > :global(*:focus-visible),
|
||||
.group > :global(*[data-active="true"]),
|
||||
.group > :global(*.active) {
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--amber);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Active state — default (no color override) */
|
||||
.active {
|
||||
background: var(--amber-bg);
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Dot indicator */
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotMuted {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,60 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import styles from './ButtonGroup.module.css'
|
||||
|
||||
export interface ButtonGroupItem {
|
||||
value: string
|
||||
label: ReactNode
|
||||
/** Optional color for dot indicator and active tint */
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface ButtonGroupProps {
|
||||
children: ReactNode
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
items: ButtonGroupItem[]
|
||||
/** Currently selected values (multi-select) */
|
||||
value: Set<string>
|
||||
onChange: (value: Set<string>) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ButtonGroup({
|
||||
children,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
}: ButtonGroupProps) {
|
||||
export function ButtonGroup({ items, value, onChange, className }: ButtonGroupProps) {
|
||||
function handleClick(itemValue: string) {
|
||||
const next = new Set(value)
|
||||
if (next.has(itemValue)) {
|
||||
next.delete(itemValue)
|
||||
} else {
|
||||
next.add(itemValue)
|
||||
}
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.group} ${styles[orientation]} ${className ?? ''}`}
|
||||
role="group"
|
||||
<div className={`${styles.group} ${className ?? ''}`} role="group">
|
||||
{items.map((item) => {
|
||||
const active = value.has(item.value)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`${styles.btn} ${active ? styles.active : ''}`}
|
||||
style={active && item.color ? {
|
||||
borderColor: item.color,
|
||||
color: item.color,
|
||||
background: `color-mix(in srgb, ${item.color} 10%, transparent)`,
|
||||
} : undefined}
|
||||
onClick={() => handleClick(item.value)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{children}
|
||||
{item.color && (
|
||||
<span
|
||||
className={`${styles.dot} ${active ? '' : styles.dotMuted}`}
|
||||
style={{ background: item.color }}
|
||||
/>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,3 +11,22 @@
|
||||
.accent-warning { border-top: 3px solid var(--warning); }
|
||||
.accent-error { border-top: 3px solid var(--error); }
|
||||
.accent-running { border-top: 3px solid var(--running); }
|
||||
|
||||
.titleHeader {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.titleText {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
49
src/design-system/primitives/Card/Card.test.tsx
Normal file
49
src/design-system/primitives/Card/Card.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Card } from './Card'
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders children', () => {
|
||||
render(<Card>Hello world</Card>)
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders title when provided', () => {
|
||||
render(<Card title="Status">Content</Card>)
|
||||
expect(screen.getByText('Status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render title header when title is omitted', () => {
|
||||
const { container } = render(<Card>Content</Card>)
|
||||
expect(container.querySelector('h3')).toBeNull()
|
||||
})
|
||||
|
||||
it('wraps children in body div when title is provided', () => {
|
||||
render(<Card title="Status"><span>Content</span></Card>)
|
||||
const content = screen.getByText('Content')
|
||||
expect(content.parentElement).toHaveClass('body')
|
||||
})
|
||||
|
||||
it('renders with accent and title together', () => {
|
||||
const { container } = render(
|
||||
<Card accent="success" title="Health">Content</Card>,
|
||||
)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('accent-success')
|
||||
expect(screen.getByText('Health')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts className prop', () => {
|
||||
const { container } = render(<Card className="custom">Content</Card>)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('custom')
|
||||
})
|
||||
|
||||
it('renders children directly when no title (no wrapper div)', () => {
|
||||
const { container } = render(<Card><span>Direct child</span></Card>)
|
||||
const card = container.firstChild as HTMLElement
|
||||
const span = screen.getByText('Direct child')
|
||||
expect(span.parentElement).toBe(card)
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,25 @@ import type { ReactNode } from 'react'
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Card({ children, accent = 'none', className }: CardProps) {
|
||||
export function Card({ children, accent = 'none', title, className }: CardProps) {
|
||||
const classes = [
|
||||
styles.card,
|
||||
accent !== 'none' ? styles[`accent-${accent}`] : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return <div className={classes}>{children}</div>
|
||||
return (
|
||||
<div className={classes}>
|
||||
{title && (
|
||||
<div className={styles.titleHeader}>
|
||||
<h3 className={styles.titleText}>{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
{title ? <div className={styles.body}>{children}</div> : children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import userEvent from '@testing-library/user-event'
|
||||
import { DateRangePicker } from './DateRangePicker'
|
||||
|
||||
describe('DateRangePicker', () => {
|
||||
it('renders two datetime inputs', () => {
|
||||
const { container } = render(
|
||||
it('renders two datetime picker triggers', () => {
|
||||
render(
|
||||
<DateRangePicker
|
||||
value={{ start: new Date(), end: new Date() }}
|
||||
value={{ start: new Date('2026-03-19T10:00'), end: new Date('2026-03-19T11:00') }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
)
|
||||
const inputs = container.querySelectorAll('input[type="datetime-local"]')
|
||||
expect(inputs.length).toBe(2)
|
||||
// DateTimePicker renders button triggers with formatted date text
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// At least 2 buttons are the from/to date picker triggers (plus preset pills)
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('renders preset buttons', () => {
|
||||
|
||||
@@ -12,26 +12,217 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
.trigger {
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
outline: 1px solid var(--amber);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 600;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 12px;
|
||||
width: 260px;
|
||||
animation: panelIn 0.12s ease-out;
|
||||
}
|
||||
|
||||
@keyframes panelIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Month navigation */
|
||||
.monthNav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.monthLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.navBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Calendar grid */
|
||||
.calendar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dayHeader {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.dayEmpty {
|
||||
/* placeholder for offset days */
|
||||
}
|
||||
|
||||
.day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.dayToday {
|
||||
font-weight: 700;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.daySelected {
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.daySelected:hover {
|
||||
background: var(--amber-hover);
|
||||
}
|
||||
|
||||
/* Time selector */
|
||||
.timeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.timeLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.timeInput {
|
||||
width: 32px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
.timeInput:focus {
|
||||
border-color: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||
box-shadow: 0 0 0 2px var(--amber-bg);
|
||||
}
|
||||
|
||||
.input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
.timeSep {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.todayBtn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.todayBtn:hover {
|
||||
background: var(--amber-bg);
|
||||
}
|
||||
|
||||
.doneBtn {
|
||||
padding: 4px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.doneBtn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.doneBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,51 +1,204 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './DateTimePicker.module.css'
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||
|
||||
interface DateTimePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {
|
||||
interface DateTimePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | null) => void
|
||||
label?: string
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function toLocalDateTimeString(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
date.getFullYear() +
|
||||
'-' +
|
||||
pad(date.getMonth() + 1) +
|
||||
'-' +
|
||||
pad(date.getDate()) +
|
||||
'T' +
|
||||
pad(date.getHours()) +
|
||||
':' +
|
||||
pad(date.getMinutes())
|
||||
)
|
||||
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
export const DateTimePicker = forwardRef<HTMLInputElement, DateTimePickerProps>(
|
||||
({ value, onChange, label, className, ...rest }, ref) => {
|
||||
const inputValue = value ? toLocalDateTimeString(value) : ''
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (!onChange) return
|
||||
const v = e.target.value
|
||||
onChange(v ? new Date(v) : null)
|
||||
function getFirstDayOfWeek(year: number, month: number): number {
|
||||
const day = new Date(year, month, 1).getDay()
|
||||
return day === 0 ? 6 : day - 1 // Monday = 0
|
||||
}
|
||||
|
||||
function formatDisplay(d: Date | undefined): string {
|
||||
if (!d) return '—'
|
||||
const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
return `${date}\u2009${time}`
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function DateTimePicker({ value, onChange, label, placeholder, className }: DateTimePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth())
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(value ?? null)
|
||||
const [hour, setHour] = useState(value ? pad(value.getHours()) : pad(new Date().getHours()))
|
||||
const [minute, setMinute] = useState(value ? pad(value.getMinutes()) : pad(new Date().getMinutes()))
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
// Sync when value changes externally
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelectedDate(value)
|
||||
setHour(pad(value.getHours()))
|
||||
setMinute(pad(value.getMinutes()))
|
||||
setViewYear(value.getFullYear())
|
||||
setViewMonth(value.getMonth())
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
setPos({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const id = requestAnimationFrame(reposition)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}
|
||||
}, [open, reposition])
|
||||
|
||||
// Close on Escape only — panel closes via Apply/Now buttons
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [open])
|
||||
|
||||
function handleDone() {
|
||||
if (selectedDate) {
|
||||
const d = new Date(selectedDate)
|
||||
d.setHours(parseInt(hour, 10) || 0, parseInt(minute, 10) || 0, 0, 0)
|
||||
onChange?.(d)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleDayClick(day: number) {
|
||||
const d = new Date(viewYear, viewMonth, day)
|
||||
setSelectedDate(d)
|
||||
}
|
||||
|
||||
function handleNow() {
|
||||
const now = new Date()
|
||||
onChange?.(now)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1) }
|
||||
else setViewMonth((m) => m - 1)
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1) }
|
||||
else setViewMonth((m) => m + 1)
|
||||
}
|
||||
|
||||
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
|
||||
const firstDay = getFirstDayOfWeek(viewYear, viewMonth)
|
||||
const today = new Date()
|
||||
|
||||
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
|
||||
return (
|
||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||
{label && <label className={styles.label}>{label}</label>}
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{value ? formatDisplay(value) : (placeholder ?? '—')}
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={styles.panel}
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{/* Month navigation */}
|
||||
<div className={styles.monthNav}>
|
||||
<button type="button" className={styles.navBtn} onClick={prevMonth} aria-label="Previous month">◀</button>
|
||||
<span className={styles.monthLabel}>{monthLabel}</span>
|
||||
<button type="button" className={styles.navBtn} onClick={nextMonth} aria-label="Next month">▶</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className={styles.calendar}>
|
||||
{DAYS.map((d) => (
|
||||
<span key={d} className={styles.dayHeader}>{d}</span>
|
||||
))}
|
||||
{Array.from({ length: firstDay }, (_, i) => (
|
||||
<span key={`pad-${i}`} className={styles.dayEmpty} />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const day = i + 1
|
||||
const isToday = viewYear === today.getFullYear() && viewMonth === today.getMonth() && day === today.getDate()
|
||||
const isSelected = selectedDate && viewYear === selectedDate.getFullYear() && viewMonth === selectedDate.getMonth() && day === selectedDate.getDate()
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={[styles.day, isToday ? styles.dayToday : '', isSelected ? styles.daySelected : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => handleDayClick(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Time selector */}
|
||||
<div className={styles.timeRow}>
|
||||
<span className={styles.timeLabel}>Time</span>
|
||||
<input
|
||||
ref={ref}
|
||||
type="datetime-local"
|
||||
className={styles.input}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
type="text"
|
||||
className={styles.timeInput}
|
||||
value={hour}
|
||||
onChange={(e) => setHour(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||
maxLength={2}
|
||||
aria-label="Hour"
|
||||
/>
|
||||
<span className={styles.timeSep}>:</span>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.timeInput}
|
||||
value={minute}
|
||||
onChange={(e) => setMinute(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||
maxLength={2}
|
||||
aria-label="Minute"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.todayBtn} onClick={handleNow}>Now</button>
|
||||
<button type="button" className={styles.doneBtn} onClick={handleDone} disabled={!selectedDate}>Apply</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
DateTimePicker.displayName = 'DateTimePicker'
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotMuted {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.activeColored {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface FilterPillProps {
|
||||
active?: boolean
|
||||
dot?: boolean
|
||||
dotColor?: string
|
||||
activeColor?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
@@ -18,21 +19,27 @@ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
|
||||
active = false,
|
||||
dot = false,
|
||||
dotColor,
|
||||
activeColor,
|
||||
onClick,
|
||||
className,
|
||||
}, ref) => {
|
||||
const classes = [
|
||||
styles.pill,
|
||||
active ? styles.active : '',
|
||||
active && activeColor ? styles.activeColored : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
const activeStyle = active && activeColor
|
||||
? { borderColor: activeColor, backgroundColor: `color-mix(in srgb, ${activeColor} 12%, transparent)`, color: activeColor } as React.CSSProperties
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<button ref={ref} className={classes} onClick={onClick} type="button" data-active={active || undefined}>
|
||||
<button ref={ref} className={classes} style={activeStyle} onClick={onClick} type="button" data-active={active || undefined}>
|
||||
{dot && (
|
||||
<span
|
||||
className={styles.dot}
|
||||
style={dotColor ? { background: dotColor } : undefined}
|
||||
className={`${styles.dot} ${!active ? styles.dotMuted : ''}`}
|
||||
style={dotColor ? { background: active ? dotColor : undefined } : undefined}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.label}>{label}</span>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
.display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.display:hover .editBtn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.editBtn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.editBtn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.disabled .editBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||
}
|
||||
76
src/design-system/primitives/InlineEdit/InlineEdit.test.tsx
Normal file
76
src/design-system/primitives/InlineEdit/InlineEdit.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InlineEdit } from './InlineEdit'
|
||||
|
||||
describe('InlineEdit', () => {
|
||||
it('renders value in display mode', () => {
|
||||
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows placeholder when value is empty', () => {
|
||||
render(<InlineEdit value="" onSave={vi.fn()} placeholder="Enter name" />)
|
||||
expect(screen.getByText('Enter name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enters edit mode on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||||
await user.click(screen.getByText('Alice'))
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Alice')
|
||||
})
|
||||
|
||||
it('saves on Enter', async () => {
|
||||
const onSave = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||||
await user.click(screen.getByText('Alice'))
|
||||
await user.clear(screen.getByRole('textbox'))
|
||||
await user.type(screen.getByRole('textbox'), 'Bob')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onSave).toHaveBeenCalledWith('Bob')
|
||||
})
|
||||
|
||||
it('cancels on Escape', async () => {
|
||||
const onSave = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||||
await user.click(screen.getByText('Alice'))
|
||||
await user.clear(screen.getByRole('textbox'))
|
||||
await user.type(screen.getByRole('textbox'), 'Bob')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels on blur', async () => {
|
||||
const onSave = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||||
await user.click(screen.getByText('Alice'))
|
||||
await user.clear(screen.getByRole('textbox'))
|
||||
await user.type(screen.getByRole('textbox'), 'Bob')
|
||||
await user.tab()
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not enter edit mode when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<InlineEdit value="Alice" onSave={vi.fn()} disabled />)
|
||||
await user.click(screen.getByText('Alice'))
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows edit icon button', () => {
|
||||
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enters edit mode when edit button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Alice')
|
||||
})
|
||||
})
|
||||
78
src/design-system/primitives/InlineEdit/InlineEdit.tsx
Normal file
78
src/design-system/primitives/InlineEdit/InlineEdit.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import styles from './InlineEdit.module.css'
|
||||
|
||||
export interface InlineEditProps {
|
||||
value: string
|
||||
onSave: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function InlineEdit({ value, onSave, placeholder, disabled, className }: InlineEditProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(value)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
function startEdit() {
|
||||
if (disabled) return
|
||||
setDraft(value)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
setEditing(false)
|
||||
onSave(draft)
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={`${styles.input} ${className ?? ''}`}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty = !value
|
||||
return (
|
||||
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
||||
<span
|
||||
className={isEmpty ? styles.placeholder : styles.value}
|
||||
onClick={startEdit}
|
||||
>
|
||||
{isEmpty ? placeholder : value}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
className={styles.editBtn}
|
||||
onClick={startEdit}
|
||||
aria-label="Edit"
|
||||
type="button"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import styles from './StatCard.module.css'
|
||||
import { Sparkline } from '../Sparkline/Sparkline'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
detail?: string
|
||||
value: ReactNode
|
||||
detail?: ReactNode
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
trendValue?: string
|
||||
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.statusText {}
|
||||
.success { color: var(--success); }
|
||||
.warning { color: var(--warning); }
|
||||
.error { color: var(--error); }
|
||||
.running { color: var(--running); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.bold { font-weight: 600; }
|
||||
47
src/design-system/primitives/StatusText/StatusText.test.tsx
Normal file
47
src/design-system/primitives/StatusText/StatusText.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { StatusText } from './StatusText'
|
||||
|
||||
describe('StatusText', () => {
|
||||
it('renders children text', () => {
|
||||
render(<StatusText variant="success">Online</StatusText>)
|
||||
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders as a span element', () => {
|
||||
render(<StatusText variant="success">Status</StatusText>)
|
||||
const el = screen.getByText('Status')
|
||||
expect(el.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('applies variant class', () => {
|
||||
render(<StatusText variant="error">Failed</StatusText>)
|
||||
expect(screen.getByText('Failed')).toHaveClass('error')
|
||||
})
|
||||
|
||||
it('applies bold class when bold=true', () => {
|
||||
render(<StatusText variant="success" bold>OK</StatusText>)
|
||||
expect(screen.getByText('OK')).toHaveClass('bold')
|
||||
})
|
||||
|
||||
it('does not apply bold class by default', () => {
|
||||
render(<StatusText variant="success">OK</StatusText>)
|
||||
expect(screen.getByText('OK')).not.toHaveClass('bold')
|
||||
})
|
||||
|
||||
it('accepts custom className', () => {
|
||||
render(<StatusText variant="muted" className="custom">Text</StatusText>)
|
||||
expect(screen.getByText('Text')).toHaveClass('custom')
|
||||
})
|
||||
|
||||
it('renders all 5 variant classes correctly', () => {
|
||||
const variants = ['success', 'warning', 'error', 'running', 'muted'] as const
|
||||
for (const variant of variants) {
|
||||
const { unmount } = render(
|
||||
<StatusText variant={variant}>{variant}</StatusText>
|
||||
)
|
||||
expect(screen.getByText(variant)).toHaveClass(variant)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
20
src/design-system/primitives/StatusText/StatusText.tsx
Normal file
20
src/design-system/primitives/StatusText/StatusText.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import styles from './StatusText.module.css'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface StatusTextProps {
|
||||
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
|
||||
bold?: boolean
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
|
||||
const classes = [
|
||||
styles.statusText,
|
||||
styles[variant],
|
||||
bold ? styles.bold : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return <span className={classes}>{children}</span>
|
||||
}
|
||||
@@ -1,78 +1,11 @@
|
||||
/* ── Integrated readout cell ──────────────────────────────
|
||||
First child of the ButtonGroup — styled as a recessed
|
||||
instrument-panel display, not a clickable control. */
|
||||
|
||||
.readout {
|
||||
.rangeRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-inset);
|
||||
box-shadow: inset 0 1px 3px rgba(44, 37, 32, 0.06);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .readout {
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ── Custom date picker panel ────────────────────────── */
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
min-width: 220px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 500;
|
||||
animation: panelIn 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes panelIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-family: var(--font-body);
|
||||
.rangeSep {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.applyBtn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.applyBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import styles from './TimeRangeDropdown.module.css'
|
||||
import { FilterPill } from '../FilterPill/FilterPill'
|
||||
import { ButtonGroup } from '../ButtonGroup/ButtonGroup'
|
||||
import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs'
|
||||
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
||||
import { computePresetRange } from '../../utils/timePresets'
|
||||
import type { TimeRange } from '../../providers/GlobalFilterProvider'
|
||||
|
||||
function formatRangeLabel(range: TimeRange): string {
|
||||
const start = range.preset ? computePresetRange(range.preset).start : range.start
|
||||
|
||||
const time = (d: Date) =>
|
||||
d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
const dateTime = (d: Date) =>
|
||||
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + '\u2009' + time(d)
|
||||
|
||||
// Preset ranges are open-ended ("since X"), so only show the start
|
||||
if (range.preset) {
|
||||
const now = new Date()
|
||||
const sameDay =
|
||||
start.getFullYear() === now.getFullYear() &&
|
||||
start.getMonth() === now.getMonth() &&
|
||||
start.getDate() === now.getDate()
|
||||
return sameDay ? `${time(start)}\u2009\u2013\u2009now` : `${dateTime(start)}\u2009\u2013\u2009now`
|
||||
}
|
||||
|
||||
// Custom range: show both ends
|
||||
const end = range.end
|
||||
const sameDay =
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getDate() === end.getDate()
|
||||
|
||||
if (sameDay) return `${time(start)}\u2009\u2013\u2009${time(end)}`
|
||||
return `${dateTime(start)}\u2009\u2013\u2009${dateTime(end)}`
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ value: 'last-1h', label: '1h' },
|
||||
{ value: 'last-3h', label: '3h' },
|
||||
@@ -45,6 +14,8 @@ const PRESETS = [
|
||||
{ value: 'last-7d', label: '7d' },
|
||||
]
|
||||
|
||||
const CUSTOM_VALUE = '__custom__'
|
||||
|
||||
interface TimeRangeDropdownProps {
|
||||
value: TimeRange
|
||||
onChange: (range: TimeRange) => void
|
||||
@@ -52,112 +23,78 @@ interface TimeRangeDropdownProps {
|
||||
}
|
||||
|
||||
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customFrom, setCustomFrom] = useState<Date | null>(value.start)
|
||||
const [customTo, setCustomTo] = useState<Date | null>(value.end)
|
||||
const customRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 })
|
||||
const [customFrom, setCustomFrom] = useState<Date>(value.start)
|
||||
const [customTo, setCustomTo] = useState<Date>(value.end)
|
||||
const [toIsSet, setToIsSet] = useState(false)
|
||||
|
||||
const isCustom = value.preset === null || value.preset === 'custom'
|
||||
const activeValue = isCustom ? CUSTOM_VALUE : (value.preset ?? 'last-1h')
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
if (!customRef.current) return
|
||||
const rect = customRef.current.getBoundingClientRect()
|
||||
const panelWidth = panelRef.current?.offsetWidth ?? 240
|
||||
setPanelPos({
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: rect.right + window.scrollX - panelWidth,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Sync local state when value changes from presets
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const id = requestAnimationFrame(reposition)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}
|
||||
}, [open, reposition])
|
||||
setCustomFrom(value.start)
|
||||
setCustomTo(value.end)
|
||||
}, [value.start, value.end])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (
|
||||
customRef.current?.contains(e.target as Node) ||
|
||||
panelRef.current?.contains(e.target as Node)
|
||||
) return
|
||||
setOpen(false)
|
||||
function handleTabChange(tabValue: string) {
|
||||
if (tabValue === CUSTOM_VALUE) return
|
||||
setToIsSet(false)
|
||||
const range = computePresetRange(tabValue)
|
||||
onChange({ ...range, preset: tabValue })
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const rangeLabel = useMemo(() => formatRangeLabel(value), [value])
|
||||
function handleFromChange(d: Date | null) {
|
||||
if (!d) return
|
||||
setCustomFrom(d)
|
||||
// Only set preset to null; keep to-date as "now" if not explicitly set
|
||||
if (toIsSet) {
|
||||
onChange({ start: d, end: customTo, preset: null })
|
||||
} else {
|
||||
onChange({ start: d, end: new Date(), preset: null })
|
||||
}
|
||||
}
|
||||
|
||||
function handleToChange(d: Date | null) {
|
||||
if (!d) return
|
||||
setCustomTo(d)
|
||||
setToIsSet(true)
|
||||
onChange({ start: customFrom, end: d, preset: null })
|
||||
}
|
||||
|
||||
// Show "now" when to-date is not explicitly set
|
||||
const showNow = !isCustom || !toIsSet
|
||||
|
||||
const rangeContent = (
|
||||
<div className={styles.rangeRow}>
|
||||
<DateTimePicker
|
||||
value={isCustom ? customFrom : value.start}
|
||||
onChange={handleFromChange}
|
||||
/>
|
||||
<span className={styles.rangeSep}>–</span>
|
||||
{showNow ? (
|
||||
<DateTimePicker
|
||||
value={undefined}
|
||||
onChange={handleToChange}
|
||||
placeholder="now"
|
||||
/>
|
||||
) : (
|
||||
<DateTimePicker
|
||||
value={customTo}
|
||||
onChange={handleToChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup className={className}>
|
||||
{PRESETS.map((preset) => (
|
||||
<FilterPill
|
||||
key={preset.value}
|
||||
label={preset.label}
|
||||
active={value.preset === preset.value}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
const range = computePresetRange(preset.value)
|
||||
onChange({ ...range, preset: preset.value })
|
||||
}}
|
||||
<div className={className}>
|
||||
<SegmentedTabs
|
||||
tabs={PRESETS}
|
||||
active={activeValue}
|
||||
onChange={handleTabChange}
|
||||
trailing={rangeContent}
|
||||
trailingValue={CUSTOM_VALUE}
|
||||
/>
|
||||
))}
|
||||
<FilterPill
|
||||
ref={customRef}
|
||||
label="Custom"
|
||||
active={isCustom}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
/>
|
||||
<span className={styles.readout} aria-label="Active time range">
|
||||
{rangeLabel}
|
||||
</span>
|
||||
</ButtonGroup>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={styles.panel}
|
||||
style={{ top: panelPos.top, left: panelPos.left }}
|
||||
role="dialog"
|
||||
>
|
||||
<DateTimePicker
|
||||
label="From"
|
||||
value={customFrom ?? undefined}
|
||||
onChange={(d) => setCustomFrom(d)}
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="To"
|
||||
value={customTo ?? undefined}
|
||||
onChange={(d) => setCustomTo(d)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.applyBtn}
|
||||
disabled={!customFrom || !customTo}
|
||||
onClick={() => {
|
||||
if (customFrom && customTo) {
|
||||
onChange({ start: customFrom, end: customTo, preset: null })
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export { Avatar } from './Avatar/Avatar'
|
||||
export { Badge } from './Badge/Badge'
|
||||
export { Button } from './Button/Button'
|
||||
export { ButtonGroup } from './ButtonGroup/ButtonGroup'
|
||||
export type { ButtonGroupItem } from './ButtonGroup/ButtonGroup'
|
||||
export { Card } from './Card/Card'
|
||||
export { Checkbox } from './Checkbox/Checkbox'
|
||||
export { CodeBlock } from './CodeBlock/CodeBlock'
|
||||
@@ -13,6 +14,8 @@ export { EmptyState } from './EmptyState/EmptyState'
|
||||
export { FilterPill } from './FilterPill/FilterPill'
|
||||
export { FormField } from './FormField/FormField'
|
||||
export { InfoCallout } from './InfoCallout/InfoCallout'
|
||||
export { InlineEdit } from './InlineEdit/InlineEdit'
|
||||
export type { InlineEditProps } from './InlineEdit/InlineEdit'
|
||||
export { Input } from './Input/Input'
|
||||
export { KeyboardHint } from './KeyboardHint/KeyboardHint'
|
||||
export { Label } from './Label/Label'
|
||||
@@ -27,6 +30,7 @@ export { Sparkline } from './Sparkline/Sparkline'
|
||||
export { Spinner } from './Spinner/Spinner'
|
||||
export { StatCard } from './StatCard/StatCard'
|
||||
export { StatusDot } from './StatusDot/StatusDot'
|
||||
export { StatusText } from './StatusText/StatusText'
|
||||
export { Tag } from './Tag/Tag'
|
||||
export { Textarea } from './Textarea/Textarea'
|
||||
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
|
||||
|
||||
@@ -16,6 +16,8 @@ interface GlobalFilterContextValue {
|
||||
toggleStatus: (status: ExchangeStatus) => void
|
||||
clearStatusFilters: () => void
|
||||
isInTimeRange: (timestamp: Date) => boolean
|
||||
autoRefresh: boolean
|
||||
setAutoRefresh: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||
@@ -27,9 +29,17 @@ function getDefaultTimeRange(): TimeRange {
|
||||
return { start, end, preset: DEFAULT_PRESET }
|
||||
}
|
||||
|
||||
function getInitialAutoRefresh(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem('cameleer:auto-refresh')
|
||||
return stored === null ? true : stored === 'true'
|
||||
} catch { return true }
|
||||
}
|
||||
|
||||
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
||||
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
||||
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
|
||||
|
||||
const setTimeRange = useCallback((range: TimeRange) => {
|
||||
setTimeRangeState(range)
|
||||
@@ -51,6 +61,11 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
setStatusFilters(new Set())
|
||||
}, [])
|
||||
|
||||
const setAutoRefresh = useCallback((enabled: boolean) => {
|
||||
setAutoRefreshState(enabled)
|
||||
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
||||
}, [])
|
||||
|
||||
const isInTimeRange = useCallback(
|
||||
(timestamp: Date) => {
|
||||
if (timeRange.preset) {
|
||||
@@ -65,7 +80,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<GlobalFilterContext.Provider
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||
>
|
||||
{children}
|
||||
</GlobalFilterContext.Provider>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
--sidebar-bg: #2C2520;
|
||||
--sidebar-hover: #3A322C;
|
||||
--sidebar-active: #4A3F38;
|
||||
--sidebar-text: #BFB5A8;
|
||||
--sidebar-muted: #7A6F63;
|
||||
--sidebar-text: #D8D0C6;
|
||||
--sidebar-muted: #9C9184;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #1A1612;
|
||||
@@ -58,6 +58,10 @@
|
||||
--shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10);
|
||||
--shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04);
|
||||
|
||||
/* Accent: purple (for choice/router elements) */
|
||||
--purple: #7C3AED;
|
||||
--purple-bg: #F3EEFA;
|
||||
|
||||
/* Chart palette */
|
||||
--chart-1: #C6820E;
|
||||
--chart-2: #3D7C47;
|
||||
@@ -80,7 +84,7 @@
|
||||
--sidebar-bg: #141210;
|
||||
--sidebar-hover: #1E1B17;
|
||||
--sidebar-active: #28241E;
|
||||
--sidebar-text: #A89E92;
|
||||
--sidebar-text: #CCC4B8;
|
||||
--sidebar-muted: #6A6058;
|
||||
|
||||
--text-primary: #E8E0D6;
|
||||
@@ -109,6 +113,9 @@
|
||||
--running-bg: #1A2628;
|
||||
--running-border: #243A3E;
|
||||
|
||||
--purple: #A78BFA;
|
||||
--purple-bg: rgba(124, 58, 237, 0.15);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
|
||||
@@ -12,7 +12,6 @@ export const DEFAULT_PRESETS: Preset[] = [
|
||||
{ label: 'Last 1h', value: 'last-1h' },
|
||||
{ label: 'Last 6h', value: 'last-6h' },
|
||||
{ label: 'Today', value: 'today' },
|
||||
{ label: 'This shift', value: 'shift' },
|
||||
{ label: 'Last 24h', value: 'last-24h' },
|
||||
{ label: 'Last 7d', value: 'last-7d' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
@@ -23,7 +22,6 @@ export const PRESET_SHORT_LABELS: Record<string, string> = {
|
||||
'last-3h': '3h',
|
||||
'last-6h': '6h',
|
||||
'today': 'Today',
|
||||
'shift': 'Shift',
|
||||
'last-24h': '24h',
|
||||
'last-7d': '7d',
|
||||
'custom': 'Custom',
|
||||
@@ -45,10 +43,6 @@ export function computePresetRange(preset: string): DateRange {
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return { start, end }
|
||||
}
|
||||
case 'shift': {
|
||||
// "This shift" = last 8 hours
|
||||
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
|
||||
}
|
||||
case 'last-24h':
|
||||
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
|
||||
case 'last-7d':
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import { ThemeProvider } from './design-system/providers/ThemeProvider'
|
||||
import { GlobalFilterProvider } from './design-system/providers/GlobalFilterProvider'
|
||||
import { CommandPaletteProvider } from './design-system/providers/CommandPaletteProvider'
|
||||
import { ToastProvider } from './design-system/composites/Toast/Toast'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
@@ -13,7 +14,9 @@ createRoot(document.getElementById('root')!).render(
|
||||
<ThemeProvider>
|
||||
<GlobalFilterProvider>
|
||||
<CommandPaletteProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</CommandPaletteProvider>
|
||||
</GlobalFilterProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Exchange {
|
||||
errorMessage?: string
|
||||
errorClass?: string
|
||||
processors: ProcessorData[]
|
||||
correlationGroup?: string
|
||||
}
|
||||
|
||||
export const exchanges: Exchange[] = [
|
||||
@@ -34,6 +35,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:12:04'),
|
||||
correlationId: 'cmr-f4a1c82b-9d3e',
|
||||
agent: 'prod-1',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
|
||||
@@ -53,6 +55,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:11:22'),
|
||||
correlationId: 'cmr-7b2d9f14-c5a8',
|
||||
agent: 'prod-2',
|
||||
correlationGroup: 'payment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 5 },
|
||||
@@ -72,6 +75,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:13:44'),
|
||||
correlationId: 'cmr-3c8e1a7f-d2b6',
|
||||
agent: 'prod-1',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||
{ name: 'enrich(inventory-api)', type: 'enrich', durationMs: 29990, status: 'slow', startMs: 5 },
|
||||
@@ -88,6 +92,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:09:47'),
|
||||
correlationId: 'cmr-a9f3b2c1-e4d7',
|
||||
agent: 'prod-3',
|
||||
correlationGroup: 'shipment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 6, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 6 },
|
||||
@@ -106,6 +111,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:06:11'),
|
||||
correlationId: 'cmr-9a4f2b71-e8c3',
|
||||
agent: 'prod-2',
|
||||
correlationGroup: 'payment-flow-002',
|
||||
errorMessage: 'org.apache.camel.CamelExecutionException: Payment gateway timeout after 5000ms — POST https://pay.provider.com/v2/charge returned HTTP 504. Retry exhausted (3/3).',
|
||||
errorClass: 'org.apache.camel.CamelExecutionException',
|
||||
processors: [
|
||||
@@ -145,6 +151,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:00:15'),
|
||||
correlationId: 'cmr-2e5f8d9a-b4c1',
|
||||
agent: 'prod-3',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 5, status: 'ok', startMs: 3 },
|
||||
@@ -164,6 +171,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:58:33'),
|
||||
correlationId: 'cmr-d1a3e7f4-c2b8',
|
||||
agent: 'prod-1',
|
||||
correlationGroup: 'payment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||
{ name: 'validate(payment-schema)', type: 'process', durationMs: 14, status: 'ok', startMs: 4 },
|
||||
@@ -199,6 +207,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:50:41'),
|
||||
correlationId: 'cmr-f3c7a1b9-d5e2',
|
||||
agent: 'prod-1',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 3 },
|
||||
@@ -218,6 +227,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:46:19'),
|
||||
correlationId: 'cmr-a2d8f5c3-b9e1',
|
||||
agent: 'prod-2',
|
||||
correlationGroup: 'payment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||
{ name: 'validate(payment-schema)', type: 'process', durationMs: 12, status: 'ok', startMs: 5 },
|
||||
@@ -254,6 +264,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:31:05'),
|
||||
correlationId: 'cmr-7e9a2c5f-d1b4',
|
||||
agent: 'prod-2',
|
||||
correlationGroup: 'payment-flow-002',
|
||||
errorMessage: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP operation failed invoking https://pay.provider.com/v2/charge with statusCode: 422 — Unprocessable Entity: card declined (insufficient funds)',
|
||||
errorClass: 'org.apache.camel.component.http.HttpOperationFailedException',
|
||||
processors: [
|
||||
@@ -273,6 +284,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:22:44'),
|
||||
correlationId: 'cmr-b5c8d2a7-f4e3',
|
||||
agent: 'prod-3',
|
||||
correlationGroup: 'shipment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 5 },
|
||||
@@ -291,6 +303,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:15:19'),
|
||||
correlationId: 'cmr-d9e3f7b1-a6c5',
|
||||
agent: 'prod-4',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 4 },
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface MetricSeries {
|
||||
data: TimeSeriesPoint[]
|
||||
}
|
||||
|
||||
// Generate a realistic time series for the past shift (06:00 - now ~09:15)
|
||||
// Generate a realistic time series for the past hours (06:00 - now ~09:15)
|
||||
function generateTimeSeries(
|
||||
baseValue: number,
|
||||
variance: number,
|
||||
@@ -44,12 +44,12 @@ function generateTimeSeries(
|
||||
// KPI stat cards data
|
||||
export const kpiMetrics: KpiMetric[] = [
|
||||
{
|
||||
label: 'Exchanges (shift)',
|
||||
label: 'Exchanges',
|
||||
value: '3,241',
|
||||
trend: 'up',
|
||||
trendValue: '+12%',
|
||||
trendSentiment: 'good',
|
||||
detail: '97.1% success since 06:00',
|
||||
detail: '97.1% success rate',
|
||||
accent: 'amber',
|
||||
sparkline: [28, 32, 29, 35, 38, 41, 37, 44, 42, 47, 45, 51, 48, 52],
|
||||
},
|
||||
@@ -64,12 +64,12 @@ export const kpiMetrics: KpiMetric[] = [
|
||||
sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1],
|
||||
},
|
||||
{
|
||||
label: 'Errors (shift)',
|
||||
label: 'Errors',
|
||||
value: 38,
|
||||
trend: 'up',
|
||||
trendValue: '+5',
|
||||
trendSentiment: 'bad',
|
||||
detail: '23 overnight · 15 since 06:00',
|
||||
detail: '38 errors in selected period',
|
||||
accent: 'error',
|
||||
sparkline: [1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6, 8],
|
||||
},
|
||||
@@ -147,6 +147,7 @@ export const errorCountSeries: MetricSeries[] = [
|
||||
export interface RouteMetricRow {
|
||||
routeId: string
|
||||
routeName: string
|
||||
appId: string
|
||||
exchangeCount: number
|
||||
successRate: number
|
||||
avgDurationMs: number
|
||||
@@ -159,6 +160,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
{
|
||||
routeId: 'order-intake',
|
||||
routeName: 'order-intake',
|
||||
appId: 'order-service',
|
||||
exchangeCount: 892,
|
||||
successRate: 99.2,
|
||||
avgDurationMs: 88,
|
||||
@@ -169,6 +171,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
{
|
||||
routeId: 'order-enrichment',
|
||||
routeName: 'order-enrichment',
|
||||
appId: 'order-service',
|
||||
exchangeCount: 541,
|
||||
successRate: 97.6,
|
||||
avgDurationMs: 156,
|
||||
@@ -179,6 +182,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
{
|
||||
routeId: 'payment-process',
|
||||
routeName: 'payment-process',
|
||||
appId: 'payment-svc',
|
||||
exchangeCount: 414,
|
||||
successRate: 96.1,
|
||||
avgDurationMs: 234,
|
||||
@@ -186,9 +190,21 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
errorCount: 16,
|
||||
sparkline: [210, 225, 232, 218, 241, 235, 228, 242, 238, 231, 244, 237, 233, 234],
|
||||
},
|
||||
{
|
||||
routeId: 'payment-validate',
|
||||
routeName: 'payment-validate',
|
||||
appId: 'payment-svc',
|
||||
exchangeCount: 498,
|
||||
successRate: 99.8,
|
||||
avgDurationMs: 142,
|
||||
p99DurationMs: 198,
|
||||
errorCount: 1,
|
||||
sparkline: [138, 141, 140, 143, 145, 142, 144, 141, 139, 143, 142, 140, 141, 142],
|
||||
},
|
||||
{
|
||||
routeId: 'shipment-dispatch',
|
||||
routeName: 'shipment-dispatch',
|
||||
appId: 'shipment-tracker',
|
||||
exchangeCount: 387,
|
||||
successRate: 98.4,
|
||||
avgDurationMs: 118,
|
||||
@@ -196,4 +212,26 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
errorCount: 6,
|
||||
sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118],
|
||||
},
|
||||
{
|
||||
routeId: 'shipment-track',
|
||||
routeName: 'shipment-track',
|
||||
appId: 'shipment-tracker',
|
||||
exchangeCount: 923,
|
||||
successRate: 99.5,
|
||||
avgDurationMs: 94,
|
||||
p99DurationMs: 167,
|
||||
errorCount: 5,
|
||||
sparkline: [88, 91, 93, 95, 92, 94, 96, 93, 91, 95, 94, 92, 93, 94],
|
||||
},
|
||||
{
|
||||
routeId: 'notification-dispatch',
|
||||
routeName: 'notification-dispatch',
|
||||
appId: 'notification-hub',
|
||||
exchangeCount: 471,
|
||||
successRate: 98.9,
|
||||
avgDurationMs: 62,
|
||||
p99DurationMs: 124,
|
||||
errorCount: 5,
|
||||
sparkline: [58, 60, 63, 61, 64, 62, 60, 63, 65, 62, 61, 63, 62, 62],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SearchResult } from '../design-system/composites/CommandPalette/ty
|
||||
import { exchanges, type Exchange } from './exchanges'
|
||||
import { routes } from './routes'
|
||||
import { agents } from './agents'
|
||||
import { SIDEBAR_APPS, type SidebarApp } from './sidebar'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||
@@ -72,14 +72,16 @@ export function buildSearchData(
|
||||
})
|
||||
}
|
||||
|
||||
const routeToApp = buildRouteToAppMap(apps)
|
||||
for (const route of rts) {
|
||||
const appIdForRoute = routeToApp.get(route.id)
|
||||
results.push({
|
||||
id: route.id,
|
||||
category: 'route',
|
||||
title: route.name,
|
||||
badges: [{ label: route.group }],
|
||||
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
|
||||
path: `/routes/${route.id}`,
|
||||
path: appIdForRoute ? `/apps/${appIdForRoute}/${route.id}` : `/apps/${route.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,17 @@ export interface SidebarApp {
|
||||
agents: SidebarAgent[]
|
||||
}
|
||||
|
||||
/** Build a routeId → appId lookup from the sidebar tree */
|
||||
export function buildRouteToAppMap(apps: SidebarApp[] = SIDEBAR_APPS): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
for (const app of apps) {
|
||||
for (const route of app.routes) {
|
||||
map.set(route.id, app.id)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const SIDEBAR_APPS: SidebarApp[] = [
|
||||
{
|
||||
id: 'order-service',
|
||||
|
||||
5
src/pages/Admin/Admin.module.css
Normal file
5
src/pages/Admin/Admin.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.adminContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
}
|
||||
@@ -1,22 +1,45 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
||||
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import styles from './Admin.module.css'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const ADMIN_TABS = [
|
||||
{ label: 'User Management', value: '/admin/rbac' },
|
||||
{ label: 'Audit Log', value: '/admin/audit' },
|
||||
{ label: 'OIDC', value: '/admin/oidc' },
|
||||
]
|
||||
|
||||
interface AdminLayoutProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdminLayout({ title, children }: AdminLayoutProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
export function Admin() {
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<TopBar
|
||||
breadcrumb={[{ label: 'Admin' }]}
|
||||
breadcrumb={[
|
||||
{ label: 'Admin', href: '/admin' },
|
||||
{ label: title },
|
||||
]}
|
||||
environment="PRODUCTION"
|
||||
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
<EmptyState
|
||||
title="Admin Panel"
|
||||
description="Admin panel coming soon."
|
||||
<Tabs
|
||||
tabs={ADMIN_TABS}
|
||||
active={location.pathname}
|
||||
onChange={(path) => navigate(path)}
|
||||
/>
|
||||
<div className={styles.adminContent}>
|
||||
{children}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
86
src/pages/Admin/AuditLog/AuditLog.module.css
Normal file
86
src/pages/Admin/AuditLog/AuditLog.module.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.target {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expandedDetail {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detailField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
153
src/pages/Admin/AuditLog/AuditLog.tsx
Normal file
153
src/pages/Admin/AuditLog/AuditLog.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { AdminLayout } from '../Admin'
|
||||
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
|
||||
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||
import { Select } from '../../../design-system/primitives/Select/Select'
|
||||
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
|
||||
import { DataTable } from '../../../design-system/composites/DataTable/DataTable'
|
||||
import type { Column } from '../../../design-system/composites/DataTable/types'
|
||||
import type { DateRange } from '../../../design-system/utils/timePresets'
|
||||
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
|
||||
import styles from './AuditLog.module.css'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'All categories' },
|
||||
{ value: 'INFRA', label: 'INFRA' },
|
||||
{ value: 'AUTH', label: 'AUTH' },
|
||||
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
||||
{ value: 'CONFIG', label: 'CONFIG' },
|
||||
]
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-GB', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
const COLUMNS: Column<AuditEvent>[] = [
|
||||
{
|
||||
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
|
||||
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'username', header: 'User', sortable: true,
|
||||
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
|
||||
},
|
||||
{
|
||||
key: 'category', header: 'Category', width: '110px', sortable: true,
|
||||
render: (_, row) => <Badge label={row.category} color="auto" />,
|
||||
},
|
||||
{ key: 'action', header: 'Action' },
|
||||
{
|
||||
key: 'target', header: 'Target',
|
||||
render: (_, row) => <span className={styles.target}>{row.target}</span>,
|
||||
},
|
||||
{
|
||||
key: 'result', header: 'Result', width: '90px', sortable: true,
|
||||
render: (_, row) => (
|
||||
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const now = Date.now()
|
||||
const INITIAL_RANGE: DateRange = {
|
||||
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
|
||||
to: new Date(now).toISOString().slice(0, 16),
|
||||
}
|
||||
|
||||
export function AuditLog() {
|
||||
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
|
||||
const [userFilter, setUserFilter] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const from = new Date(dateRange.from).getTime()
|
||||
const to = new Date(dateRange.to).getTime()
|
||||
return AUDIT_EVENTS.filter((e) => {
|
||||
const ts = new Date(e.timestamp).getTime()
|
||||
if (ts < from || ts > to) return false
|
||||
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
|
||||
if (categoryFilter && e.category !== categoryFilter) return false
|
||||
if (searchFilter) {
|
||||
const q = searchFilter.toLowerCase()
|
||||
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [dateRange, userFilter, categoryFilter, searchFilter])
|
||||
|
||||
return (
|
||||
<AdminLayout title="Audit Log">
|
||||
<div className={styles.filters}>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Filter by user..."
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
onClear={() => setUserFilter('')}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
<Select
|
||||
options={CATEGORIES}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search action or target..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
onClear={() => setSearchFilter('')}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Audit Log</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{filtered.length} events
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
data={filtered}
|
||||
sortable
|
||||
flush
|
||||
pageSize={10}
|
||||
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
|
||||
expandedContent={(row) => (
|
||||
<div className={styles.expandedDetail}>
|
||||
<div className={styles.detailGrid}>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>IP Address</span>
|
||||
<MonoText size="xs">{row.ipAddress}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>User Agent</span>
|
||||
<span className={styles.detailValue}>{row.userAgent}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Detail</span>
|
||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
194
src/pages/Admin/AuditLog/auditMocks.ts
Normal file
194
src/pages/Admin/AuditLog/auditMocks.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
export interface AuditEvent {
|
||||
id: string
|
||||
timestamp: string
|
||||
username: string
|
||||
category: 'INFRA' | 'AUTH' | 'USER_MGMT' | 'CONFIG'
|
||||
action: string
|
||||
target: string
|
||||
result: 'SUCCESS' | 'FAILURE'
|
||||
detail: Record<string, unknown>
|
||||
ipAddress: string
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const hour = 3600_000
|
||||
const day = 24 * hour
|
||||
|
||||
export const AUDIT_EVENTS: AuditEvent[] = [
|
||||
{
|
||||
id: 'audit-1', timestamp: new Date(now - 0.5 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
|
||||
target: 'users/alice', result: 'SUCCESS',
|
||||
detail: { displayName: 'Alice Johnson', roles: ['VIEWER'] },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-2', timestamp: new Date(now - 1.2 * hour).toISOString(),
|
||||
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
|
||||
target: 'db/primary', result: 'SUCCESS',
|
||||
detail: { oldSize: 10, newSize: 20, reason: 'auto-scale' },
|
||||
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||
},
|
||||
{
|
||||
id: 'audit-3', timestamp: new Date(now - 2 * hour).toISOString(),
|
||||
username: 'alice', category: 'AUTH', action: 'LOGIN',
|
||||
target: 'sessions/abc123', result: 'SUCCESS',
|
||||
detail: { method: 'OIDC', provider: 'keycloak' },
|
||||
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||||
},
|
||||
{
|
||||
id: 'audit-4', timestamp: new Date(now - 2.5 * hour).toISOString(),
|
||||
username: 'unknown', category: 'AUTH', action: 'LOGIN',
|
||||
target: 'sessions', result: 'FAILURE',
|
||||
detail: { method: 'local', reason: 'invalid_credentials' },
|
||||
ipAddress: '203.0.113.50', userAgent: 'curl/8.1',
|
||||
},
|
||||
{
|
||||
id: 'audit-5', timestamp: new Date(now - 3 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
|
||||
target: 'thresholds/pool-connections', result: 'SUCCESS',
|
||||
detail: { field: 'maxConnections', oldValue: 50, newValue: 100 },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-6', timestamp: new Date(now - 4 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
|
||||
target: 'users/bob', result: 'SUCCESS',
|
||||
detail: { role: 'EDITOR', method: 'direct' },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-7', timestamp: new Date(now - 5 * hour).toISOString(),
|
||||
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
|
||||
target: 'opensearch/exchanges', result: 'SUCCESS',
|
||||
detail: { documents: 15420, duration: '12.3s' },
|
||||
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||
},
|
||||
{
|
||||
id: 'audit-8', timestamp: new Date(now - 6 * hour).toISOString(),
|
||||
username: 'bob', category: 'AUTH', action: 'LOGIN',
|
||||
target: 'sessions/def456', result: 'SUCCESS',
|
||||
detail: { method: 'local' },
|
||||
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
|
||||
},
|
||||
{
|
||||
id: 'audit-9', timestamp: new Date(now - 8 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_GROUP',
|
||||
target: 'groups/developers', result: 'SUCCESS',
|
||||
detail: { parent: null },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-10', timestamp: new Date(now - 10 * hour).toISOString(),
|
||||
username: 'system', category: 'INFRA', action: 'BACKUP',
|
||||
target: 'db/primary', result: 'SUCCESS',
|
||||
detail: { sizeBytes: 524288000, duration: '45s' },
|
||||
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||
},
|
||||
{
|
||||
id: 'audit-11', timestamp: new Date(now - 12 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_OIDC',
|
||||
target: 'config/oidc', result: 'SUCCESS',
|
||||
detail: { field: 'autoSignup', oldValue: false, newValue: true },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-12', timestamp: new Date(now - 1 * day).toISOString(),
|
||||
username: 'alice', category: 'AUTH', action: 'LOGOUT',
|
||||
target: 'sessions/abc123', result: 'SUCCESS',
|
||||
detail: { reason: 'user_initiated' },
|
||||
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||||
},
|
||||
{
|
||||
id: 'audit-13', timestamp: new Date(now - 1 * day - 2 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'USER_MGMT', action: 'DELETE_USER',
|
||||
target: 'users/temp-user', result: 'SUCCESS',
|
||||
detail: { reason: 'cleanup' },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-14', timestamp: new Date(now - 1 * day - 4 * hour).toISOString(),
|
||||
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
|
||||
target: 'db/primary', result: 'FAILURE',
|
||||
detail: { oldSize: 20, newSize: 50, error: 'max_connections_exceeded' },
|
||||
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||
},
|
||||
{
|
||||
id: 'audit-15', timestamp: new Date(now - 1 * day - 6 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'USER_MGMT', action: 'UPDATE_GROUP',
|
||||
target: 'groups/admins', result: 'SUCCESS',
|
||||
detail: { addedMembers: ['alice'], removedMembers: [] },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-16', timestamp: new Date(now - 2 * day).toISOString(),
|
||||
username: 'bob', category: 'AUTH', action: 'PASSWORD_CHANGE',
|
||||
target: 'users/bob', result: 'SUCCESS',
|
||||
detail: { method: 'self_service' },
|
||||
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
|
||||
},
|
||||
{
|
||||
id: 'audit-17', timestamp: new Date(now - 2 * day - 3 * hour).toISOString(),
|
||||
username: 'system', category: 'INFRA', action: 'VACUUM',
|
||||
target: 'db/primary/exchanges', result: 'SUCCESS',
|
||||
detail: { reclaimedBytes: 1048576, duration: '3.2s' },
|
||||
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||
},
|
||||
{
|
||||
id: 'audit-18', timestamp: new Date(now - 2 * day - 5 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
|
||||
target: 'thresholds/latency-p99', result: 'SUCCESS',
|
||||
detail: { field: 'warningMs', oldValue: 500, newValue: 300 },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-19', timestamp: new Date(now - 3 * day).toISOString(),
|
||||
username: 'attacker', category: 'AUTH', action: 'LOGIN',
|
||||
target: 'sessions', result: 'FAILURE',
|
||||
detail: { method: 'local', reason: 'account_locked', attempts: 5 },
|
||||
ipAddress: '198.51.100.23', userAgent: 'python-requests/2.31',
|
||||
},
|
||||
{
|
||||
id: 'audit-20', timestamp: new Date(now - 3 * day - 2 * hour).toISOString(),
|
||||
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
|
||||
target: 'groups/developers', result: 'SUCCESS',
|
||||
detail: { role: 'EDITOR', method: 'group_assignment' },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-21', timestamp: new Date(now - 4 * day).toISOString(),
|
||||
username: 'system', category: 'INFRA', action: 'BACKUP',
|
||||
target: 'db/primary', result: 'FAILURE',
|
||||
detail: { error: 'disk_full', sizeBytes: 0 },
|
||||
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||
},
|
||||
{
|
||||
id: 'audit-22', timestamp: new Date(now - 4 * day - 1 * hour).toISOString(),
|
||||
username: 'alice', category: 'CONFIG', action: 'VIEW_CONFIG',
|
||||
target: 'config/oidc', result: 'SUCCESS',
|
||||
detail: { section: 'provider_settings' },
|
||||
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||||
},
|
||||
{
|
||||
id: 'audit-23', timestamp: new Date(now - 5 * day).toISOString(),
|
||||
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_ROLE',
|
||||
target: 'roles/OPERATOR', result: 'SUCCESS',
|
||||
detail: { scope: 'custom', description: 'Pipeline operator' },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
{
|
||||
id: 'audit-24', timestamp: new Date(now - 5 * day - 3 * hour).toISOString(),
|
||||
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
|
||||
target: 'opensearch/agents', result: 'SUCCESS',
|
||||
detail: { documents: 230, duration: '1.1s' },
|
||||
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||
},
|
||||
{
|
||||
id: 'audit-25', timestamp: new Date(now - 6 * day).toISOString(),
|
||||
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
|
||||
target: 'users/bob', result: 'SUCCESS',
|
||||
detail: { displayName: 'Bob Smith', roles: ['VIEWER'] },
|
||||
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||
},
|
||||
]
|
||||
53
src/pages/Admin/OidcConfig/OidcConfig.module.css
Normal file
53
src/pages/Admin/OidcConfig/OidcConfig.module.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.noRoles {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.addRoleRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roleInput {
|
||||
width: 200px;
|
||||
}
|
||||
188
src/pages/Admin/OidcConfig/OidcConfig.tsx
Normal file
188
src/pages/Admin/OidcConfig/OidcConfig.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react'
|
||||
import { AdminLayout } from '../Admin'
|
||||
import { Button } from '../../../design-system/primitives/Button/Button'
|
||||
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||
import { Toggle } from '../../../design-system/primitives/Toggle/Toggle'
|
||||
import { FormField } from '../../../design-system/primitives/FormField/FormField'
|
||||
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||
import styles from './OidcConfig.module.css'
|
||||
|
||||
interface OidcFormData {
|
||||
enabled: boolean
|
||||
autoSignup: boolean
|
||||
issuerUri: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
rolesClaim: string
|
||||
displayNameClaim: string
|
||||
defaultRoles: string[]
|
||||
}
|
||||
|
||||
const INITIAL_DATA: OidcFormData = {
|
||||
enabled: true,
|
||||
autoSignup: true,
|
||||
issuerUri: 'https://keycloak.example.com/realms/cameleer',
|
||||
clientId: 'cameleer-app',
|
||||
clientSecret: '••••••••••••',
|
||||
rolesClaim: 'realm_access.roles',
|
||||
displayNameClaim: 'name',
|
||||
defaultRoles: ['USER', 'VIEWER'],
|
||||
}
|
||||
|
||||
export function OidcConfig() {
|
||||
const [form, setForm] = useState<OidcFormData>(INITIAL_DATA)
|
||||
const [newRole, setNewRole] = useState('')
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function addRole() {
|
||||
const role = newRole.trim().toUpperCase()
|
||||
if (role && !form.defaultRoles.includes(role)) {
|
||||
update('defaultRoles', [...form.defaultRoles, role])
|
||||
setNewRole('')
|
||||
}
|
||||
}
|
||||
|
||||
function removeRole(role: string) {
|
||||
update('defaultRoles', form.defaultRoles.filter((r) => r !== role))
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' })
|
||||
}
|
||||
|
||||
function handleTest() {
|
||||
toast({ title: 'Connection test', description: 'OIDC provider responded successfully.', variant: 'info' })
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
setDeleteOpen(false)
|
||||
setForm({ ...INITIAL_DATA, enabled: false, issuerUri: '', clientId: '', clientSecret: '', defaultRoles: [] })
|
||||
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' })
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="OIDC Configuration">
|
||||
<div className={styles.page}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Behavior</SectionHeader>
|
||||
<div className={styles.toggleRow}>
|
||||
<Toggle
|
||||
label="Enabled"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => update('enabled', e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toggleRow}>
|
||||
<Toggle
|
||||
label="Auto Sign-Up"
|
||||
checked={form.autoSignup}
|
||||
onChange={(e) => update('autoSignup', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Provider Settings</SectionHeader>
|
||||
<FormField label="Issuer URI" htmlFor="issuer">
|
||||
<Input
|
||||
id="issuer"
|
||||
type="url"
|
||||
placeholder="https://idp.example.com/realms/my-realm"
|
||||
value={form.issuerUri}
|
||||
onChange={(e) => update('issuerUri', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Client ID" htmlFor="client-id">
|
||||
<Input
|
||||
id="client-id"
|
||||
value={form.clientId}
|
||||
onChange={(e) => update('clientId', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Client Secret" htmlFor="client-secret">
|
||||
<Input
|
||||
id="client-secret"
|
||||
type="password"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) => update('clientSecret', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Claim Mapping</SectionHeader>
|
||||
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
|
||||
<Input
|
||||
id="roles-claim"
|
||||
value={form.rolesClaim}
|
||||
onChange={(e) => update('rolesClaim', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
|
||||
<Input
|
||||
id="name-claim"
|
||||
value={form.displayNameClaim}
|
||||
onChange={(e) => update('displayNameClaim', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Default Roles</SectionHeader>
|
||||
<div className={styles.tagList}>
|
||||
{form.defaultRoles.map((role) => (
|
||||
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
|
||||
))}
|
||||
{form.defaultRoles.length === 0 && (
|
||||
<span className={styles.noRoles}>No default roles configured</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.addRoleRow}>
|
||||
<Input
|
||||
placeholder="Add role..."
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole() } }}
|
||||
className={styles.roleInput}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Danger Zone</SectionHeader>
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
|
||||
Delete OIDC Configuration
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
|
||||
confirmText="delete oidc"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
297
src/pages/Admin/UserManagement/GroupsTab.tsx
Normal file
297
src/pages/Admin/UserManagement/GroupsTab.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
|
||||
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||
import { Button } from '../../../design-system/primitives/Button/Button'
|
||||
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||
import { Select } from '../../../design-system/primitives/Select/Select'
|
||||
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||
import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit'
|
||||
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
||||
import { SplitPane } from '../../../design-system/composites/SplitPane/SplitPane'
|
||||
import { EntityList } from '../../../design-system/composites/EntityList/EntityList'
|
||||
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, type MockGroup } from './rbacMocks'
|
||||
import styles from './UserManagement.module.css'
|
||||
|
||||
export function GroupsTab() {
|
||||
const { toast } = useToast()
|
||||
const [groups, setGroups] = useState(MOCK_GROUPS)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<MockGroup | null>(null)
|
||||
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null)
|
||||
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newParent, setNewParent] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return groups
|
||||
const q = search.toLowerCase()
|
||||
return groups.filter((g) => g.name.toLowerCase().includes(q))
|
||||
}, [groups, search])
|
||||
|
||||
const selected = groups.find((g) => g.id === selectedId) ?? null
|
||||
|
||||
function handleCreate() {
|
||||
if (!newName.trim()) return
|
||||
const newGroup: MockGroup = {
|
||||
id: `grp-${Date.now()}`,
|
||||
name: newName.trim(),
|
||||
parentId: newParent || null,
|
||||
builtIn: false,
|
||||
directRoles: [],
|
||||
memberUserIds: [],
|
||||
}
|
||||
setGroups((prev) => [...prev, newGroup])
|
||||
setCreating(false)
|
||||
setNewName(''); setNewParent('')
|
||||
setSelectedId(newGroup.id)
|
||||
toast({ title: 'Group created', description: newGroup.name, variant: 'success' })
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
setGroups((prev) => prev.filter((g) => g.id !== deleteTarget.id))
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||
setDeleteTarget(null)
|
||||
toast({ title: 'Group deleted', description: deleteTarget.name, variant: 'warning' })
|
||||
}
|
||||
|
||||
function updateGroup(id: string, patch: Partial<MockGroup>) {
|
||||
setGroups((prev) => prev.map((g) => g.id === id ? { ...g, ...patch } : g))
|
||||
}
|
||||
|
||||
const duplicateGroupName = newName.trim() !== '' && groups.some((g) => g.name.toLowerCase() === newName.trim().toLowerCase())
|
||||
|
||||
const children = selected ? groups.filter((g) => g.parentId === selected.id) : []
|
||||
const members = selected ? MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)) : []
|
||||
const parent = selected?.parentId ? groups.find((g) => g.id === selected.parentId) : null
|
||||
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
|
||||
.map((r) => ({ value: r.name, label: r.name }))
|
||||
const availableMembers = MOCK_USERS.filter((u) => !selected || !u.directGroups.includes(selected.id))
|
||||
.map((u) => ({ value: u.id, label: u.displayName }))
|
||||
const availableChildGroups = groups.filter((g) => selected && g.id !== selected.id && g.parentId !== selected.id && !children.some((c) => c.id === g.id))
|
||||
.map((g) => ({ value: g.id, label: g.name }))
|
||||
|
||||
const parentOptions = [
|
||||
{ value: '', label: 'Top-level' },
|
||||
...groups.filter((g) => g.id !== selectedId).map((g) => ({ value: g.id, label: g.name })),
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplitPane
|
||||
list={
|
||||
<>
|
||||
{creating && (
|
||||
<div className={styles.createForm}>
|
||||
<Input placeholder="Group name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||
{duplicateGroupName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Group name already exists</span>}
|
||||
<Select
|
||||
options={parentOptions}
|
||||
value={newParent}
|
||||
onChange={(e) => setNewParent(e.target.value)}
|
||||
/>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateGroupName}>Create</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityList
|
||||
items={filtered}
|
||||
renderItem={(group) => {
|
||||
const groupChildren = groups.filter((g) => g.parentId === group.id)
|
||||
const groupMembers = MOCK_USERS.filter((u) => u.directGroups.includes(group.id))
|
||||
const groupParent = group.parentId ? groups.find((g) => g.id === group.parentId) : null
|
||||
return (
|
||||
<>
|
||||
<Avatar name={group.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>{group.name}</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{groupParent ? `Child of ${groupParent.name}` : 'Top-level'}
|
||||
{' · '}{groupChildren.length} children · {groupMembers.length} members
|
||||
</div>
|
||||
<div className={styles.entityTags}>
|
||||
{group.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
getItemId={(group) => group.id}
|
||||
selectedId={selectedId ?? undefined}
|
||||
onSelect={setSelectedId}
|
||||
searchPlaceholder="Search groups..."
|
||||
onSearch={setSearch}
|
||||
addLabel="+ Add group"
|
||||
onAdd={() => setCreating(true)}
|
||||
emptyMessage="No groups match your search"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
detail={selected ? (
|
||||
<>
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={selected.name} size="lg" />
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailName}>
|
||||
{selected.builtIn ? selected.name : (
|
||||
<InlineEdit
|
||||
value={selected.name}
|
||||
onSave={(v) => updateGroup(selected.id, { name: v })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailEmail}>
|
||||
{parent ? `${parent.name} > ${selected.name}` : 'Top-level group'}
|
||||
{selected.builtIn && ' (built-in)'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(selected)}
|
||||
disabled={selected.builtIn}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>ID</span>
|
||||
<MonoText size="xs">{selected.id}</MonoText>
|
||||
</div>
|
||||
|
||||
{parent && (
|
||||
<>
|
||||
<SectionHeader>Member of</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
<Tag label={parent.name} color="auto" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionHeader>Members (direct)</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{members.map((u) => (
|
||||
<Tag
|
||||
key={u.id}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
onRemove={() => {
|
||||
// Remove this group from the user's directGroups
|
||||
// Note: in mock data we can't easily update MOCK_USERS, so this is visual only
|
||||
toast({ title: 'Member removed', description: u.displayName, variant: 'success' })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{members.length === 0 && <span className={styles.inheritedNote}>(no members)</span>}
|
||||
<MultiSelect
|
||||
options={availableMembers}
|
||||
value={[]}
|
||||
onChange={(ids) => {
|
||||
toast({ title: `${ids.length} member(s) added`, variant: 'success' })
|
||||
}}
|
||||
placeholder="+ Add"
|
||||
/>
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<span className={styles.inheritedNote}>
|
||||
+ all members of {children.map((c) => c.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<SectionHeader>Child groups</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{children.map((c) => (
|
||||
<Tag
|
||||
key={c.id}
|
||||
label={c.name}
|
||||
color="success"
|
||||
onRemove={() => {
|
||||
updateGroup(c.id, { parentId: null })
|
||||
toast({ title: 'Child group removed', description: c.name, variant: 'success' })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{children.length === 0 && <span className={styles.inheritedNote}>(no child groups)</span>}
|
||||
<MultiSelect
|
||||
options={availableChildGroups}
|
||||
value={[]}
|
||||
onChange={(ids) => {
|
||||
for (const id of ids) {
|
||||
updateGroup(id, { parentId: selected!.id })
|
||||
}
|
||||
toast({ title: `${ids.length} child group(s) added`, variant: 'success' })
|
||||
}}
|
||||
placeholder="+ Add"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionHeader>Assigned roles</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{selected.directRoles.map((r) => (
|
||||
<Tag
|
||||
key={r}
|
||||
label={r}
|
||||
color="warning"
|
||||
onRemove={() => {
|
||||
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
|
||||
if (memberCount > 0) {
|
||||
setRemoveRoleTarget(r)
|
||||
} else {
|
||||
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
|
||||
toast({ title: 'Role removed', variant: 'success' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{selected.directRoles.length === 0 && <span className={styles.inheritedNote}>(no roles)</span>}
|
||||
<MultiSelect
|
||||
options={availableRoles}
|
||||
value={[]}
|
||||
onChange={(roles) => {
|
||||
updateGroup(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
||||
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
||||
}}
|
||||
placeholder="+ Add"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
emptyMessage="Select a group to view details"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
|
||||
confirmText={deleteTarget?.name ?? ''}
|
||||
/>
|
||||
<AlertDialog
|
||||
open={removeRoleTarget !== null}
|
||||
onClose={() => setRemoveRoleTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (removeRoleTarget && selected) {
|
||||
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== removeRoleTarget) })
|
||||
toast({ title: 'Role removed', variant: 'success' })
|
||||
}
|
||||
setRemoveRoleTarget(null)
|
||||
}}
|
||||
title="Remove role from group"
|
||||
description={`Removing ${removeRoleTarget} from ${selected?.name} will affect ${members.length} member(s) who inherit this role. Continue?`}
|
||||
confirmLabel="Remove"
|
||||
variant="warning"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
212
src/pages/Admin/UserManagement/RolesTab.tsx
Normal file
212
src/pages/Admin/UserManagement/RolesTab.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
|
||||
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||
import { Button } from '../../../design-system/primitives/Button/Button'
|
||||
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||
import { SplitPane } from '../../../design-system/composites/SplitPane/SplitPane'
|
||||
import { EntityList } from '../../../design-system/composites/EntityList/EntityList'
|
||||
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
|
||||
import styles from './UserManagement.module.css'
|
||||
|
||||
export function RolesTab() {
|
||||
const { toast } = useToast()
|
||||
const [roles, setRoles] = useState(MOCK_ROLES)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<MockRole | null>(null)
|
||||
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return roles
|
||||
const q = search.toLowerCase()
|
||||
return roles.filter((r) =>
|
||||
r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)
|
||||
)
|
||||
}, [roles, search])
|
||||
|
||||
const selected = roles.find((r) => r.id === selectedId) ?? null
|
||||
|
||||
function handleCreate() {
|
||||
if (!newName.trim()) return
|
||||
const newRole: MockRole = {
|
||||
id: `role-${Date.now()}`,
|
||||
name: newName.trim().toUpperCase(),
|
||||
description: newDesc.trim(),
|
||||
scope: 'custom',
|
||||
system: false,
|
||||
}
|
||||
setRoles((prev) => [...prev, newRole])
|
||||
setCreating(false)
|
||||
setNewName(''); setNewDesc('')
|
||||
setSelectedId(newRole.id)
|
||||
toast({ title: 'Role created', description: newRole.name, variant: 'success' })
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
setRoles((prev) => prev.filter((r) => r.id !== deleteTarget.id))
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||
setDeleteTarget(null)
|
||||
toast({ title: 'Role deleted', description: deleteTarget.name, variant: 'warning' })
|
||||
}
|
||||
|
||||
const duplicateRoleName = newName.trim() !== '' && roles.some((r) => r.name === newName.trim().toUpperCase())
|
||||
|
||||
// Role assignments
|
||||
const assignedGroups = selected
|
||||
? MOCK_GROUPS.filter((g) => g.directRoles.includes(selected.name))
|
||||
: []
|
||||
|
||||
const directUsers = selected
|
||||
? MOCK_USERS.filter((u) => u.directRoles.includes(selected.name))
|
||||
: []
|
||||
|
||||
const effectivePrincipals = selected
|
||||
? MOCK_USERS.filter((u) => getEffectiveRoles(u).some((r) => r.role === selected.name))
|
||||
: []
|
||||
|
||||
function getAssignmentCount(role: MockRole): number {
|
||||
const groups = MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name)).length
|
||||
const users = MOCK_USERS.filter((u) => u.directRoles.includes(role.name)).length
|
||||
return groups + users
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplitPane
|
||||
list={
|
||||
<>
|
||||
{creating && (
|
||||
<div className={styles.createForm}>
|
||||
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||
{duplicateRoleName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Role name already exists</span>}
|
||||
<Input placeholder="Description" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
|
||||
<div className={styles.createFormActions}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateRoleName}>Create</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityList
|
||||
items={filtered}
|
||||
renderItem={(role) => (
|
||||
<>
|
||||
<Avatar name={role.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>
|
||||
{role.name}
|
||||
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
|
||||
</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{role.description} · {getAssignmentCount(role)} assignments
|
||||
</div>
|
||||
<div className={styles.entityTags}>
|
||||
{MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name))
|
||||
.map((g) => <Badge key={g.id} label={g.name} color="success" />)}
|
||||
{MOCK_USERS.filter((u) => u.directRoles.includes(role.name))
|
||||
.map((u) => <Badge key={u.id} label={u.username} color="auto" />)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
getItemId={(role) => role.id}
|
||||
selectedId={selectedId ?? undefined}
|
||||
onSelect={setSelectedId}
|
||||
searchPlaceholder="Search roles..."
|
||||
onSearch={setSearch}
|
||||
addLabel="+ Add role"
|
||||
onAdd={() => setCreating(true)}
|
||||
emptyMessage="No roles match your search"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
detail={selected ? (
|
||||
<>
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={selected.name} size="lg" />
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailName}>{selected.name}</div>
|
||||
{selected.description && (
|
||||
<div className={styles.detailEmail}>{selected.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{!selected.system && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(selected)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>ID</span>
|
||||
<MonoText size="xs">{selected.id}</MonoText>
|
||||
<span className={styles.metaLabel}>Scope</span>
|
||||
<span className={styles.metaValue}>{selected.scope}</span>
|
||||
{selected.system && (
|
||||
<>
|
||||
<span className={styles.metaLabel}>Type</span>
|
||||
<span className={styles.metaValue}>System role (read-only)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader>Assigned to groups</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{assignedGroups.map((g) => <Tag key={g.id} label={g.name} color="success" />)}
|
||||
{assignedGroups.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||
</div>
|
||||
|
||||
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{directUsers.map((u) => <Tag key={u.id} label={u.displayName} color="auto" />)}
|
||||
{directUsers.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||
</div>
|
||||
|
||||
<SectionHeader>Effective principals</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{effectivePrincipals.map((u) => {
|
||||
const isDirect = u.directRoles.includes(selected.name)
|
||||
return (
|
||||
<Badge
|
||||
key={u.id}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
variant={isDirect ? 'filled' : 'dashed'}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{effectivePrincipals.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||
</div>
|
||||
{effectivePrincipals.some((u) => !u.directRoles.includes(selected.name)) && (
|
||||
<span className={styles.inheritedNote}>
|
||||
Dashed entries inherit this role through group membership
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
emptyMessage="Select a role to view details"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
|
||||
confirmText={deleteTarget?.name ?? ''}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
151
src/pages/Admin/UserManagement/UserManagement.module.css
Normal file
151
src/pages/Admin/UserManagement/UserManagement.module.css
Normal file
@@ -0,0 +1,151 @@
|
||||
.entityInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entityName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.entityMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.entityTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detailHeaderInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detailName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.detailEmail {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.metaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.selectWrap {
|
||||
margin-top: 8px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.createFormRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.createFormActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.inheritedNote {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-family: var(--font-body);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.inherited {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.securitySection {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.securityRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.passwordDots {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.resetForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.resetInput {
|
||||
width: 200px;
|
||||
}
|
||||
28
src/pages/Admin/UserManagement/UserManagement.tsx
Normal file
28
src/pages/Admin/UserManagement/UserManagement.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react'
|
||||
import styles from './UserManagement.module.css'
|
||||
import { AdminLayout } from '../Admin'
|
||||
import { Tabs } from '../../../design-system/composites/Tabs/Tabs'
|
||||
import { UsersTab } from './UsersTab'
|
||||
import { GroupsTab } from './GroupsTab'
|
||||
import { RolesTab } from './RolesTab'
|
||||
|
||||
const TABS = [
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: 'Groups', value: 'groups' },
|
||||
{ label: 'Roles', value: 'roles' },
|
||||
]
|
||||
|
||||
export function UserManagement() {
|
||||
const [tab, setTab] = useState('users')
|
||||
|
||||
return (
|
||||
<AdminLayout title="User Management">
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
<div className={styles.tabContent}>
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'groups' && <GroupsTab />}
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user