Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f359a2ba3d | ||
|
|
384ee97643 | ||
|
|
a12b374fb2 | ||
|
|
433d582da6 | ||
|
|
2ffc268b44 | ||
|
|
99ae66315b | ||
|
|
26de5ec58f | ||
|
|
d26dc6a8a5 | ||
|
|
c0b1cbdc5b | ||
|
|
d101d883a9 | ||
|
|
2a020c1e15 | ||
|
|
19303eefad | ||
|
|
5fe6321d30 | ||
|
|
90e3de2cdf | ||
|
|
499c86b680 | ||
|
|
63e16d2685 | ||
|
|
19dccb8685 | ||
|
|
4b873194c9 | ||
|
|
5f1b039056 | ||
|
|
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 |
@@ -15,7 +15,21 @@
|
|||||||
"Bash(echo \"EXIT:$?\")",
|
"Bash(echo \"EXIT:$?\")",
|
||||||
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||||
"Bash(echo \"EXIT_CODE=$?\")",
|
"Bash(echo \"EXIT_CODE=$?\")",
|
||||||
"Bash(echo \"EXIT=$?\")"
|
"Bash(echo \"EXIT=$?\")",
|
||||||
|
"mcp__gitea__actions_config_read",
|
||||||
|
"mcp__gitea__search_repos",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")",
|
||||||
|
"Bash(npx vite:*)",
|
||||||
|
"Bash(cd:*)",
|
||||||
|
"mcp__gitea__actions_run_read",
|
||||||
|
"mcp__gitea__get_file_contents",
|
||||||
|
"WebFetch(domain:ui.shadcn.com)",
|
||||||
|
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||||
|
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(xargs cat:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npx vitest run
|
run: npx vitest run --exclude 'e2e/**'
|
||||||
|
|
||||||
- name: Build library
|
- name: Build library
|
||||||
run: npm run build:lib
|
run: npm run build:lib
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*)
|
refs/tags/v*)
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
npm version "$VERSION" --no-git-tag-version
|
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
|
|||||||
### Import Paths
|
### Import Paths
|
||||||
```tsx
|
```tsx
|
||||||
import { Button, Input } from '../design-system/primitives'
|
import { Button, Input } from '../design-system/primitives'
|
||||||
import { Modal, DataTable } from '../design-system/composites'
|
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
|
||||||
import type { Column } from '../design-system/composites'
|
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
|
||||||
import { AppShell } from '../design-system/layout/AppShell'
|
import { AppShell } from '../design-system/layout/AppShell'
|
||||||
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
@@ -91,10 +91,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// All components from single entry
|
// 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
|
// Types
|
||||||
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Providers
|
// Providers
|
||||||
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
|
|
||||||
### "I need to show status"
|
### "I need to show status"
|
||||||
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
|
- 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
|
- Labeled status → **Badge** with semantic color
|
||||||
- Removable label → **Tag**
|
- Removable label → **Tag**
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@
|
|||||||
- Event log → **EventFeed**
|
- Event log → **EventFeed**
|
||||||
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||||
- Processing pipeline (flow diagram) → **RouteFlow**
|
- 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"
|
### "I need to organize content"
|
||||||
- Collapsible sections (standalone) → **Collapsible**
|
- Collapsible sections (standalone) → **Collapsible**
|
||||||
@@ -64,15 +68,17 @@
|
|||||||
- Tabbed content → **Tabs**
|
- Tabbed content → **Tabs**
|
||||||
- Tab switching with pill/segment style → **SegmentedTabs**
|
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||||
- Side panel inspector → **DetailPanel**
|
- Side panel inspector → **DetailPanel**
|
||||||
|
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
|
||||||
- Section with title + action → **SectionHeader**
|
- Section with title + action → **SectionHeader**
|
||||||
- Empty content placeholder → **EmptyState**
|
- 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)
|
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
|
||||||
|
|
||||||
### "I need to display text"
|
### "I need to display text"
|
||||||
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
|
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
|
||||||
- Monospace inline text → **MonoText**
|
- Monospace inline text → **MonoText**
|
||||||
- Keyboard shortcut hint → **KeyboardHint**
|
- 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"
|
### "I need to show people/users"
|
||||||
- Single user avatar → **Avatar**
|
- Single user avatar → **Avatar**
|
||||||
@@ -115,6 +121,13 @@ Row of StatCard components (each with optional Sparkline and trend)
|
|||||||
Below: charts (AreaChart, LineChart, BarChart)
|
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
|
### Detail/inspector pattern
|
||||||
```
|
```
|
||||||
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
|
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
|
||||||
@@ -162,7 +175,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| Breadcrumb | composite | Navigation path showing current location |
|
| Breadcrumb | composite | Navigation path showing current location |
|
||||||
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
||||||
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
| 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 |
|
| Card | primitive | Content container with optional accent border and title header |
|
||||||
| Checkbox | primitive | Boolean input with label |
|
| Checkbox | primitive | Boolean input with label |
|
||||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||||
| Collapsible | primitive | Single expand/collapse section |
|
| Collapsible | primitive | Single expand/collapse section |
|
||||||
@@ -174,6 +187,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
||||||
| Dropdown | composite | Action menu triggered by any element |
|
| Dropdown | composite | Action menu triggered by any element |
|
||||||
| EmptyState | primitive | Placeholder for empty content areas |
|
| 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 |
|
| EventFeed | composite | Chronological event log with severity |
|
||||||
| FilterBar | composite | Search + filter controls for data views |
|
| 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. |
|
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
|
||||||
@@ -183,16 +197,18 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
| 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 |
|
| Input | primitive | Single-line text input with optional icon |
|
||||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
| 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 |
|
| Label | primitive | Form label with optional required asterisk |
|
||||||
| LineChart | composite | Time series line visualization |
|
| 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 |
|
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||||
| Modal | composite | Generic dialog overlay with backdrop |
|
| 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 |
|
| 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) |
|
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||||
| Pagination | primitive | Page navigation controls |
|
| Pagination | primitive | Page navigation controls |
|
||||||
| Popover | composite | Click-triggered floating panel with arrow |
|
| Popover | composite | Click-triggered floating panel with arrow |
|
||||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. |
|
||||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. |
|
||||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||||
@@ -202,9 +218,11 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
||||||
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
||||||
| Sparkline | primitive | Inline mini chart for trends |
|
| 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 |
|
| Spinner | primitive | Animated loading indicator |
|
||||||
| StatCard | primitive | KPI card with value, trend, optional sparkline |
|
| StatCard | primitive | KPI card with value, trend, optional sparkline |
|
||||||
| StatusDot | primitive | Colored dot for status indication |
|
| 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 |
|
| Tabs | composite | Tabbed content switcher with optional counts |
|
||||||
| Tag | primitive | Removable colored label |
|
| Tag | primitive | Removable colored label |
|
||||||
| Textarea | primitive | Multi-line text input with resize control |
|
| Textarea | primitive | Multi-line text input with resize control |
|
||||||
|
|||||||
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}
|
||||||
|
```
|
||||||
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`.
|
||||||
165
e2e/admin.spec.ts
Normal file
165
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Admin - User Management (/admin/rbac)', () => {
|
||||||
|
test('renders admin tabs and user table', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Admin navigation tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible()
|
||||||
|
|
||||||
|
// User Management sub-tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar breadcrumb
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Users, Groups, and Roles tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Default tab is Users
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
|
||||||
|
// Switch to Groups tab
|
||||||
|
await page.getByRole('tab', { name: 'Groups' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch to Roles tab
|
||||||
|
await page.getByRole('tab', { name: 'Roles' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch back to Users
|
||||||
|
await page.getByRole('tab', { name: 'Users' }).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating between admin sections via tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Click Audit Log tab
|
||||||
|
await page.getByRole('tab', { name: 'Audit Log' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/audit/)
|
||||||
|
|
||||||
|
// Click OIDC tab
|
||||||
|
await page.getByRole('tab', { name: 'OIDC' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/oidc/)
|
||||||
|
|
||||||
|
// Back to User Management
|
||||||
|
await page.getByRole('tab', { name: 'User Management' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/rbac/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - Audit Log (/admin/audit)', () => {
|
||||||
|
test('renders audit table with filters', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Filter inputs exist
|
||||||
|
await expect(page.getByPlaceholder('Filter by user...')).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('Search action or target...')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filtering audit events by search', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
const searchInput = page.getByPlaceholder('Search action or target...')
|
||||||
|
await searchInput.fill('deploy')
|
||||||
|
|
||||||
|
// Table should update
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
const count = await rows.count()
|
||||||
|
expect(count).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - OIDC Config (/admin/oidc)', () => {
|
||||||
|
test('renders OIDC form with all fields', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Section headers
|
||||||
|
await expect(page.getByText('Behavior')).toBeVisible()
|
||||||
|
await expect(page.getByText('Provider Settings')).toBeVisible()
|
||||||
|
await expect(page.getByText('Claim Mapping')).toBeVisible()
|
||||||
|
await expect(page.getByText('Default Roles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Danger Zone')).toBeVisible()
|
||||||
|
|
||||||
|
// Form fields by id
|
||||||
|
await expect(page.locator('#issuer')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-id')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-secret')).toBeVisible()
|
||||||
|
await expect(page.locator('#roles-claim')).toBeVisible()
|
||||||
|
await expect(page.locator('#name-claim')).toBeVisible()
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible()
|
||||||
|
|
||||||
|
// Default roles tags
|
||||||
|
await expect(page.getByText('USER').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('VIEWER').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggling Enabled switch', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// The Toggle's checkbox is visually hidden — click the label wrapper instead
|
||||||
|
const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' })
|
||||||
|
await enabledLabel.click()
|
||||||
|
|
||||||
|
// Should not crash; label still visible
|
||||||
|
await expect(enabledLabel).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adding and removing a role tag', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Add a new role
|
||||||
|
const roleInput = page.getByPlaceholder('Add role...')
|
||||||
|
await roleInput.fill('EDITOR')
|
||||||
|
// Use the Add button next to the input (scoped to same row)
|
||||||
|
await roleInput.press('Enter')
|
||||||
|
|
||||||
|
// New role tag should appear
|
||||||
|
await expect(page.getByText('EDITOR')).toBeVisible()
|
||||||
|
|
||||||
|
// Remove it via aria-label on the tag's remove button
|
||||||
|
await page.getByRole('button', { name: 'Remove EDITOR' }).click()
|
||||||
|
|
||||||
|
// EDITOR tag should be gone
|
||||||
|
await expect(page.getByText('EDITOR')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Save button shows success toast', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
await expect(page.getByText('Settings saved')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Delete button shows confirmation dialog', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Delete OIDC/i }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog should appear
|
||||||
|
await expect(page.getByText('Delete OIDC configuration?')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
80
e2e/agents.spec.ts
Normal file
80
e2e/agents.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Agent Health (/agents)', () => {
|
||||||
|
test('renders stat cards and group cards', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Stat strip
|
||||||
|
await expect(page.getByText('Total Agents')).toBeVisible()
|
||||||
|
await expect(page.getByText('Total TPS')).toBeVisible()
|
||||||
|
|
||||||
|
// Group cards for each application
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('notification-hub').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Instance tables have data
|
||||||
|
const instanceRows = page.locator('table tbody tr')
|
||||||
|
expect(await instanceRows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Instance table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline section
|
||||||
|
await expect(page.getByText('Timeline').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking an instance row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click first instance row
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Detail panel opens — look for detail-specific labels
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Throughput').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detail panel has Performance tab with charts', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click an instance to open detail panel
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Wait for panel to open
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
|
||||||
|
// DetailPanel tabs are plain buttons (not role="tab")
|
||||||
|
// Switch to Performance tab
|
||||||
|
const perfTab = page.getByRole('button', { name: 'Performance' })
|
||||||
|
await perfTab.click()
|
||||||
|
|
||||||
|
// Performance charts should render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped agents view', async ({ page }) => {
|
||||||
|
await page.goto('/agents/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb/scope shows app
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible()
|
||||||
|
|
||||||
|
// Only order-service agents should show
|
||||||
|
await expect(page.getByText('ord-1').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-2').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-3').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dead agent shows alert banner', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// notification-hub has a dead instance, should show alert
|
||||||
|
await expect(page.getByText('Single point of failure')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
90
e2e/dashboard.spec.ts
Normal file
90
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */
|
||||||
|
async function widenTimeRange(page: import('@playwright/test').Page) {
|
||||||
|
await page.getByRole('tab', { name: '7d' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Dashboard (/apps)', () => {
|
||||||
|
test('renders KPI stat cards and exchange table', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// KPI health strip renders
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data rows
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Sidebar renders with app names
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar renders
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible()
|
||||||
|
await expect(page.getByText('PRODUCTION')).toBeVisible()
|
||||||
|
|
||||||
|
// Shortcuts bar
|
||||||
|
await expect(page.getByText('Ctrl+K').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a table row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Click the first data row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Detail panel should open — look for "Open full details" link
|
||||||
|
await expect(page.getByText('Open full details')).toBeVisible()
|
||||||
|
// Overview section
|
||||||
|
await expect(page.getByText('Correlation').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => {
|
||||||
|
await page.goto('/apps/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows app scope
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table should still render
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sidebar navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
|
||||||
|
// Click on an app in the sidebar
|
||||||
|
const sidebarApp = page.getByText('order-service').first()
|
||||||
|
await sidebarApp.click()
|
||||||
|
|
||||||
|
// URL should change to the app scope
|
||||||
|
await expect(page).toHaveURL(/\/apps\/order-service/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inspect button navigates to exchange detail', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Wait for table rows to appear
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
|
||||||
|
// Click the inspect button (↗) on first row
|
||||||
|
const inspectBtn = firstRow.locator('button[title="Inspect exchange"]')
|
||||||
|
await inspectBtn.click()
|
||||||
|
|
||||||
|
// Should navigate to exchange detail page
|
||||||
|
await expect(page).toHaveURL(/\/exchanges\//)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
e2e/exchanges.spec.ts
Normal file
60
e2e/exchanges.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Exchange Detail (/exchanges/:id)', () => {
|
||||||
|
test('renders exchange header, timeline, and message panels', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Exchange header — use the one NOT in the breadcrumb
|
||||||
|
await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible()
|
||||||
|
|
||||||
|
// Header stats
|
||||||
|
await expect(page.getByText('Duration').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Processors').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Processor Timeline section
|
||||||
|
await expect(page.getByText('Processor Timeline').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline/Flow toggle buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible()
|
||||||
|
|
||||||
|
// Message IN panel
|
||||||
|
await expect(page.getByText('Message IN')).toBeVisible()
|
||||||
|
await expect(page.getByText('Headers').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Body').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Timeline and Flow view', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Default view is timeline (gantt)
|
||||||
|
const timelineBtn = page.getByRole('button', { name: 'Timeline' })
|
||||||
|
const flowBtn = page.getByRole('button', { name: 'Flow' })
|
||||||
|
|
||||||
|
// Switch to Flow view
|
||||||
|
await flowBtn.click()
|
||||||
|
|
||||||
|
// Flow view should render (RouteFlow component)
|
||||||
|
await expect(flowBtn).toHaveClass(/active|Active/)
|
||||||
|
|
||||||
|
// Switch back to Timeline
|
||||||
|
await timelineBtn.click()
|
||||||
|
await expect(timelineBtn).toHaveClass(/active|Active/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('not-found exchange shows warning', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/nonexistent-id')
|
||||||
|
|
||||||
|
await expect(page.getByText('not found', { exact: false })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('breadcrumb navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Click Applications breadcrumb to go back
|
||||||
|
const appsBreadcrumb = page.getByRole('link', { name: 'Applications' })
|
||||||
|
await appsBreadcrumb.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/apps/)
|
||||||
|
})
|
||||||
|
})
|
||||||
63
e2e/routes.spec.ts
Normal file
63
e2e/routes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Routes (/routes)', () => {
|
||||||
|
test('renders KPI cards, route table, and charts', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// KPI cards
|
||||||
|
await expect(page.getByText('Total Throughput')).toBeVisible()
|
||||||
|
await expect(page.getByText('System Error Rate')).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency Percentiles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Active Routes')).toBeVisible()
|
||||||
|
await expect(page.getByText('In-Flight Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Route performance table
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible()
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Charts render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency (ms)')).toBeVisible()
|
||||||
|
await expect(page.getByText('Errors by Route')).toBeVisible()
|
||||||
|
await expect(page.getByText('Message Volume (msg/min)')).toBeVisible()
|
||||||
|
|
||||||
|
// Auto-refresh indicator
|
||||||
|
await expect(page.getByText('Auto-refresh: 30s')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a route row navigates to route detail', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// Click first route row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Should navigate to route detail
|
||||||
|
await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/)
|
||||||
|
|
||||||
|
// Route detail view: processor performance table
|
||||||
|
await expect(page.getByText('Processor Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible()
|
||||||
|
|
||||||
|
// Route Flow diagram
|
||||||
|
await expect(page.getByText('Route Flow')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped routes view filters data', async ({ page }) => {
|
||||||
|
await page.goto('/routes/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows scope
|
||||||
|
await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible()
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table still renders
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
111
package-lock.json
generated
111
package-lock.json
generated
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "cameleer3",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.0.0",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cameleer3",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.0.0",
|
"version": "0.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -24,6 +26,11 @@
|
|||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"vitest": "^3.0.0"
|
"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": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -925,6 +932,22 @@
|
|||||||
"resolve": "~1.22.2"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1895,9 +1918,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/language-core/node_modules/brace-expansion": {
|
"node_modules/@vue/language-core/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2076,9 +2099,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2497,9 +2520,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/happy-dom": {
|
"node_modules/happy-dom": {
|
||||||
"version": "20.8.4",
|
"version": "20.8.9",
|
||||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
|
||||||
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==",
|
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2692,6 +2715,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -2850,9 +2882,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2874,6 +2906,53 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.0",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.es.js",
|
"main": "./dist/index.es.js",
|
||||||
"module": "./dist/index.es.js",
|
"module": "./dist/index.es.js",
|
||||||
@@ -12,8 +12,12 @@
|
|||||||
},
|
},
|
||||||
"./style.css": "./dist/style.css"
|
"./style.css": "./dist/style.css"
|
||||||
},
|
},
|
||||||
"files": ["dist"],
|
"files": [
|
||||||
"sideEffects": ["*.css"],
|
"dist"
|
||||||
|
],
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css"
|
||||||
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
},
|
},
|
||||||
@@ -27,9 +31,11 @@
|
|||||||
"build:lib": "vite build --config vite.lib.config.ts",
|
"build:lib": "vite build --config vite.lib.config.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
@@ -40,6 +46,7 @@
|
|||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
|||||||
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
|
|||||||
|
|
||||||
it('renders danger variant icon', () => {
|
it('renders danger variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="danger" />)
|
render(<AlertDialog {...defaultProps} variant="danger" />)
|
||||||
// Icon area should be present (aria-hidden)
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders warning variant icon', () => {
|
it('renders warning variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="warning" />)
|
render(<AlertDialog {...defaultProps} variant="warning" />)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders info variant icon', () => {
|
it('renders info variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="info" />)
|
render(<AlertDialog {...defaultProps} variant="info" />)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import { XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||||
import { Modal } from '../Modal/Modal'
|
import { Modal } from '../Modal/Modal'
|
||||||
import { Button } from '../../primitives/Button/Button'
|
import { Button } from '../../primitives/Button/Button'
|
||||||
import styles from './AlertDialog.module.css'
|
import styles from './AlertDialog.module.css'
|
||||||
@@ -16,10 +17,10 @@ interface AlertDialogProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
|
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
|
||||||
danger: '✕',
|
danger: <XCircle size={20} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={20} />,
|
||||||
info: 'ℹ',
|
info: <Info size={20} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertDialog({
|
export function AlertDialog({
|
||||||
|
|||||||
@@ -277,6 +277,23 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Match context snippet */
|
||||||
|
.matchContext {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-top: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchContext em {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Match highlight */
|
/* Match highlight */
|
||||||
.mark {
|
.mark {
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CommandPalette.module.css'
|
import styles from './CommandPalette.module.css'
|
||||||
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
||||||
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
||||||
@@ -12,12 +13,17 @@ interface CommandPaletteProps {
|
|||||||
onSelect: (result: SearchResult) => void
|
onSelect: (result: SearchResult) => void
|
||||||
data: SearchResult[]
|
data: SearchResult[]
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
|
onQueryChange?: (query: string) => void
|
||||||
|
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
|
||||||
|
* Useful for applying the query as a full-text search filter. */
|
||||||
|
onSubmit?: (query: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
application: 'Applications',
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
|
attribute: 'Attributes',
|
||||||
route: 'Routes',
|
route: 'Routes',
|
||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
}
|
}
|
||||||
@@ -26,6 +32,7 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
|||||||
'all',
|
'all',
|
||||||
'application',
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
|
'attribute',
|
||||||
'route',
|
'route',
|
||||||
'agent',
|
'agent',
|
||||||
]
|
]
|
||||||
@@ -60,12 +67,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
|||||||
return <>{parts}</>
|
return <>{parts}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
|
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
||||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const userNavigated = useRef(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -88,25 +96,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
setQuery('')
|
setQuery('')
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
setExpandedId(null)
|
setExpandedId(null)
|
||||||
|
userNavigated.current = false
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Filter results
|
// Stage 1: apply text query + scope filters (used for counts)
|
||||||
const filtered = useMemo(() => {
|
const queryFiltered = useMemo(() => {
|
||||||
let results = data
|
let results = data
|
||||||
|
|
||||||
if (activeCategory !== 'all') {
|
|
||||||
results = results.filter((r) => r.category === activeCategory)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
results = results.filter(
|
results = results.filter(
|
||||||
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply scope filters
|
|
||||||
for (const sf of scopeFilters) {
|
for (const sf of scopeFilters) {
|
||||||
results = results.filter((r) =>
|
results = results.filter((r) =>
|
||||||
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
||||||
@@ -114,7 +118,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}, [data, query, activeCategory, scopeFilters])
|
}, [data, query, scopeFilters])
|
||||||
|
|
||||||
|
// Stage 2: apply category filter (used for display)
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (activeCategory === 'all') return queryFiltered
|
||||||
|
return queryFiltered.filter((r) => r.category === activeCategory)
|
||||||
|
}, [queryFiltered, activeCategory])
|
||||||
|
|
||||||
// Group results by category
|
// Group results by category
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
@@ -129,14 +139,14 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
// Flatten for keyboard nav
|
// Flatten for keyboard nav
|
||||||
const flatResults = useMemo(() => filtered, [filtered])
|
const flatResults = useMemo(() => filtered, [filtered])
|
||||||
|
|
||||||
// Counts per category
|
// Counts per category (from query-filtered, before category filter)
|
||||||
const categoryCounts = useMemo(() => {
|
const categoryCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = { all: data.length }
|
const counts: Record<string, number> = { all: queryFiltered.length }
|
||||||
for (const r of data) {
|
for (const r of queryFiltered) {
|
||||||
counts[r.category] = (counts[r.category] ?? 0) + 1
|
counts[r.category] = (counts[r.category] ?? 0) + 1
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}, [data])
|
}, [queryFiltered])
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@@ -145,15 +155,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
break
|
break
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.max(i - 1, 0))
|
setFocusedIdx((i) => Math.max(i - 1, 0))
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (flatResults[focusedIdx]) {
|
if (!userNavigated.current && onSubmit && query.trim()) {
|
||||||
|
onSubmit(query.trim())
|
||||||
|
onClose()
|
||||||
|
} else if (flatResults[focusedIdx]) {
|
||||||
onSelect(flatResults[focusedIdx])
|
onSelect(flatResults[focusedIdx])
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -185,7 +200,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
>
|
>
|
||||||
{/* Search input area */}
|
{/* Search input area */}
|
||||||
<div className={styles.searchArea}>
|
<div className={styles.searchArea}>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">⌕</span>
|
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
|
||||||
{scopeFilters.map((sf, i) => (
|
{scopeFilters.map((sf, i) => (
|
||||||
<span key={i} className={styles.scopeTag}>
|
<span key={i} className={styles.scopeTag}>
|
||||||
<span className={styles.scopeField}>{sf.field}:</span>
|
<span className={styles.scopeField}>{sf.field}:</span>
|
||||||
@@ -195,7 +210,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onClick={() => removeScopeFilter(i)}
|
onClick={() => removeScopeFilter(i)}
|
||||||
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
||||||
>
|
>
|
||||||
×
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -208,6 +223,8 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value)
|
setQuery(e.target.value)
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
|
userNavigated.current = false
|
||||||
|
onQueryChange?.(e.target.value)
|
||||||
}}
|
}}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
/>
|
/>
|
||||||
@@ -276,7 +293,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onSelect(result)
|
onSelect(result)
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setFocusedIdx(flatIdx)}
|
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
||||||
>
|
>
|
||||||
<div className={styles.itemMain}>
|
<div className={styles.itemMain}>
|
||||||
{result.icon && (
|
{result.icon && (
|
||||||
@@ -301,6 +318,12 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
<div className={styles.itemMeta}>
|
<div className={styles.itemMeta}>
|
||||||
{highlightText(result.meta, query)}
|
{highlightText(result.meta, query)}
|
||||||
</div>
|
</div>
|
||||||
|
{result.matchContext && (
|
||||||
|
<div
|
||||||
|
className={styles.matchContext}
|
||||||
|
dangerouslySetInnerHTML={{ __html: result.matchContext }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{result.expandedContent && (
|
{result.expandedContent && (
|
||||||
<button
|
<button
|
||||||
@@ -312,7 +335,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-label="Toggle detail"
|
aria-label="Toggle detail"
|
||||||
>
|
>
|
||||||
{isExpanded ? '▲' : '▼'}
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +364,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Enter" />
|
<KeyboardHint keys="Enter" />
|
||||||
<span>Open</span>
|
<span>Search</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Esc" />
|
<KeyboardHint keys="Esc" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent'
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -13,6 +13,10 @@ export interface SearchResult {
|
|||||||
path?: string
|
path?: string
|
||||||
expandedContent?: string
|
expandedContent?: string
|
||||||
matchRanges?: [number, number][]
|
matchRanges?: [number, number][]
|
||||||
|
/** Skip client-side query filtering (result already matched server-side) */
|
||||||
|
serverFiltered?: boolean
|
||||||
|
/** Server-side match snippet with <em> tags around matched text */
|
||||||
|
matchContext?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScopeFilter {
|
export interface ScopeFilter {
|
||||||
|
|||||||
@@ -12,6 +12,23 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fillHeight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -35,6 +52,9 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: color 0.12s;
|
transition: color 0.12s;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable {
|
.th.sortable {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function DataTable<T extends { id: string }>({
|
|||||||
rowAccent,
|
rowAccent,
|
||||||
expandedContent,
|
expandedContent,
|
||||||
flush = false,
|
flush = false,
|
||||||
|
fillHeight = false,
|
||||||
|
onSortChange,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||||
@@ -31,14 +33,16 @@ export function DataTable<T extends { id: string }>({
|
|||||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// When onSortChange is provided (controlled mode), skip client-side sorting
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
|
if (onSortChange) return data
|
||||||
if (!sortKey) return data
|
if (!sortKey) return data
|
||||||
return [...data].sort((a, b) => {
|
return [...data].sort((a, b) => {
|
||||||
const av = (a as Record<string, unknown>)[sortKey]
|
const av = (a as Record<string, unknown>)[sortKey]
|
||||||
const bv = (b as Record<string, unknown>)[sortKey]
|
const bv = (b as Record<string, unknown>)[sortKey]
|
||||||
return compareValues(av, bv, sortDir)
|
return compareValues(av, bv, sortDir)
|
||||||
})
|
})
|
||||||
}, [data, sortKey, sortDir])
|
}, [data, sortKey, sortDir, onSortChange])
|
||||||
|
|
||||||
const totalRows = sorted.length
|
const totalRows = sorted.length
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
||||||
@@ -52,13 +56,17 @@ export function DataTable<T extends { id: string }>({
|
|||||||
|
|
||||||
function handleHeaderClick(col: Column<T>) {
|
function handleHeaderClick(col: Column<T>) {
|
||||||
if (!sortable && !col.sortable) return
|
if (!sortable && !col.sortable) return
|
||||||
|
let newDir: SortDir
|
||||||
if (sortKey === col.key) {
|
if (sortKey === col.key) {
|
||||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
newDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||||
|
setSortDir(newDir)
|
||||||
} else {
|
} else {
|
||||||
|
newDir = 'asc'
|
||||||
setSortKey(col.key)
|
setSortKey(col.key)
|
||||||
setSortDir('asc')
|
setSortDir(newDir)
|
||||||
}
|
}
|
||||||
setPage(1)
|
setPage(1)
|
||||||
|
onSortChange?.(col.key, newDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRowClick(row: T) {
|
function handleRowClick(row: T) {
|
||||||
@@ -74,7 +82,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
|
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
|
||||||
<div className={styles.scroll}>
|
<div className={styles.scroll}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -20,4 +20,12 @@ export interface DataTableProps<T extends { id: string }> {
|
|||||||
expandedContent?: (row: T) => ReactNode | null
|
expandedContent?: (row: T) => ReactNode | null
|
||||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||||
flush?: boolean
|
flush?: boolean
|
||||||
|
/** Make the table fill remaining vertical space in a flex parent.
|
||||||
|
* The table body scrolls while the header stays sticky and the
|
||||||
|
* pagination footer stays pinned at the bottom. */
|
||||||
|
fillHeight?: 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,21 @@
|
|||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 99;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100vh;
|
||||||
width: 0;
|
width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: width 0.25s ease, opacity 0.2s ease;
|
transition: width 0.25s ease, opacity 0.2s ease;
|
||||||
@@ -7,13 +24,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
flex-shrink: 0;
|
z-index: 100;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel.open {
|
.panel.open {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-left-color: var(--border);
|
border-left-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
animation: slideInRight 0.25s ease-out both;
|
animation: slideInRight 0.25s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import styles from './DetailPanel.module.css'
|
import styles from './DetailPanel.module.css'
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
@@ -22,47 +23,54 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
|||||||
|
|
||||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<aside
|
<>
|
||||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
|
||||||
aria-hidden={!open}
|
<aside
|
||||||
>
|
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||||
<div className={styles.header}>
|
aria-hidden={!open}
|
||||||
<span className={styles.title}>{title}</span>
|
>
|
||||||
<button
|
<div className={styles.header}>
|
||||||
className={styles.closeBtn}
|
<span className={styles.title}>{title}</span>
|
||||||
onClick={onClose}
|
<button
|
||||||
aria-label="Close panel"
|
className={styles.closeBtn}
|
||||||
type="button"
|
onClick={onClose}
|
||||||
>
|
aria-label="Close panel"
|
||||||
×
|
type="button"
|
||||||
</button>
|
>
|
||||||
</div>
|
×
|
||||||
|
</button>
|
||||||
{tabs && tabs.length > 0 && (
|
|
||||||
<div className={styles.tabs}>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.value}
|
|
||||||
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab.value)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.body}>
|
{tabs && tabs.length > 0 && (
|
||||||
{children ?? activeContent}
|
<div className={styles.tabs}>
|
||||||
</div>
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{actions && (
|
<div className={styles.body}>
|
||||||
<div className={styles.actions}>
|
{children ?? activeContent}
|
||||||
{actions}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
{actions && (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Portal to AppShell level if target exists, otherwise render in place
|
||||||
|
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||||
|
return portalTarget ? createPortal(content, portalTarget) : content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
|
||||||
import styles from './EventFeed.module.css'
|
import styles from './EventFeed.module.css'
|
||||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
|
||||||
error: '\u2715', // ✕
|
error: <XIcon size={14} />,
|
||||||
warning: '\u26A0', // ⚠
|
warning: <AlertTriangle size={14} />,
|
||||||
success: '\u25B6', // ▶
|
success: <Play size={14} />,
|
||||||
running: '\u2699', // ⚙
|
running: <Loader size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
||||||
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
||||||
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to top (newest entries are at top in desc sort)
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPaused) {
|
if (!isPaused) {
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}
|
}
|
||||||
}, [events, isPaused, scrollToBottom])
|
}, [events, isPaused, scrollToTop])
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
|
const atTop = el.scrollTop < 8
|
||||||
setIsPaused(!atBottom)
|
setIsPaused(!atTop)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFilter(severity: SeverityFilter) {
|
function toggleFilter(severity: SeverityFilter) {
|
||||||
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<XIcon size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
className={styles.resumeBtn}
|
className={styles.resumeBtn}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPaused(false)
|
setIsPaused(false)
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↓ Resume auto-scroll
|
↑ Scroll to latest
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ChangeEvent } from 'react'
|
import { useState, type ChangeEvent } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './FilterBar.module.css'
|
import styles from './FilterBar.module.css'
|
||||||
import { Input } from '../../primitives/Input/Input'
|
import { Input } from '../../primitives/Input/Input'
|
||||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||||
@@ -77,12 +78,7 @@ export function FilterBar({
|
|||||||
if (onSearchChange) onSearchChange('')
|
if (onSearchChange) onSearchChange('')
|
||||||
else setInternalSearch('')
|
else setInternalSearch('')
|
||||||
} : undefined}
|
} : undefined}
|
||||||
icon={
|
icon={<Search size={13} />}
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
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, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface KpiItem {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
trend?: { label: ReactNode; 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/design-system/composites/LogViewer/LogViewer.module.css
Normal file
78
src/design-system/composites/LogViewer/LogViewer.module.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
.container {
|
||||||
|
overflow-y: auto;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelTrace {
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: color-mix(in srgb, var(--text-faint) 8%, 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);
|
||||||
|
}
|
||||||
59
src/design-system/composites/LogViewer/LogViewer.test.tsx
Normal file
59
src/design-system/composites/LogViewer/LogViewer.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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()
|
||||||
|
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('TRACE')).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()
|
||||||
|
})
|
||||||
|
})
|
||||||
78
src/design-system/composites/LogViewer/LogViewer.tsx
Normal file
78
src/design-system/composites/LogViewer/LogViewer.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
|
import styles from './LogViewer.module.css'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
|
||||||
|
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,
|
||||||
|
trace: styles.levelTrace,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isAtTopRef = useRef(true)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
isAtTopRef.current = el.scrollTop < 20
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el && isAtTopRef.current) {
|
||||||
|
el.scrollTop = 0
|
||||||
|
}
|
||||||
|
}, [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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -96,6 +96,58 @@
|
|||||||
padding: 2px 0 2px 4px;
|
padding: 2px 0 2px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Action trigger — hidden by default, shown on hover/selected */
|
||||||
|
.actionsTrigger {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .actionsTrigger,
|
||||||
|
.actionsVisible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.1s;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeInfo { background: var(--running); }
|
||||||
|
.badgeSuccess { background: var(--success); }
|
||||||
|
.badgeWarning { background: var(--amber); }
|
||||||
|
.badgeError { background: var(--error); }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { ProcessorTimeline } from './ProcessorTimeline'
|
||||||
|
|
||||||
|
const processors = [
|
||||||
|
{ name: 'Validate', type: 'validator', durationMs: 12, status: 'ok' as const, startMs: 0 },
|
||||||
|
{ name: 'Enrich', type: 'enricher', durationMs: 35, status: 'slow' as const, startMs: 12 },
|
||||||
|
{ name: 'Route', type: 'router', durationMs: 8, status: 'fail' as const, startMs: 47 },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ProcessorTimeline', () => {
|
||||||
|
it('renders processor names', () => {
|
||||||
|
render(<ProcessorTimeline processors={processors} totalMs={55} />)
|
||||||
|
expect(screen.getByText('Validate')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Enrich')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Route')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render action trigger when no actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline processors={processors} totalMs={55} />,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders action trigger when actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking action trigger does not fire onProcessorClick', async () => {
|
||||||
|
const onProcessorClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
onProcessorClick={onProcessorClick}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
expect(onProcessorClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls action onClick when menu item clicked', async () => {
|
||||||
|
const actionClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
await user.click(screen.getByText('Change Log Level'))
|
||||||
|
expect(actionClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports dynamic getActions per processor', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
getActions={(proc) =>
|
||||||
|
proc.status === 'fail'
|
||||||
|
? [{ label: 'View Error', onClick: () => {} }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Only the failing processor should have an action trigger
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(1)
|
||||||
|
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for Route')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './ProcessorTimeline.module.css'
|
import styles from './ProcessorTimeline.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
||||||
|
|
||||||
export interface ProcessorStep {
|
export interface ProcessorStep {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,6 +10,15 @@ export interface ProcessorStep {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
startMs: number
|
startMs: number
|
||||||
|
badges?: NodeBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessorAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessorTimelineProps {
|
interface ProcessorTimelineProps {
|
||||||
@@ -13,6 +26,8 @@ interface ProcessorTimelineProps {
|
|||||||
totalMs: number
|
totalMs: number
|
||||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: ProcessorAction[]
|
||||||
|
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +41,8 @@ export function ProcessorTimeline({
|
|||||||
totalMs,
|
totalMs,
|
||||||
onProcessorClick,
|
onProcessorClick,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
actions,
|
||||||
|
getActions,
|
||||||
className,
|
className,
|
||||||
}: ProcessorTimelineProps) {
|
}: ProcessorTimelineProps) {
|
||||||
const safeTotal = totalMs || 1
|
const safeTotal = totalMs || 1
|
||||||
@@ -70,6 +87,16 @@ export function ProcessorTimeline({
|
|||||||
>
|
>
|
||||||
<div className={styles.name} title={proc.name}>
|
<div className={styles.name} title={proc.name}>
|
||||||
{proc.name}
|
{proc.name}
|
||||||
|
{proc.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.barBg}>
|
<div className={styles.barBg}>
|
||||||
<div
|
<div
|
||||||
@@ -82,6 +109,30 @@ export function ProcessorTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
||||||
|
{(() => {
|
||||||
|
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
|
||||||
|
if (resolvedActions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${proc.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -188,17 +188,100 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottleneck badge */
|
/* Action trigger — hidden by default, shown on hover/selected */
|
||||||
.bottleneckBadge {
|
.actionsTrigger {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node:hover .actionsTrigger,
|
||||||
|
.actionsVisible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.1s;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badgeRow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -7px;
|
top: -7px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--error);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badgeInfo { background: var(--running); }
|
||||||
|
.badgeSuccess { background: var(--success); }
|
||||||
|
.badgeWarning { background: var(--amber); }
|
||||||
|
.badgeError { background: var(--error); }
|
||||||
|
|
||||||
|
/* Node wrapper (replaces inline style) */
|
||||||
|
.nodeWrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-flow sections */
|
||||||
|
.flowSection {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowSectionSeparated {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 2px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLabelDefault { color: var(--text-muted); }
|
||||||
|
.flowLabelError { color: var(--error); }
|
||||||
|
.flowLabelWarning { color: var(--warning); }
|
||||||
|
.flowLabelInfo { color: var(--running); }
|
||||||
|
|||||||
160
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
160
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { RouteFlow } from './RouteFlow'
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
{ name: 'jms:orders', type: 'from' as const, durationMs: 4, status: 'ok' as const },
|
||||||
|
{ name: 'OrderValidator', type: 'process' as const, durationMs: 8, status: 'ok' as const },
|
||||||
|
{ name: 'http:payment-api', type: 'to' as const, durationMs: 187, status: 'slow' as const },
|
||||||
|
{ name: 'dead-letter:failed', type: 'error-handler' as const, durationMs: 14, status: 'fail' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('RouteFlow', () => {
|
||||||
|
it('renders node names', () => {
|
||||||
|
render(<RouteFlow nodes={nodes} />)
|
||||||
|
expect(screen.getByText('jms:orders')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('OrderValidator')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('http:payment-api')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('dead-letter:failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render action trigger when no actions provided', () => {
|
||||||
|
const { container } = render(<RouteFlow nodes={nodes} />)
|
||||||
|
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders action trigger on all nodes including error handlers when actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
actions={[{ label: 'View Config', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(4) // 3 main + 1 error handler
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking action trigger does not fire onNodeClick', async () => {
|
||||||
|
const onNodeClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
expect(onNodeClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls action onClick when menu item clicked', async () => {
|
||||||
|
const actionClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
await user.click(screen.getByText('Change Log Level'))
|
||||||
|
expect(actionClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports dynamic getActions per node', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
getActions={(node) =>
|
||||||
|
node.type === 'process'
|
||||||
|
? [{ label: 'Edit Processor', onClick: () => {} }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(1)
|
||||||
|
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const multiFlows = [
|
||||||
|
{
|
||||||
|
label: 'Main Route',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'timer:tick', type: 'from' as const, durationMs: 0, status: 'ok' as const },
|
||||||
|
{ name: 'Processor1', type: 'process' as const, durationMs: 8, status: 'ok' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'onException',
|
||||||
|
variant: 'error' as const,
|
||||||
|
nodes: [
|
||||||
|
{ name: 'LogHandler', type: 'process' as const, durationMs: 3, status: 'ok' as const },
|
||||||
|
{ name: 'dead-letter:errors', type: 'to' as const, durationMs: 8, status: 'fail' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('RouteFlow (multi-flow)', () => {
|
||||||
|
it('renders all segment labels', () => {
|
||||||
|
render(<RouteFlow flows={multiFlows} />)
|
||||||
|
expect(screen.getByText('Main Route')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('onException')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all nodes across segments', () => {
|
||||||
|
render(<RouteFlow flows={multiFlows} />)
|
||||||
|
expect(screen.getByText('timer:tick')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Processor1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('LogHandler')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('dead-letter:errors')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses global flat indexing for onNodeClick', async () => {
|
||||||
|
const onNodeClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<RouteFlow flows={multiFlows} onNodeClick={onNodeClick} />)
|
||||||
|
// Click the first node of the second flow (global index = 2)
|
||||||
|
await user.click(screen.getByText('LogHandler'))
|
||||||
|
expect(onNodeClick).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'LogHandler' }),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectedIndex highlights correct node across flows', () => {
|
||||||
|
const { container } = render(<RouteFlow flows={multiFlows} selectedIndex={3} />)
|
||||||
|
// Index 3 = dead-letter:errors (2nd node of 2nd flow)
|
||||||
|
const selectedNodes = container.querySelectorAll('[class*="nodeSelected"]')
|
||||||
|
expect(selectedNodes.length).toBe(1)
|
||||||
|
expect(selectedNodes[0]).toHaveTextContent('dead-letter:errors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('actions work in multi-flow mode', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
flows={multiFlows}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flows takes precedence over nodes', () => {
|
||||||
|
render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
flows={multiFlows}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Should render flow content, not nodes content
|
||||||
|
expect(screen.getByText('Main Route')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('jms:orders')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './RouteFlow.module.css'
|
import styles from './RouteFlow.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
|
||||||
|
export interface NodeBadge {
|
||||||
|
label: string
|
||||||
|
variant?: 'info' | 'success' | 'warning' | 'error'
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface RouteNode {
|
export interface RouteNode {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,12 +15,30 @@ export interface RouteNode {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
isBottleneck?: boolean
|
isBottleneck?: boolean
|
||||||
|
badges?: NodeBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowSegment {
|
||||||
|
label: string
|
||||||
|
nodes: RouteNode[]
|
||||||
|
variant?: 'default' | 'error' | 'warning' | 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteFlowProps {
|
interface RouteFlowProps {
|
||||||
nodes: RouteNode[]
|
nodes?: RouteNode[]
|
||||||
|
flows?: FlowSegment[]
|
||||||
onNodeClick?: (node: RouteNode, index: number) => void
|
onNodeClick?: (node: RouteNode, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: NodeAction[]
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +56,12 @@ function durationClass(ms: number, status: string): string {
|
|||||||
return styles.durBreach
|
return styles.durBreach
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||||
'from': '\u25B6',
|
'from': <Play size={14} />,
|
||||||
'process': '\u2699',
|
'process': <Cog size={14} />,
|
||||||
'to': '\u25A2',
|
'to': <Square size={14} />,
|
||||||
'choice': '\u25C6',
|
'choice': <Diamond size={14} />,
|
||||||
'error-handler': '\u26A0',
|
'error-handler': <AlertTriangle size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_CLASSES: Record<string, string> = {
|
const ICON_CLASSES: Record<string, string> = {
|
||||||
@@ -52,12 +79,141 @@ function nodeStatusClass(node: RouteNode): string {
|
|||||||
return styles.nodeHealthy
|
return styles.nodeHealthy
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
|
function renderActionTrigger(
|
||||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
node: RouteNode,
|
||||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
index: number,
|
||||||
|
isSelected: boolean,
|
||||||
|
actions?: NodeAction[],
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||||
|
) {
|
||||||
|
const resolvedActions = getActions ? getActions(node, index) : (actions ?? [])
|
||||||
|
if (resolvedActions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${node.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOW_LABEL_CLASSES: Record<string, string> = {
|
||||||
|
'default': styles.flowLabelDefault,
|
||||||
|
'error': styles.flowLabelError,
|
||||||
|
'warning': styles.flowLabelWarning,
|
||||||
|
'info': styles.flowLabelInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeChain(
|
||||||
|
nodes: RouteNode[],
|
||||||
|
globalIndexOffset: number,
|
||||||
|
onNodeClick?: RouteFlowProps['onNodeClick'],
|
||||||
|
selectedIndex?: number,
|
||||||
|
actions?: NodeAction[],
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||||
|
) {
|
||||||
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
|
return nodes.map((node, i) => {
|
||||||
|
const globalIndex = globalIndexOffset + i
|
||||||
|
const isSelected = selectedIndex === globalIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
|
{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, globalIndex)}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault()
|
||||||
|
onNodeClick?.(node, globalIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(node.isBottleneck || node.badges?.length) ? (
|
||||||
|
<span className={styles.badgeRow}>
|
||||||
|
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||||
|
{node.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
|
</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>
|
||||||
|
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
||||||
|
// Multi-flow mode
|
||||||
|
if (flows && flows.length > 0) {
|
||||||
|
let globalOffset = 0
|
||||||
|
return (
|
||||||
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
|
{flows.map((flow, fi) => {
|
||||||
|
const sectionOffset = globalOffset
|
||||||
|
globalOffset += flow.nodes.length
|
||||||
|
const variant = flow.variant ?? 'default'
|
||||||
|
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
|
||||||
|
return (
|
||||||
|
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
|
||||||
|
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
|
||||||
|
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy mode (single nodes array with automatic error-handler separation)
|
||||||
|
const allNodes = nodes ?? []
|
||||||
|
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
|
||||||
|
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
|
||||||
|
|
||||||
// Map from mainNodes index back to original nodes index
|
// Map from mainNodes index back to original nodes index
|
||||||
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
|
const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
|
||||||
if (n.type !== 'error-handler') acc.push(idx)
|
if (n.type !== 'error-handler') acc.push(idx)
|
||||||
return acc
|
return acc
|
||||||
}, [])
|
}, [])
|
||||||
@@ -70,7 +226,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
const isClickable = !!onNodeClick
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
{i > 0 && (
|
{i > 0 && (
|
||||||
<div className={styles.connector}>
|
<div className={styles.connector}>
|
||||||
<div className={styles.connectorLine} />
|
<div className={styles.connectorLine} />
|
||||||
@@ -89,9 +245,23 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
|
{(node.isBottleneck || node.badges?.length) ? (
|
||||||
|
<span className={styles.badgeRow}>
|
||||||
|
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||||
|
{node.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<div className={styles.type}>{node.type}</div>
|
<div className={styles.type}>{node.type}</div>
|
||||||
@@ -102,6 +272,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{formatDuration(node.durationMs)}
|
{formatDuration(node.durationMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -110,22 +281,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{errorHandlers.length > 0 && (
|
{errorHandlers.length > 0 && (
|
||||||
<div className={styles.errorSection}>
|
<div className={styles.errorSection}>
|
||||||
<div className={styles.errorLabel}>Error Handler</div>
|
<div className={styles.errorLabel}>Error Handler</div>
|
||||||
{errorHandlers.map((node, i) => (
|
{errorHandlers.map((node, i) => {
|
||||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
const errOriginalIndex = allNodes.indexOf(node)
|
||||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
return (
|
||||||
{TYPE_ICONS['error-handler']}
|
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||||
</div>
|
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||||
<div className={styles.info}>
|
{TYPE_ICONS['error-handler']}
|
||||||
<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 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>
|
||||||
|
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
||||||
|
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for success variant', () => {
|
it('shows correct icon for success variant', () => {
|
||||||
@@ -97,7 +97,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
||||||
|
|
||||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for warning variant', () => {
|
it('shows correct icon for warning variant', () => {
|
||||||
@@ -105,7 +105,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
||||||
|
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for error variant', () => {
|
it('shows correct icon for error variant', () => {
|
||||||
@@ -113,7 +113,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
||||||
|
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dismisses toast when close button is clicked', () => {
|
it('dismisses toast when close button is clicked', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Toast.module.css'
|
import styles from './Toast.module.css'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
|
|||||||
const DEFAULT_DURATION = 5000
|
const DEFAULT_DURATION = 5000
|
||||||
const EXIT_ANIMATION_MS = 300
|
const EXIT_ANIMATION_MS = 300
|
||||||
|
|
||||||
const ICONS: Record<ToastVariant, string> = {
|
const ICONS: Record<ToastVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context ────────────────────────────────────────────────────────────────
|
// ── Context ────────────────────────────────────────────────────────────────
|
||||||
@@ -183,7 +184,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
|
|||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,23 +11,33 @@ export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog'
|
|||||||
export { DataTable } from './DataTable/DataTable'
|
export { DataTable } from './DataTable/DataTable'
|
||||||
export type { Column, DataTableProps } from './DataTable/types'
|
export type { Column, DataTableProps } from './DataTable/types'
|
||||||
export { DetailPanel } from './DetailPanel/DetailPanel'
|
export { DetailPanel } from './DetailPanel/DetailPanel'
|
||||||
|
export { EntityList } from './EntityList/EntityList'
|
||||||
export { Dropdown } from './Dropdown/Dropdown'
|
export { Dropdown } from './Dropdown/Dropdown'
|
||||||
export { EventFeed } from './EventFeed/EventFeed'
|
export { EventFeed } from './EventFeed/EventFeed'
|
||||||
export { GroupCard } from './GroupCard/GroupCard'
|
export { GroupCard } from './GroupCard/GroupCard'
|
||||||
|
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||||
|
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||||
export type { FeedEvent } from './EventFeed/EventFeed'
|
export type { FeedEvent } from './EventFeed/EventFeed'
|
||||||
export { FilterBar } from './FilterBar/FilterBar'
|
export { FilterBar } from './FilterBar/FilterBar'
|
||||||
export { LineChart } from './LineChart/LineChart'
|
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 { MenuItem } from './MenuItem/MenuItem'
|
||||||
export { Modal } from './Modal/Modal'
|
export { Modal } from './Modal/Modal'
|
||||||
export { MultiSelect } from './MultiSelect/MultiSelect'
|
export { MultiSelect } from './MultiSelect/MultiSelect'
|
||||||
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||||
export { Popover } from './Popover/Popover'
|
export { Popover } from './Popover/Popover'
|
||||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
export type { RouteNode, NodeAction, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
|
||||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||||
|
export { SplitPane } from './SplitPane/SplitPane'
|
||||||
export { Tabs } from './Tabs/Tabs'
|
export { Tabs } from './Tabs/Tabs'
|
||||||
export { ToastProvider, useToast } from './Toast/Toast'
|
export { ToastProvider, useToast } from './Toast/Toast'
|
||||||
export { TreeView } from './TreeView/TreeView'
|
export { TreeView } from './TreeView/TreeView'
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ export * from './layout'
|
|||||||
export * from './providers/ThemeProvider'
|
export * from './providers/ThemeProvider'
|
||||||
export * from './providers/CommandPaletteProvider'
|
export * from './providers/CommandPaletteProvider'
|
||||||
export * from './providers/GlobalFilterProvider'
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider'
|
||||||
|
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
|
||||||
export * from './utils/hashColor'
|
export * from './utils/hashColor'
|
||||||
export * from './utils/timePresets'
|
export * from './utils/timePresets'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
|
|||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
sidebar: ReactNode
|
sidebar: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
|
||||||
detail?: ReactNode
|
detail?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ sidebar, children, detail }: AppShellProps) {
|
export function AppShell({ sidebar, children }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{detail}
|
<div id="cameleer-detail-panel-root" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||||
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
||||||
@@ -33,6 +34,7 @@ export interface SidebarAgent {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
apps: SidebarApp[]
|
apps: SidebarApp[]
|
||||||
className?: string
|
className?: string
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -55,7 +57,7 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|||||||
id: `route:${app.id}:${route.id}`,
|
id: `route:${app.id}:${route.id}`,
|
||||||
starKey: `${app.id}:${route.id}`,
|
starKey: `${app.id}:${route.id}`,
|
||||||
label: route.name,
|
label: route.name,
|
||||||
icon: <span className={styles.routeArrow}>▸</span>,
|
icon: <ChevronRight size={12} />,
|
||||||
badge: formatCount(route.exchangeCount),
|
badge: formatCount(route.exchangeCount),
|
||||||
path: `/apps/${app.id}/${route.id}`,
|
path: `/apps/${app.id}/${route.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
@@ -78,7 +80,7 @@ function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|||||||
id: `routestat:${app.id}:${route.id}`,
|
id: `routestat:${app.id}:${route.id}`,
|
||||||
starKey: `routes:${app.id}:${route.id}`,
|
starKey: `routes:${app.id}:${route.id}`,
|
||||||
label: route.name,
|
label: route.name,
|
||||||
icon: <span className={styles.routeArrow}>▸</span>,
|
icon: <ChevronRight size={12} />,
|
||||||
badge: formatCount(route.exchangeCount),
|
badge: formatCount(route.exchangeCount),
|
||||||
path: `/routes/${app.id}/${route.id}`,
|
path: `/routes/${app.id}/${route.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
@@ -235,10 +237,7 @@ function StarredGroup({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={`Remove ${item.label} from starred`}
|
aria-label={`Remove ${item.label} from starred`}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<X size={12} />
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -248,7 +247,7 @@ function StarredGroup({
|
|||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Sidebar({ apps, className }: SidebarProps) {
|
export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||||
@@ -277,7 +276,8 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const nav = onNavigate ?? routerNavigate
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
|
<div className={styles.logo} onClick={() => nav('/apps')} style={{ cursor: 'pointer' }}>
|
||||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
||||||
<div>
|
<div>
|
||||||
<span className={styles.brand}>cameleer</span>
|
<span className={styles.brand}>cameleer</span>
|
||||||
@@ -344,10 +344,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<div className={styles.searchWrap}>
|
<div className={styles.searchWrap}>
|
||||||
<div className={styles.searchInner}>
|
<div className={styles.searchInner}>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<Search size={12} />
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className={styles.searchInput}
|
className={styles.searchInput}
|
||||||
@@ -363,7 +360,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,14 +379,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!appsCollapsed}
|
aria-expanded={!appsCollapsed}
|
||||||
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
||||||
>
|
>
|
||||||
{appsCollapsed ? '▸' : '▾'}
|
{appsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/apps')}
|
onClick={() => nav('/apps')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
|
||||||
>
|
>
|
||||||
Applications
|
Applications
|
||||||
</span>
|
</span>
|
||||||
@@ -403,6 +400,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:apps"
|
persistKey="cameleer:expanded:apps"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -416,14 +414,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!agentsCollapsed}
|
aria-expanded={!agentsCollapsed}
|
||||||
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
||||||
>
|
>
|
||||||
{agentsCollapsed ? '▸' : '▾'}
|
{agentsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/agents')}
|
onClick={() => nav('/agents')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
|
||||||
>
|
>
|
||||||
Agents
|
Agents
|
||||||
</span>
|
</span>
|
||||||
@@ -437,6 +435,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:agents"
|
persistKey="cameleer:expanded:agents"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -450,14 +449,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!routesCollapsed}
|
aria-expanded={!routesCollapsed}
|
||||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||||
>
|
>
|
||||||
{routesCollapsed ? '▸' : '▾'}
|
{routesCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/routes')}
|
onClick={() => nav('/routes')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
|
||||||
>
|
>
|
||||||
Routes
|
Routes
|
||||||
</span>
|
</span>
|
||||||
@@ -471,6 +470,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:routes"
|
persistKey="cameleer:expanded:routes"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -491,7 +491,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Applications"
|
label="Applications"
|
||||||
items={starredApps}
|
items={starredApps}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -499,7 +499,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Routes"
|
label="Routes"
|
||||||
items={starredRoutes}
|
items={starredRoutes}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -507,7 +507,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Agents"
|
label="Agents"
|
||||||
items={starredAgents}
|
items={starredAgents}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -515,7 +515,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Routes"
|
label="Routes"
|
||||||
items={starredRouteStats}
|
items={starredRouteStats}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -531,12 +531,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => nav('/admin')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/admin') }}
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}>⚙</span>
|
<span className={styles.bottomIcon}><Settings size={14} /></span>
|
||||||
<div className={styles.itemInfo}>
|
<div className={styles.itemInfo}>
|
||||||
<div className={styles.itemName}>Admin</div>
|
<div className={styles.itemName}>Admin</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -546,12 +546,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/api-docs')}
|
onClick={() => nav('/api-docs')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/api-docs') }}
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}>☰</span>
|
<span className={styles.bottomIcon}><FileText size={14} /></span>
|
||||||
<div className={styles.itemInfo}>
|
<div className={styles.itemInfo}>
|
||||||
<div className={styles.itemName}>API Docs</div>
|
<div className={styles.itemName}>API Docs</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -33,24 +34,17 @@ export interface SidebarTreeProps {
|
|||||||
filterQuery?: string
|
filterQuery?: string
|
||||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||||
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
// ── Star icons ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StarOutline() {
|
function StarOutline() {
|
||||||
return (
|
return <Star size={14} />
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarFilled() {
|
function StarFilled() {
|
||||||
return (
|
return <Star size={14} fill="currentColor" />
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistent expand state ──────────────────────────────────────────────────
|
// ── Persistent expand state ──────────────────────────────────────────────────
|
||||||
@@ -141,8 +135,10 @@ export function SidebarTree({
|
|||||||
filterQuery,
|
filterQuery,
|
||||||
persistKey,
|
persistKey,
|
||||||
autoRevealPath,
|
autoRevealPath,
|
||||||
|
onNavigate,
|
||||||
}: SidebarTreeProps) {
|
}: SidebarTreeProps) {
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const navigate = onNavigate ?? routerNavigate
|
||||||
|
|
||||||
// Expand/collapse state — optionally persisted to sessionStorage
|
// Expand/collapse state — optionally persisted to sessionStorage
|
||||||
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
||||||
@@ -395,7 +391,7 @@ function SidebarTreeRow({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
>
|
>
|
||||||
{isExpanded ? '▾' : '▸'}
|
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -81,6 +81,56 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.themeToggle {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Search, Moon, Sun, Power } from 'lucide-react'
|
||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||||
@@ -8,11 +9,8 @@ import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeD
|
|||||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
||||||
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||||
import { useTheme } from '../../providers/ThemeProvider'
|
import { useTheme } from '../../providers/ThemeProvider'
|
||||||
|
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||||
interface BreadcrumbItem {
|
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||||
label: string
|
|
||||||
href?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
@@ -39,11 +37,12 @@ export function TopBar({
|
|||||||
const globalFilters = useGlobalFilters()
|
const globalFilters = useGlobalFilters()
|
||||||
const commandPalette = useCommandPalette()
|
const commandPalette = useCommandPalette()
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const breadcrumbOverride = useBreadcrumbOverride()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||||
|
|
||||||
{/* Search trigger */}
|
{/* Search trigger */}
|
||||||
<button
|
<button
|
||||||
@@ -53,10 +52,7 @@ export function TopBar({
|
|||||||
aria-label="Open search"
|
aria-label="Open search"
|
||||||
>
|
>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<Search size={13} />
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||||
<span className={styles.kbd}>Ctrl+K</span>
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
@@ -84,8 +80,18 @@ export function TopBar({
|
|||||||
onChange={globalFilters.setTimeRange}
|
onChange={globalFilters.setTimeRange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right: theme toggle, env badge, user */}
|
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
|
||||||
<div className={styles.right}>
|
<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
|
<button
|
||||||
className={styles.themeToggle}
|
className={styles.themeToggle}
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
@@ -93,7 +99,7 @@ export function TopBar({
|
|||||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
>
|
>
|
||||||
{theme === 'light' ? '\u263E' : '\u2600'}
|
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
||||||
</button>
|
</button>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<span className={styles.env}>{environment}</span>
|
||||||
@@ -107,7 +113,7 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
|
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,24 +46,23 @@ describe('Alert', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows default icon for each variant', () => {
|
it('shows default icon for each variant', () => {
|
||||||
const { rerender } = render(<Alert variant="info">msg</Alert>)
|
const { container, rerender } = render(<Alert variant="info">msg</Alert>)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
// Each variant should render an SVG icon in the icon slot
|
||||||
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="success">msg</Alert>)
|
rerender(<Alert variant="success">msg</Alert>)
|
||||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="warning">msg</Alert>)
|
rerender(<Alert variant="warning">msg</Alert>)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="error">msg</Alert>)
|
rerender(<Alert variant="error">msg</Alert>)
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a custom icon when provided', () => {
|
it('renders a custom icon when provided', () => {
|
||||||
render(<Alert icon={<span>★</span>}>Custom icon alert</Alert>)
|
render(<Alert icon={<span data-testid="custom-icon">★</span>}>Custom icon alert</Alert>)
|
||||||
expect(screen.getByText('★')).toBeInTheDocument()
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||||
// Default icon should not appear
|
|
||||||
expect(screen.queryByText('ℹ')).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show dismiss button when dismissible is false', () => {
|
it('does not show dismiss button when dismissible is false', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Alert.module.css'
|
import styles from './Alert.module.css'
|
||||||
|
|
||||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||||
@@ -13,11 +14,11 @@ interface AlertProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<AlertVariant, string> = {
|
const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
||||||
@@ -61,7 +62,7 @@ export function Alert({
|
|||||||
aria-label="Dismiss alert"
|
aria-label="Dismiss alert"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,3 +11,22 @@
|
|||||||
.accent-warning { border-top: 3px solid var(--warning); }
|
.accent-warning { border-top: 3px solid var(--warning); }
|
||||||
.accent-error { border-top: 3px solid var(--error); }
|
.accent-error { border-top: 3px solid var(--error); }
|
||||||
.accent-running { border-top: 3px solid var(--running); }
|
.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 {
|
interface CardProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
|
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
|
||||||
|
title?: string
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, accent = 'none', className }: CardProps) {
|
export function Card({ children, accent = 'none', title, className }: CardProps) {
|
||||||
const classes = [
|
const classes = [
|
||||||
styles.card,
|
styles.card,
|
||||||
accent !== 'none' ? styles[`accent-${accent}`] : '',
|
accent !== 'none' ? styles[`accent-${accent}`] : '',
|
||||||
className ?? '',
|
className ?? '',
|
||||||
].filter(Boolean).join(' ')
|
].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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import styles from './Input.module.css'
|
import styles from './Input.module.css'
|
||||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export { Sparkline } from './Sparkline/Sparkline'
|
|||||||
export { Spinner } from './Spinner/Spinner'
|
export { Spinner } from './Spinner/Spinner'
|
||||||
export { StatCard } from './StatCard/StatCard'
|
export { StatCard } from './StatCard/StatCard'
|
||||||
export { StatusDot } from './StatusDot/StatusDot'
|
export { StatusDot } from './StatusDot/StatusDot'
|
||||||
|
export { StatusText } from './StatusText/StatusText'
|
||||||
export { Tag } from './Tag/Tag'
|
export { Tag } from './Tag/Tag'
|
||||||
export { Textarea } from './Textarea/Textarea'
|
export { Textarea } from './Textarea/Textarea'
|
||||||
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
|
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
|
||||||
|
|||||||
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbContextValue {
|
||||||
|
override: BreadcrumbItem[] | null
|
||||||
|
setOverride: (items: BreadcrumbItem[] | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BreadcrumbContext = createContext<BreadcrumbContextValue>({
|
||||||
|
override: null,
|
||||||
|
setOverride: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [override, setOverride] = useState<BreadcrumbItem[] | null>(null)
|
||||||
|
return (
|
||||||
|
<BreadcrumbContext.Provider value={{ override, setOverride }}>
|
||||||
|
{children}
|
||||||
|
</BreadcrumbContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the TopBar breadcrumb with page-specific semantic items.
|
||||||
|
* Pass `null` to clear (or let unmount handle it).
|
||||||
|
* Callers should `useMemo` the items array to avoid unnecessary re-renders.
|
||||||
|
*/
|
||||||
|
export function useBreadcrumb(items: BreadcrumbItem[] | null) {
|
||||||
|
const { setOverride } = useContext(BreadcrumbContext)
|
||||||
|
useEffect(() => {
|
||||||
|
setOverride(items)
|
||||||
|
return () => setOverride(null)
|
||||||
|
}, [items, setOverride])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal — used by TopBar to read the current override. */
|
||||||
|
export function useBreadcrumbOverride(): BreadcrumbItem[] | null {
|
||||||
|
return useContext(BreadcrumbContext).override
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||||
import { computePresetRange } from '../utils/timePresets'
|
import { computePresetRange } from '../utils/timePresets'
|
||||||
|
|
||||||
export interface TimeRange {
|
export interface TimeRange {
|
||||||
@@ -16,6 +16,8 @@ interface GlobalFilterContextValue {
|
|||||||
toggleStatus: (status: ExchangeStatus) => void
|
toggleStatus: (status: ExchangeStatus) => void
|
||||||
clearStatusFilters: () => void
|
clearStatusFilters: () => void
|
||||||
isInTimeRange: (timestamp: Date) => boolean
|
isInTimeRange: (timestamp: Date) => boolean
|
||||||
|
autoRefresh: boolean
|
||||||
|
setAutoRefresh: (enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||||
@@ -27,9 +29,17 @@ function getDefaultTimeRange(): TimeRange {
|
|||||||
return { start, end, preset: DEFAULT_PRESET }
|
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 }) {
|
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||||
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
||||||
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
||||||
|
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
|
||||||
|
|
||||||
const setTimeRange = useCallback((range: TimeRange) => {
|
const setTimeRange = useCallback((range: TimeRange) => {
|
||||||
setTimeRangeState(range)
|
setTimeRangeState(range)
|
||||||
@@ -51,6 +61,21 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
setStatusFilters(new Set())
|
setStatusFilters(new Set())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setAutoRefresh = useCallback((enabled: boolean) => {
|
||||||
|
setAutoRefreshState(enabled)
|
||||||
|
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Keep the time range sliding forward when a preset is active and live
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh || !timeRange.preset) return
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const { start, end } = computePresetRange(timeRange.preset!)
|
||||||
|
setTimeRangeState({ start, end, preset: timeRange.preset })
|
||||||
|
}, 10_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [autoRefresh, timeRange.preset])
|
||||||
|
|
||||||
const isInTimeRange = useCallback(
|
const isInTimeRange = useCallback(
|
||||||
(timestamp: Date) => {
|
(timestamp: Date) => {
|
||||||
if (timeRange.preset) {
|
if (timeRange.preset) {
|
||||||
@@ -65,7 +90,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalFilterContext.Provider
|
<GlobalFilterContext.Provider
|
||||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
|
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</GlobalFilterContext.Provider>
|
</GlobalFilterContext.Provider>
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineE
|
|||||||
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
||||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
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 { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, getChildGroups, type MockGroup } from './rbacMocks'
|
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, type MockGroup } from './rbacMocks'
|
||||||
import styles from './UserManagement.module.css'
|
import styles from './UserManagement.module.css'
|
||||||
|
|
||||||
export function GroupsTab() {
|
export function GroupsTab() {
|
||||||
@@ -83,207 +85,190 @@ export function GroupsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.splitPane}>
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search groups..."
|
<div className={styles.createForm}>
|
||||||
value={search}
|
<Input placeholder="Group name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
{duplicateGroupName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Group name already exists</span>}
|
||||||
onClear={() => setSearch('')}
|
<Select
|
||||||
className={styles.listHeaderSearch}
|
options={parentOptions}
|
||||||
/>
|
value={newParent}
|
||||||
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
onChange={(e) => setNewParent(e.target.value)}
|
||||||
+ Add group
|
/>
|
||||||
</Button>
|
<div className={styles.createFormActions}>
|
||||||
</div>
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateGroupName}>Create</Button>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.entityList} role="listbox" aria-label="Groups">
|
|
||||||
{filtered.map((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 (
|
|
||||||
<div
|
|
||||||
key={group.id}
|
|
||||||
className={`${styles.entityItem} ${selectedId === group.id ? styles.entityItemSelected : ''}`}
|
|
||||||
onClick={() => setSelectedId(group.id)}
|
|
||||||
role="option"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={selectedId === group.id}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(group.id) } }}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className={styles.emptySearch}>No groups match your search</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.detailPane}>
|
<EntityList
|
||||||
{selected ? (
|
items={filtered}
|
||||||
<>
|
renderItem={(group) => {
|
||||||
<div className={styles.detailHeader}>
|
const groupChildren = groups.filter((g) => g.parentId === group.id)
|
||||||
<Avatar name={selected.name} size="lg" />
|
const groupMembers = MOCK_USERS.filter((u) => u.directGroups.includes(group.id))
|
||||||
<div className={styles.detailHeaderInfo}>
|
const groupParent = group.parentId ? groups.find((g) => g.id === group.parentId) : null
|
||||||
<div className={styles.detailName}>
|
return (
|
||||||
{selected.builtIn ? selected.name : (
|
<>
|
||||||
<InlineEdit
|
<Avatar name={group.name} size="sm" />
|
||||||
value={selected.name}
|
<div className={styles.entityInfo}>
|
||||||
onSave={(v) => updateGroup(selected.id, { name: v })}
|
<div className={styles.entityName}>{group.name}</div>
|
||||||
/>
|
<div className={styles.entityMeta}>
|
||||||
)}
|
{groupParent ? `Child of ${groupParent.name}` : 'Top-level'}
|
||||||
</div>
|
{' · '}{groupChildren.length} children · {groupMembers.length} members
|
||||||
<div className={styles.detailEmail}>
|
</div>
|
||||||
{parent ? `${parent.name} > ${selected.name}` : 'Top-level group'}
|
<div className={styles.entityTags}>
|
||||||
{selected.builtIn && ' (built-in)'}
|
{group.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||||||
</div>
|
</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>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
disabled={selected.builtIn}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.metaGrid}>
|
<div className={styles.metaGrid}>
|
||||||
<span className={styles.metaLabel}>ID</span>
|
<span className={styles.metaLabel}>ID</span>
|
||||||
<MonoText size="xs">{selected.id}</MonoText>
|
<MonoText size="xs">{selected.id}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{parent && (
|
{parent && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader>Member of</SectionHeader>
|
<SectionHeader>Member of</SectionHeader>
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
<Tag label={parent.name} color="auto" />
|
<Tag label={parent.name} color="auto" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SectionHeader>Members (direct)</SectionHeader>
|
<SectionHeader>Members (direct)</SectionHeader>
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{members.map((u) => (
|
{members.map((u) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={u.id}
|
key={u.id}
|
||||||
label={u.displayName}
|
label={u.displayName}
|
||||||
color="auto"
|
color="auto"
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
// Remove this group from the user's directGroups
|
// Remove this group from the user's directGroups
|
||||||
// Note: in mock data we can't easily update MOCK_USERS, so this is visual only
|
// 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' })
|
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 && (
|
{members.length === 0 && <span className={styles.inheritedNote}>(no members)</span>}
|
||||||
<span className={styles.inheritedNote}>
|
<MultiSelect
|
||||||
+ all members of {children.map((c) => c.name).join(', ')}
|
options={availableMembers}
|
||||||
</span>
|
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>
|
<SectionHeader>Child groups</SectionHeader>
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{children.map((c) => (
|
{children.map((c) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={c.id}
|
key={c.id}
|
||||||
label={c.name}
|
label={c.name}
|
||||||
color="success"
|
color="success"
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
updateGroup(c.id, { parentId: null })
|
updateGroup(c.id, { parentId: null })
|
||||||
toast({ title: 'Child group removed', description: c.name, variant: 'success' })
|
toast({ title: 'Child group removed', description: c.name, variant: 'success' })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{children.length === 0 && <span className={styles.inheritedNote}>(no child groups)</span>}
|
{children.length === 0 && <span className={styles.inheritedNote}>(no child groups)</span>}
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={availableChildGroups}
|
options={availableChildGroups}
|
||||||
value={[]}
|
value={[]}
|
||||||
onChange={(ids) => {
|
onChange={(ids) => {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
updateGroup(id, { parentId: selected!.id })
|
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' })
|
||||||
}
|
}
|
||||||
toast({ title: `${ids.length} child group(s) added`, variant: 'success' })
|
|
||||||
}}
|
}}
|
||||||
placeholder="+ Add"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
|
{selected.directRoles.length === 0 && <span className={styles.inheritedNote}>(no roles)</span>}
|
||||||
<SectionHeader>Assigned roles</SectionHeader>
|
<MultiSelect
|
||||||
<div className={styles.sectionTags}>
|
options={availableRoles}
|
||||||
{selected.directRoles.map((r) => (
|
value={[]}
|
||||||
<Tag
|
onChange={(roles) => {
|
||||||
key={r}
|
updateGroup(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
||||||
label={r}
|
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
||||||
color="warning"
|
}}
|
||||||
onRemove={() => {
|
placeholder="+ Add"
|
||||||
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
|
/>
|
||||||
if (memberCount > 0) {
|
</div>
|
||||||
setRemoveRoleTarget(r)
|
</>
|
||||||
} else {
|
) : null}
|
||||||
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
|
emptyMessage="Select a group to view details"
|
||||||
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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className={styles.emptyDetail}>Select a group to view details</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
|||||||
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
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 { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
|
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
|
||||||
import styles from './UserManagement.module.css'
|
import styles from './UserManagement.module.css'
|
||||||
@@ -79,141 +81,124 @@ export function RolesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.splitPane}>
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search roles..."
|
<div className={styles.createForm}>
|
||||||
value={search}
|
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
{duplicateRoleName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Role name already exists</span>}
|
||||||
onClear={() => setSearch('')}
|
<Input placeholder="Description" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
|
||||||
className={styles.listHeaderSearch}
|
<div className={styles.createFormActions}>
|
||||||
/>
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateRoleName}>Create</Button>
|
||||||
+ Add role
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.entityList} role="listbox" aria-label="Roles">
|
|
||||||
{filtered.map((role) => (
|
|
||||||
<div
|
|
||||||
key={role.id}
|
|
||||||
className={`${styles.entityItem} ${selectedId === role.id ? styles.entityItemSelected : ''}`}
|
|
||||||
onClick={() => setSelectedId(role.id)}
|
|
||||||
role="option"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={selectedId === role.id}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(role.id) } }}
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className={styles.emptySearch}>No roles match your search</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.detailPane}>
|
<EntityList
|
||||||
{selected ? (
|
items={filtered}
|
||||||
<>
|
renderItem={(role) => (
|
||||||
<div className={styles.detailHeader}>
|
<>
|
||||||
<Avatar name={selected.name} size="lg" />
|
<Avatar name={role.name} size="sm" />
|
||||||
<div className={styles.detailHeaderInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.detailName}>{selected.name}</div>
|
<div className={styles.entityName}>
|
||||||
{selected.description && (
|
{role.name}
|
||||||
<div className={styles.detailEmail}>{selected.description}</div>
|
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className={styles.entityMeta}>
|
||||||
{!selected.system && (
|
{role.description} · {getAssignmentCount(role)} assignments
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
<div className={styles.entityTags}>
|
||||||
variant="danger"
|
{MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name))
|
||||||
onClick={() => setDeleteTarget(selected)}
|
.map((g) => <Badge key={g.id} label={g.name} color="success" />)}
|
||||||
>
|
{MOCK_USERS.filter((u) => u.directRoles.includes(role.name))
|
||||||
Delete
|
.map((u) => <Badge key={u.id} label={u.username} color="auto" />)}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
getItemId={(role) => role.id}
|
||||||
) : (
|
selectedId={selectedId ?? undefined}
|
||||||
<div className={styles.emptyDetail}>Select a role to view details</div>
|
onSelect={setSelectedId}
|
||||||
)}
|
searchPlaceholder="Search roles..."
|
||||||
</div>
|
onSearch={setSearch}
|
||||||
</div>
|
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
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -1,63 +1,3 @@
|
|||||||
.splitPane {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 52fr 48fr;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--border-subtle);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
min-height: 500px;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailPane {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.listHeaderSearch {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityList {
|
|
||||||
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(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityInfo {
|
.entityInfo {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -140,16 +80,6 @@
|
|||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyDetail {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.createForm {
|
.createForm {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
@@ -190,14 +120,6 @@
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptySearch {
|
|
||||||
padding: 32px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.securitySection {
|
.securitySection {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { InfoCallout } from '../../../design-system/primitives/InfoCallout/InfoC
|
|||||||
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
||||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
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 { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks'
|
import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks'
|
||||||
import styles from './UserManagement.module.css'
|
import styles from './UserManagement.module.css'
|
||||||
@@ -97,260 +99,243 @@ export function UsersTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.splitPane}>
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search users..."
|
<div className={styles.createForm}>
|
||||||
value={search}
|
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<RadioItem value="local" label="Local" />
|
||||||
onClear={() => setSearch('')}
|
<RadioItem value="oidc" label="OIDC" />
|
||||||
className={styles.listHeaderSearch}
|
</RadioGroup>
|
||||||
/>
|
<div className={styles.createFormRow}>
|
||||||
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
||||||
+ Add user
|
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
|
||||||
|
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
||||||
{creating && (
|
{newProvider === 'local' && (
|
||||||
<div className={styles.createForm}>
|
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||||
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
|
)}
|
||||||
<RadioItem value="local" label="Local" />
|
{newProvider === 'oidc' && (
|
||||||
<RadioItem value="oidc" label="OIDC" />
|
<InfoCallout variant="amber">
|
||||||
</RadioGroup>
|
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
|
||||||
<div className={styles.createFormRow}>
|
</InfoCallout>
|
||||||
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
)}
|
||||||
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
<div className={styles.createFormActions}>
|
||||||
</div>
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
|
<Button
|
||||||
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
size="sm"
|
||||||
{newProvider === 'local' && (
|
variant="primary"
|
||||||
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
onClick={handleCreate}
|
||||||
)}
|
disabled={!newUsername.trim() || (newProvider === 'local' && !newPassword.trim()) || duplicateUsername}
|
||||||
{newProvider === 'oidc' && (
|
>
|
||||||
<InfoCallout variant="amber">
|
Create
|
||||||
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
|
</Button>
|
||||||
</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()) || duplicateUsername}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.entityList} role="listbox" aria-label="Users">
|
|
||||||
{filtered.map((user) => (
|
|
||||||
<div
|
|
||||||
key={user.id}
|
|
||||||
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
|
|
||||||
onClick={() => { setSelectedId(user.id); setResettingPassword(false) }}
|
|
||||||
role="option"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={selectedId === user.id}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(user.id); setResettingPassword(false) } }}
|
|
||||||
>
|
|
||||||
<Avatar name={user.displayName} size="sm" />
|
|
||||||
<div className={styles.entityInfo}>
|
|
||||||
<div className={styles.entityName}>
|
|
||||||
{user.displayName}
|
|
||||||
{user.provider !== 'local' && (
|
|
||||||
<Badge label={user.provider} color="running" variant="outlined" className={styles.providerBadge} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.entityMeta}>
|
|
||||||
{user.email} · {getUserGroupPath(user)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.entityTags}>
|
|
||||||
{user.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
|
||||||
{user.directGroups.map((gId) => {
|
|
||||||
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
|
||||||
return g ? <Badge key={gId} label={g.name} color="success" /> : null
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className={styles.emptySearch}>No users match your search</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.detailPane}>
|
<EntityList
|
||||||
{selected ? (
|
items={filtered}
|
||||||
<>
|
renderItem={(user) => (
|
||||||
<div className={styles.detailHeader}>
|
<>
|
||||||
<Avatar name={selected.displayName} size="lg" />
|
<Avatar name={user.displayName} size="sm" />
|
||||||
<div className={styles.detailHeaderInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.detailName}>
|
<div className={styles.entityName}>
|
||||||
<InlineEdit
|
{user.displayName}
|
||||||
value={selected.displayName}
|
{user.provider !== 'local' && (
|
||||||
onSave={(v) => updateUser(selected.id, { displayName: v })}
|
<Badge label={user.provider} color="running" variant="outlined" className={styles.providerBadge} />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.detailEmail}>{selected.email}</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => setDeleteTarget(selected)}
|
|
||||||
disabled={selected.username === 'hendrik'}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionHeader>Status</SectionHeader>
|
|
||||||
<div className={styles.sectionTags}>
|
|
||||||
<Tag label="Active" color="success" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.metaGrid}>
|
|
||||||
<span className={styles.metaLabel}>ID</span>
|
|
||||||
<MonoText size="xs">{selected.id}</MonoText>
|
|
||||||
<span className={styles.metaLabel}>Created</span>
|
|
||||||
<span className={styles.metaValue}>{new Date(selected.createdAt).toLocaleDateString()}</span>
|
|
||||||
<span className={styles.metaLabel}>Provider</span>
|
|
||||||
<span className={styles.metaValue}>{selected.provider}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
{resettingPassword && (
|
<div className={styles.entityMeta}>
|
||||||
<div className={styles.resetForm}>
|
{user.email} · {getUserGroupPath(user)}
|
||||||
<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>
|
</div>
|
||||||
<InfoCallout variant="amber">
|
<div className={styles.entityTags}>
|
||||||
Password managed by the identity provider.
|
{user.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||||||
</InfoCallout>
|
{user.directGroups.map((gId) => {
|
||||||
</>
|
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
)}
|
return g ? <Badge key={gId} label={g.name} color="success" /> : null
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
<SectionHeader>Group membership (direct only)</SectionHeader>
|
</div>
|
||||||
<div className={styles.sectionTags}>
|
</>
|
||||||
{selected.directGroups.map((gId) => {
|
|
||||||
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
|
||||||
return g ? (
|
|
||||||
<Tag
|
|
||||||
key={gId}
|
|
||||||
label={g.name}
|
|
||||||
color="success"
|
|
||||||
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' })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
})}
|
|
||||||
{selected.directGroups.length === 0 && (
|
|
||||||
<span className={styles.inheritedNote}>(no groups)</span>
|
|
||||||
)}
|
|
||||||
<MultiSelect
|
|
||||||
options={availableGroups}
|
|
||||||
value={[]}
|
|
||||||
onChange={(ids) => {
|
|
||||||
updateUser(selected.id, { directGroups: [...selected.directGroups, ...ids] })
|
|
||||||
toast({ title: `${ids.length} group(s) added`, variant: 'success' })
|
|
||||||
}}
|
|
||||||
placeholder="+ Add"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionHeader>Effective roles (direct + inherited)</SectionHeader>
|
|
||||||
<div className={styles.sectionTags}>
|
|
||||||
{effectiveRoles.map(({ role, source }) =>
|
|
||||||
source === 'direct' ? (
|
|
||||||
<Tag
|
|
||||||
key={role}
|
|
||||||
label={role}
|
|
||||||
color="warning"
|
|
||||||
onRemove={() => {
|
|
||||||
updateUser(selected.id, { directRoles: selected.directRoles.filter((r) => r !== role) })
|
|
||||||
toast({ title: 'Role removed', description: role, variant: 'success' })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
key={role}
|
|
||||||
label={`${role} ↑ ${source}`}
|
|
||||||
color="warning"
|
|
||||||
variant="dashed"
|
|
||||||
className={styles.inherited}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{effectiveRoles.length === 0 && (
|
|
||||||
<span className={styles.inheritedNote}>(no roles)</span>
|
|
||||||
)}
|
|
||||||
<MultiSelect
|
|
||||||
options={availableRoles}
|
|
||||||
value={[]}
|
|
||||||
onChange={(roles) => {
|
|
||||||
updateUser(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
|
||||||
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
|
||||||
}}
|
|
||||||
placeholder="+ Add"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{effectiveRoles.some((r) => r.source !== 'direct') && (
|
|
||||||
<span className={styles.inheritedNote}>
|
|
||||||
Roles with ↑ are inherited through group membership
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</>
|
getItemId={(user) => user.id}
|
||||||
) : (
|
selectedId={selectedId ?? undefined}
|
||||||
<div className={styles.emptyDetail}>Select a user to view details</div>
|
onSelect={(id) => { setSelectedId(id); setResettingPassword(false) }}
|
||||||
)}
|
searchPlaceholder="Search users..."
|
||||||
</div>
|
onSearch={setSearch}
|
||||||
</div>
|
addLabel="+ Add user"
|
||||||
|
onAdd={() => setCreating(true)}
|
||||||
|
emptyMessage="No users match your search"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
detail={selected ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selected.displayName} size="lg" />
|
||||||
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>
|
||||||
|
<InlineEdit
|
||||||
|
value={selected.displayName}
|
||||||
|
onSave={(v) => updateUser(selected.id, { displayName: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailEmail}>{selected.email}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
disabled={selected.username === 'hendrik'}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Status</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
<Tag label="Active" color="success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{selected.id}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Created</span>
|
||||||
|
<span className={styles.metaValue}>{new Date(selected.createdAt).toLocaleDateString()}</span>
|
||||||
|
<span className={styles.metaLabel}>Provider</span>
|
||||||
|
<span className={styles.metaValue}>{selected.provider}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<SectionHeader>Group membership (direct only)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{selected.directGroups.map((gId) => {
|
||||||
|
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
|
return g ? (
|
||||||
|
<Tag
|
||||||
|
key={gId}
|
||||||
|
label={g.name}
|
||||||
|
color="success"
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
{selected.directGroups.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(no groups)</span>
|
||||||
|
)}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableGroups}
|
||||||
|
value={[]}
|
||||||
|
onChange={(ids) => {
|
||||||
|
updateUser(selected.id, { directGroups: [...selected.directGroups, ...ids] })
|
||||||
|
toast({ title: `${ids.length} group(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Effective roles (direct + inherited)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{effectiveRoles.map(({ role, source }) =>
|
||||||
|
source === 'direct' ? (
|
||||||
|
<Tag
|
||||||
|
key={role}
|
||||||
|
label={role}
|
||||||
|
color="warning"
|
||||||
|
onRemove={() => {
|
||||||
|
updateUser(selected.id, { directRoles: selected.directRoles.filter((r) => r !== role) })
|
||||||
|
toast({ title: 'Role removed', description: role, variant: 'success' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
key={role}
|
||||||
|
label={`${role} ↑ ${source}`}
|
||||||
|
color="warning"
|
||||||
|
variant="dashed"
|
||||||
|
className={styles.inherited}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{effectiveRoles.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(no roles)</span>
|
||||||
|
)}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableRoles}
|
||||||
|
value={[]}
|
||||||
|
onChange={(roles) => {
|
||||||
|
updateUser(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
||||||
|
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{effectiveRoles.some((r) => r.source !== 'direct') && (
|
||||||
|
<span className={styles.inheritedNote}>
|
||||||
|
Roles with ↑ are inherited through group membership
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
emptyMessage="Select a user to view details"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -96,16 +96,6 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instance count badge in group header */
|
|
||||||
.instanceCountBadge {
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-inset);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Group meta row */
|
/* Group meta row */
|
||||||
.groupMeta {
|
.groupMeta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -138,62 +128,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instance table */
|
|
||||||
.instanceTable {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceTable thead th {
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thStatus {
|
|
||||||
width: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tdStatus {
|
|
||||||
width: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Instance row */
|
|
||||||
.instanceRow {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRow td {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRow:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRow:hover td {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRowActive td {
|
|
||||||
background: var(--amber-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRowActive td:first-child {
|
|
||||||
box-shadow: inset 3px 0 0 var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Instance fields */
|
/* Instance fields */
|
||||||
.instanceName {
|
.instanceName {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
import styles from './AgentHealth.module.css'
|
import styles from './AgentHealth.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -9,9 +10,11 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
|||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
|
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
|
||||||
|
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
||||||
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
||||||
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
||||||
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
||||||
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
@@ -143,6 +146,72 @@ export function AgentHealth() {
|
|||||||
// Build trend data for selected instance
|
// Build trend data for selected instance
|
||||||
const trendData = selectedInstance ? buildTrendData(selectedInstance) : null
|
const trendData = selectedInstance ? buildTrendData(selectedInstance) : null
|
||||||
|
|
||||||
|
// Column definitions for the instance DataTable
|
||||||
|
const instanceColumns: Column<AgentHealthData>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: '',
|
||||||
|
width: '12px',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Instance',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
header: 'State',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<Badge
|
||||||
|
label={row.status.toUpperCase()}
|
||||||
|
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uptime',
|
||||||
|
header: 'Uptime',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tps',
|
||||||
|
header: 'TPS',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'errorRate',
|
||||||
|
header: 'Errors',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||||
|
{row.errorRate ?? '0 err/h'}
|
||||||
|
</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastSeen',
|
||||||
|
header: 'Heartbeat',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="xs" className={
|
||||||
|
row.status === 'dead' ? styles.instanceHeartbeatDead :
|
||||||
|
row.status === 'stale' ? styles.instanceHeartbeatStale :
|
||||||
|
styles.instanceMeta
|
||||||
|
}>
|
||||||
|
{row.lastSeen}
|
||||||
|
</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [])
|
||||||
|
|
||||||
function handleInstanceClick(inst: AgentHealthData) {
|
function handleInstanceClick(inst: AgentHealthData) {
|
||||||
setSelectedInstance(inst)
|
setSelectedInstance(inst)
|
||||||
setPanelOpen(true)
|
setPanelOpen(true)
|
||||||
@@ -321,7 +390,7 @@ export function AgentHealth() {
|
|||||||
{scope.level !== 'all' && (
|
{scope.level !== 'all' && (
|
||||||
<>
|
<>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -362,65 +431,14 @@ export function AgentHealth() {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
>
|
>
|
||||||
<table className={styles.instanceTable}>
|
<DataTable<AgentHealthData>
|
||||||
<thead>
|
columns={instanceColumns}
|
||||||
<tr>
|
data={group.instances}
|
||||||
<th className={styles.thStatus} />
|
onRowClick={handleInstanceClick}
|
||||||
<th>Instance</th>
|
selectedId={panelOpen ? selectedInstance?.id : undefined}
|
||||||
<th>State</th>
|
pageSize={50}
|
||||||
<th>Uptime</th>
|
flush
|
||||||
<th>TPS</th>
|
/>
|
||||||
<th>Errors</th>
|
|
||||||
<th>Heartbeat</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{group.instances.map((inst) => (
|
|
||||||
<tr
|
|
||||||
key={inst.id}
|
|
||||||
className={[
|
|
||||||
styles.instanceRow,
|
|
||||||
selectedInstance?.id === inst.id && panelOpen ? styles.instanceRowActive : '',
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
onClick={() => handleInstanceClick(inst)}
|
|
||||||
>
|
|
||||||
<td className={styles.tdStatus}>
|
|
||||||
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Badge
|
|
||||||
label={inst.status.toUpperCase()}
|
|
||||||
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
|
|
||||||
variant="filled"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
|
|
||||||
{inst.errorRate ?? '0 err/h'}
|
|
||||||
</MonoText>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="xs" className={
|
|
||||||
inst.status === 'dead' ? styles.instanceHeartbeatDead :
|
|
||||||
inst.status === 'stale' ? styles.instanceHeartbeatStale :
|
|
||||||
styles.instanceMeta
|
|
||||||
}>
|
|
||||||
{inst.lastSeen}
|
|
||||||
</MonoText>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</GroupCard>
|
</GroupCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,20 +51,6 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section header — matches /agents */
|
|
||||||
.sectionHeaderRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Charts 3x2 grid */
|
/* Charts 3x2 grid */
|
||||||
.chartsGrid {
|
.chartsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -125,12 +111,6 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fdRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log + Timeline side by side */
|
/* Log + Timeline side by side */
|
||||||
.bottomRow {
|
.bottomRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -155,52 +135,7 @@
|
|||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logEntries {
|
/* Empty state (shared) */
|
||||||
max-height: 360px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEntry {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 5px 16px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEntry:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEntry:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logTime {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
min-width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logLogger {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-faint);
|
|
||||||
max-width: 220px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logMsg {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEmpty {
|
.logEmpty {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
import styles from './AgentInstance.module.css'
|
import styles from './AgentInstance.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -12,16 +13,15 @@ import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
|||||||
import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart'
|
import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart'
|
||||||
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
||||||
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||||
|
import { LogViewer } from '../../design-system/composites/LogViewer/LogViewer'
|
||||||
|
import type { LogEntry } from '../../design-system/composites/LogViewer/LogViewer'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
||||||
import { ProgressBar } from '../../design-system/primitives/ProgressBar/ProgressBar'
|
|
||||||
import { SectionHeader } from '../../design-system/primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
import { Card } from '../../design-system/primitives/Card/Card'
|
|
||||||
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
|
||||||
|
|
||||||
// Global filters
|
// Global filters
|
||||||
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
@@ -53,27 +53,23 @@ function buildMemoryHistory(currentPct: number) {
|
|||||||
|
|
||||||
// ── Mock log entries ─────────────────────────────────────────────────────────
|
// ── Mock log entries ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildLogEntries(agentName: string) {
|
function buildLogEntries(agentName: string): LogEntry[] {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const MIN = 60_000
|
const MIN = 60_000
|
||||||
return [
|
return [
|
||||||
{ ts: new Date(now - 1 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Route order-validation started and consuming from: direct:validate` },
|
{ timestamp: new Date(now - 1 * MIN).toISOString(), level: 'info', message: `[o.a.c.impl.DefaultCamelContext] Route order-validation started and consuming from: direct:validate` },
|
||||||
{ ts: new Date(now - 2 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Total 3 routes, of which 3 are started` },
|
{ timestamp: new Date(now - 2 * MIN).toISOString(), level: 'info', message: `[o.a.c.impl.DefaultCamelContext] Total 3 routes, of which 3 are started` },
|
||||||
{ ts: new Date(now - 5 * MIN).toISOString(), level: 'WARN', logger: 'o.a.c.processor.errorhandler', msg: `Failed delivery for exchangeId: ID-${agentName}-1710847200000-0-1. Exhausted after 3 attempts.` },
|
{ timestamp: new Date(now - 5 * MIN).toISOString(), level: 'warn', message: `[o.a.c.processor.errorhandler] Failed delivery for exchangeId: ID-${agentName}-1710847200000-0-1. Exhausted after 3 attempts.` },
|
||||||
{ ts: new Date(now - 8 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.health.HealthCheckHelper', msg: `Health check [routes] is UP` },
|
{ timestamp: new Date(now - 8 * MIN).toISOString(), level: 'info', message: `[o.a.c.health.HealthCheckHelper] Health check [routes] is UP` },
|
||||||
{ ts: new Date(now - 12 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.health.HealthCheckHelper', msg: `Health check [consumers] is UP` },
|
{ timestamp: new Date(now - 12 * MIN).toISOString(), level: 'info', message: `[o.a.c.health.HealthCheckHelper] Health check [consumers] is UP` },
|
||||||
{ ts: new Date(now - 15 * MIN).toISOString(), level: 'DEBUG', logger: 'o.a.c.component.kafka', msg: `KafkaConsumer[order-events] poll returned 42 records in 18ms` },
|
{ timestamp: new Date(now - 15 * MIN).toISOString(), level: 'debug', message: `[o.a.c.component.kafka] KafkaConsumer[order-events] poll returned 42 records in 18ms` },
|
||||||
{ ts: new Date(now - 18 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.engine.InternalRouteStartup', msg: `Route order-enrichment started and consuming from: kafka:order-events` },
|
{ timestamp: new Date(now - 18 * MIN).toISOString(), level: 'info', message: `[o.a.c.impl.engine.InternalRouteStartup] Route order-enrichment started and consuming from: kafka:order-events` },
|
||||||
{ ts: new Date(now - 25 * MIN).toISOString(), level: 'WARN', logger: 'o.a.c.component.http', msg: `HTTP endpoint https://payment-api.internal/verify returned 503 — will retry` },
|
{ timestamp: new Date(now - 25 * MIN).toISOString(), level: 'warn', message: `[o.a.c.component.http] HTTP endpoint https://payment-api.internal/verify returned 503 — will retry` },
|
||||||
{ ts: new Date(now - 30 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Apache Camel ${agentName} (CamelContext) is starting` },
|
{ timestamp: new Date(now - 30 * MIN).toISOString(), level: 'info', message: `[o.a.c.impl.DefaultCamelContext] Apache Camel ${agentName} (CamelContext) is starting` },
|
||||||
{ ts: new Date(now - 32 * MIN).toISOString(), level: 'INFO', logger: 'org.springframework.boot', msg: `Started ${agentName} in 4.231 seconds (process running for 4.892)` },
|
{ timestamp: new Date(now - 32 * MIN).toISOString(), level: 'info', message: `[org.springframework.boot] Started ${agentName} in 4.231 seconds (process running for 4.892)` },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLogTime(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mock JVM / process info ──────────────────────────────────────────────────
|
// ── Mock JVM / process info ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildProcessInfo(agent: typeof agents[0]) {
|
function buildProcessInfo(agent: typeof agents[0]) {
|
||||||
@@ -144,7 +140,7 @@ export function AgentInstance() {
|
|||||||
const logEntries = buildLogEntries(agent.name)
|
const logEntries = buildLogEntries(agent.name)
|
||||||
const filteredLogs = logFilter === 'all'
|
const filteredLogs = logFilter === 'all'
|
||||||
? logEntries
|
? logEntries
|
||||||
: logEntries.filter((l) => l.level === logFilter.toUpperCase())
|
: logEntries.filter((l) => l.level === logFilter)
|
||||||
|
|
||||||
const cpuData = buildTimeSeries(agent.cpuUsagePct, 15)
|
const cpuData = buildTimeSeries(agent.cpuUsagePct, 15)
|
||||||
const memSeries = buildMemoryHistory(agent.memoryUsagePct)
|
const memSeries = buildMemoryHistory(agent.memoryUsagePct)
|
||||||
@@ -182,9 +178,9 @@ export function AgentInstance() {
|
|||||||
{/* Scope trail + badges */}
|
{/* Scope trail + badges */}
|
||||||
<div className={styles.scopeTrail}>
|
<div className={styles.scopeTrail}>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||||
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
||||||
<Badge label={agent.version} color="auto" variant="outlined" />
|
<Badge label={agent.version} color="auto" variant="outlined" />
|
||||||
@@ -289,22 +285,7 @@ export function AgentInstance() {
|
|||||||
<SectionHeader>Application Log</SectionHeader>
|
<SectionHeader>Application Log</SectionHeader>
|
||||||
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
|
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.logEntries}>
|
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||||
{filteredLogs.map((entry, i) => (
|
|
||||||
<div key={i} className={styles.logEntry}>
|
|
||||||
<MonoText size="xs" className={styles.logTime}>{formatLogTime(entry.ts)}</MonoText>
|
|
||||||
<Badge
|
|
||||||
label={entry.level}
|
|
||||||
color={entry.level === 'WARN' ? 'warning' : entry.level === 'ERROR' ? 'error' : entry.level === 'DEBUG' ? 'auto' : 'success'}
|
|
||||||
/>
|
|
||||||
<MonoText size="xs" className={styles.logLogger}>{entry.logger}</MonoText>
|
|
||||||
<span className={styles.logMsg}>{entry.msg}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{filteredLogs.length === 0 && (
|
|
||||||
<div className={styles.logEmpty}>No log entries match the selected filter.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
|
|||||||
@@ -7,14 +7,6 @@
|
|||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Health strip */
|
|
||||||
.healthStrip {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter bar spacing */
|
/* Filter bar spacing */
|
||||||
.filterBar {
|
.filterBar {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -15,9 +16,10 @@ import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/Shortc
|
|||||||
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
||||||
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
import { KpiStrip } from '../../design-system/composites/KpiStrip/KpiStrip'
|
||||||
|
import type { KpiItem } from '../../design-system/composites/KpiStrip/KpiStrip'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
|
||||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
@@ -27,12 +29,44 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
import { kpiMetrics } from '../../mocks/metrics'
|
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
|
||||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||||
|
|
||||||
// Route → Application lookup
|
// Route → Application lookup
|
||||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
|
// ─── KPI mapping ─────────────────────────────────────────────────────────────
|
||||||
|
const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
|
||||||
|
amber: 'var(--amber)',
|
||||||
|
success: 'var(--success)',
|
||||||
|
error: 'var(--error)',
|
||||||
|
running: 'var(--running)',
|
||||||
|
warning: 'var(--warning)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
|
||||||
|
up: <TrendingUp size={12} />,
|
||||||
|
down: <TrendingDown size={12} />,
|
||||||
|
neutral: <ArrowRight size={12} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'good': return 'success'
|
||||||
|
case 'bad': return 'error'
|
||||||
|
case 'neutral': return 'muted'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
||||||
|
label: m.label,
|
||||||
|
value: m.unit ? `${m.value} ${m.unit}` : m.value,
|
||||||
|
trend: { label: <><span style={{ display: 'inline-flex', verticalAlign: 'middle' }}>{TREND_ICONS[m.trend]}</span> {m.trendValue}</>, variant: sentimentToVariant(m.trendSentiment) },
|
||||||
|
subtitle: m.detail,
|
||||||
|
sparkline: m.sparkline,
|
||||||
|
borderColor: ACCENT_TO_COLOR[m.accent],
|
||||||
|
}))
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||||
@@ -173,7 +207,7 @@ export function Dashboard() {
|
|||||||
navigate(`/exchanges/${row.id}`)
|
navigate(`/exchanges/${row.id}`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↗
|
<ExternalLink size={14} />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -270,7 +304,7 @@ export function Dashboard() {
|
|||||||
className={styles.openDetailLink}
|
className={styles.openDetailLink}
|
||||||
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
||||||
>
|
>
|
||||||
Open full details →
|
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -370,20 +404,7 @@ export function Dashboard() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
|
|
||||||
{/* Health strip */}
|
{/* Health strip */}
|
||||||
<div className={styles.healthStrip}>
|
<KpiStrip items={kpiItems} />
|
||||||
{kpiMetrics.map((kpi, i) => (
|
|
||||||
<StatCard
|
|
||||||
key={i}
|
|
||||||
label={kpi.label}
|
|
||||||
value={kpi.value}
|
|
||||||
detail={kpi.detail}
|
|
||||||
trend={kpi.trend}
|
|
||||||
trendValue={kpi.trendValue}
|
|
||||||
accent={kpi.accent}
|
|
||||||
sparkline={kpi.sparkline}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Exchanges table */}
|
{/* Exchanges table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
@@ -408,7 +429,7 @@ export function Dashboard() {
|
|||||||
expandedContent={(row) =>
|
expandedContent={(row) =>
|
||||||
row.errorMessage ? (
|
row.errorMessage ? (
|
||||||
<div className={styles.inlineError}>
|
<div className={styles.inlineError}>
|
||||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
||||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'Spinner', href: '#spinner' },
|
{ label: 'Spinner', href: '#spinner' },
|
||||||
{ label: 'StatCard', href: '#statcard' },
|
{ label: 'StatCard', href: '#statcard' },
|
||||||
{ label: 'StatusDot', href: '#statusdot' },
|
{ label: 'StatusDot', href: '#statusdot' },
|
||||||
|
{ label: 'StatusText', href: '#statustext' },
|
||||||
{ label: 'Tag', href: '#tag' },
|
{ label: 'Tag', href: '#tag' },
|
||||||
{ label: 'Textarea', href: '#textarea' },
|
{ label: 'Textarea', href: '#textarea' },
|
||||||
{ label: 'Toggle', href: '#toggle' },
|
{ label: 'Toggle', href: '#toggle' },
|
||||||
@@ -60,10 +61,15 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'DataTable', href: '#datatable' },
|
{ label: 'DataTable', href: '#datatable' },
|
||||||
{ label: 'DetailPanel', href: '#detailpanel' },
|
{ label: 'DetailPanel', href: '#detailpanel' },
|
||||||
{ label: 'Dropdown', href: '#dropdown' },
|
{ label: 'Dropdown', href: '#dropdown' },
|
||||||
|
{ label: 'EntityList', href: '#entitylist' },
|
||||||
{ label: 'EventFeed', href: '#eventfeed' },
|
{ label: 'EventFeed', href: '#eventfeed' },
|
||||||
{ label: 'FilterBar', href: '#filterbar' },
|
{ label: 'FilterBar', href: '#filterbar' },
|
||||||
{ label: 'GroupCard', href: '#groupcard' },
|
{ label: 'GroupCard', href: '#groupcard' },
|
||||||
|
{ label: 'KpiStrip', href: '#kpistrip' },
|
||||||
{ label: 'LineChart', href: '#linechart' },
|
{ label: 'LineChart', href: '#linechart' },
|
||||||
|
{ label: 'LoginDialog', href: '#logindialog' },
|
||||||
|
{ label: 'LoginForm', href: '#loginform' },
|
||||||
|
{ label: 'LogViewer', href: '#logviewer' },
|
||||||
{ label: 'MenuItem', href: '#menuitem' },
|
{ label: 'MenuItem', href: '#menuitem' },
|
||||||
{ label: 'Modal', href: '#modal' },
|
{ label: 'Modal', href: '#modal' },
|
||||||
{ label: 'MultiSelect', href: '#multi-select' },
|
{ label: 'MultiSelect', href: '#multi-select' },
|
||||||
@@ -72,6 +78,7 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'RouteFlow', href: '#routeflow' },
|
{ label: 'RouteFlow', href: '#routeflow' },
|
||||||
{ label: 'SegmentedTabs', href: '#segmented-tabs' },
|
{ label: 'SegmentedTabs', href: '#segmented-tabs' },
|
||||||
{ label: 'ShortcutsBar', href: '#shortcutsbar' },
|
{ label: 'ShortcutsBar', href: '#shortcutsbar' },
|
||||||
|
{ label: 'SplitPane', href: '#splitpane' },
|
||||||
{ label: 'Tabs', href: '#tabs' },
|
{ label: 'Tabs', href: '#tabs' },
|
||||||
{ label: 'Toast', href: '#toast' },
|
{ label: 'Toast', href: '#toast' },
|
||||||
{ label: 'TreeView', href: '#treeview' },
|
{ label: 'TreeView', href: '#treeview' },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Hexagon, ArrowRight, Diamond, Eye, Pencil, RotateCcw, Trash2, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CompositesSection.module.css'
|
import styles from './CompositesSection.module.css'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -12,10 +13,15 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
DetailPanel,
|
DetailPanel,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
EntityList,
|
||||||
EventFeed,
|
EventFeed,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
GroupCard,
|
GroupCard,
|
||||||
|
KpiStrip,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
LoginDialog,
|
||||||
|
LoginForm,
|
||||||
|
LogViewer,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Modal,
|
Modal,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
@@ -24,13 +30,14 @@ import {
|
|||||||
RouteFlow,
|
RouteFlow,
|
||||||
SegmentedTabs,
|
SegmentedTabs,
|
||||||
ShortcutsBar,
|
ShortcutsBar,
|
||||||
|
SplitPane,
|
||||||
Tabs,
|
Tabs,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
useToast,
|
useToast,
|
||||||
TreeView,
|
TreeView,
|
||||||
} from '../../../design-system/composites'
|
} from '../../../design-system/composites'
|
||||||
import type { SearchResult } from '../../../design-system/composites'
|
import type { SearchResult } from '../../../design-system/composites'
|
||||||
import { Button } from '../../../design-system/primitives'
|
import { Avatar, Badge, Button } from '../../../design-system/primitives'
|
||||||
|
|
||||||
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -151,31 +158,85 @@ const TREE_NODES = [
|
|||||||
{
|
{
|
||||||
id: 'app1',
|
id: 'app1',
|
||||||
label: 'cameleer-prod',
|
label: 'cameleer-prod',
|
||||||
icon: '⬡',
|
icon: <Hexagon size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'route1',
|
id: 'route1',
|
||||||
label: 'order-ingest',
|
label: 'order-ingest',
|
||||||
icon: '→',
|
icon: <ArrowRight size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' },
|
{ id: 'proc1', label: 'ValidateOrder', icon: <Diamond size={12} />, meta: '12ms' },
|
||||||
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' },
|
{ id: 'proc2', label: 'EnrichPayload', icon: <Diamond size={12} />, meta: '8ms' },
|
||||||
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' },
|
{ id: 'proc3', label: 'RouteToQueue', icon: <Diamond size={12} />, meta: '3ms' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'route2',
|
id: 'route2',
|
||||||
label: 'payment-validate',
|
label: 'payment-validate',
|
||||||
icon: '→',
|
icon: <ArrowRight size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' },
|
{ id: 'proc4', label: 'TokenizeCard', icon: <Diamond size={12} />, meta: '22ms' },
|
||||||
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' },
|
{ id: 'proc5', label: 'AuthorizePayment', icon: <Diamond size={12} />, meta: '45ms' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Sample data for new composites ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const KPI_ITEMS = [
|
||||||
|
{
|
||||||
|
label: 'Exchanges',
|
||||||
|
value: '12,847',
|
||||||
|
trend: { label: '\u2191 +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: '\u2191 +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: '\u2193 -12ms', variant: 'success' as const },
|
||||||
|
subtitle: 'P95: 380ms',
|
||||||
|
borderColor: 'var(--success)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Active Routes',
|
||||||
|
value: '37',
|
||||||
|
trend: { label: '\u00b10', 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 \u2014 200 OK' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:15Z', level: 'warn' as const, message: 'Retry queue depth at 847 \u2014 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 \u2014 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% \u2014 GC scheduled' },
|
||||||
|
]
|
||||||
|
|
||||||
// ── CompositesSection ─────────────────────────────────────────────────────────
|
// ── CompositesSection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CompositesSection() {
|
export function CompositesSection() {
|
||||||
@@ -219,6 +280,15 @@ export function CompositesSection() {
|
|||||||
// MultiSelect
|
// MultiSelect
|
||||||
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
|
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
|
||||||
|
|
||||||
|
// EntityList state
|
||||||
|
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>('1')
|
||||||
|
const [entitySearch, setEntitySearch] = useState('')
|
||||||
|
|
||||||
|
// LoginDialog
|
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
||||||
|
const [loginLoading, setLoginLoading] = useState(false)
|
||||||
|
const [loginError, setLoginError] = useState<string | undefined>()
|
||||||
|
|
||||||
// 15. Modal
|
// 15. Modal
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
|
||||||
@@ -436,13 +506,13 @@ export function CompositesSection() {
|
|||||||
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
||||||
>
|
>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={<Button size="sm" variant="secondary">Actions ▾</Button>}
|
trigger={<Button size="sm" variant="secondary">Actions <ChevronDown size={12} /></Button>}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'View details', icon: '👁', onClick: () => undefined },
|
{ label: 'View details', icon: <Eye size={14} />, onClick: () => undefined },
|
||||||
{ label: 'Edit route', icon: '✏', onClick: () => undefined },
|
{ label: 'Edit route', icon: <Pencil size={14} />, onClick: () => undefined },
|
||||||
{ divider: true, label: '' },
|
{ divider: true, label: '' },
|
||||||
{ label: 'Restart', icon: '↺', onClick: () => undefined },
|
{ label: 'Restart', icon: <RotateCcw size={14} />, onClick: () => undefined },
|
||||||
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true },
|
{ label: 'Delete', icon: <Trash2 size={14} />, onClick: () => undefined, disabled: true },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -458,6 +528,38 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<span style={{ marginLeft: 'auto' }}><Badge label={item.role} /></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
selectedId={selectedEntityId}
|
||||||
|
onSelect={setSelectedEntityId}
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
|
onSearch={setEntitySearch}
|
||||||
|
addLabel="+ Add user"
|
||||||
|
onAdd={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 11b. GroupCard */}
|
{/* 11b. GroupCard */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="groupcard"
|
id="groupcard"
|
||||||
@@ -477,6 +579,17 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
{/* 12. FilterBar */}
|
{/* 12. FilterBar */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="filterbar"
|
id="filterbar"
|
||||||
@@ -502,6 +615,70 @@ export function CompositesSection() {
|
|||||||
<LineChart series={CHART_SERIES} xLabel="Time (minutes)" yLabel="Count" width={420} height={180} />
|
<LineChart series={CHART_SERIES} xLabel="Time (minutes)" yLabel="Count" width={420} height={180} />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* LoginDialog */}
|
||||||
|
<DemoCard
|
||||||
|
id="logindialog"
|
||||||
|
title="LoginDialog"
|
||||||
|
description="Modal login dialog wrapping LoginForm. Supports social providers, credentials, and error states."
|
||||||
|
>
|
||||||
|
<div className={styles.demoAreaRow}>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => setLoginDialogOpen(true)}>
|
||||||
|
Open LoginDialog
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => { setLoginError('Invalid email or password.'); setLoginDialogOpen(true) }}>
|
||||||
|
With error
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<LoginDialog
|
||||||
|
open={loginDialogOpen}
|
||||||
|
onClose={() => { setLoginDialogOpen(false); setLoginError(undefined); setLoginLoading(false) }}
|
||||||
|
logo={<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--amber)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 700, fontSize: 16 }}>C</div>}
|
||||||
|
title="Sign in to Cameleer"
|
||||||
|
socialProviders={[
|
||||||
|
{ label: 'Continue with Google', onClick: () => {} },
|
||||||
|
{ label: 'Continue with GitHub', onClick: () => {} },
|
||||||
|
]}
|
||||||
|
onSubmit={() => {
|
||||||
|
setLoginLoading(true)
|
||||||
|
setTimeout(() => { setLoginLoading(false); setLoginDialogOpen(false) }, 1500)
|
||||||
|
}}
|
||||||
|
onForgotPassword={() => {}}
|
||||||
|
onSignUp={() => {}}
|
||||||
|
error={loginError}
|
||||||
|
loading={loginLoading}
|
||||||
|
/>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* LoginForm */}
|
||||||
|
<DemoCard
|
||||||
|
id="loginform"
|
||||||
|
title="LoginForm"
|
||||||
|
description="Standalone login form with configurable social providers, validation, and loading state."
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 360 }}>
|
||||||
|
<LoginForm
|
||||||
|
title="Sign in"
|
||||||
|
socialProviders={[
|
||||||
|
{ label: 'Continue with Google', onClick: () => {} },
|
||||||
|
]}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onForgotPassword={() => {}}
|
||||||
|
onSignUp={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
{/* 14. MenuItem */}
|
{/* 14. MenuItem */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="menuitem"
|
id="menuitem"
|
||||||
@@ -593,7 +770,7 @@ export function CompositesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="processortimeline"
|
id="processortimeline"
|
||||||
title="ProcessorTimeline"
|
title="ProcessorTimeline"
|
||||||
description="Horizontal Gantt-style timeline showing processor execution order, duration, and status."
|
description="Horizontal Gantt-style timeline with selectable rows and optional action menus via actions or getActions prop."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<ProcessorTimeline
|
<ProcessorTimeline
|
||||||
@@ -604,6 +781,11 @@ export function CompositesSection() {
|
|||||||
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
||||||
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
||||||
]}
|
]}
|
||||||
|
getActions={(proc) => [
|
||||||
|
{ label: 'Change Log Level', onClick: () => {} },
|
||||||
|
{ label: 'View Configuration', onClick: () => {} },
|
||||||
|
...(proc.status === 'fail' ? [{ label: 'View Stack Trace', onClick: () => {} }] : []),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -612,7 +794,7 @@ export function CompositesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="routeflow"
|
id="routeflow"
|
||||||
title="RouteFlow"
|
title="RouteFlow"
|
||||||
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
|
description="Vertical processor node diagram with status coloring, connectors, and optional action menus."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
<RouteFlow
|
<RouteFlow
|
||||||
@@ -626,6 +808,41 @@ export function CompositesSection() {
|
|||||||
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
||||||
]}
|
]}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Change Log Level', onClick: () => {} },
|
||||||
|
{ label: 'Enable Tracing', onClick: () => {} },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 17c. RouteFlow (Multi-Flow) */}
|
||||||
|
<DemoCard
|
||||||
|
id="routeflow-multi"
|
||||||
|
title="RouteFlow (Multi-Flow)"
|
||||||
|
description="Multiple flow segments with labels, showing a main route alongside an exception handler."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
<RouteFlow
|
||||||
|
flows={[
|
||||||
|
{
|
||||||
|
label: 'Main Route',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
|
||||||
|
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
|
||||||
|
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow' },
|
||||||
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'onException(IOException)',
|
||||||
|
variant: 'error',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'log:error-logger', type: 'process', durationMs: 2, status: 'ok' },
|
||||||
|
{ name: 'dead-letter:failed-orders', type: 'to', durationMs: 14, status: 'fail' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -647,6 +864,33 @@ export function CompositesSection() {
|
|||||||
/>
|
/>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
{/* 19. Tabs */}
|
{/* 19. Tabs */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="tabs"
|
id="tabs"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './PrimitivesSection.module.css'
|
import styles from './PrimitivesSection.module.css'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
StatCard,
|
StatCard,
|
||||||
StatusDot,
|
StatusDot,
|
||||||
|
StatusText,
|
||||||
Tag,
|
Tag,
|
||||||
Textarea,
|
Textarea,
|
||||||
Toggle,
|
Toggle,
|
||||||
@@ -204,12 +206,18 @@ export function PrimitivesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="card"
|
id="card"
|
||||||
title="Card"
|
title="Card"
|
||||||
description="Surface container with optional left-border accent colour."
|
description="Surface container with optional left-border accent colour and title header."
|
||||||
>
|
>
|
||||||
<Card><div style={{ padding: '8px 12px', fontSize: 13 }}>Plain card</div></Card>
|
<Card><div style={{ padding: '8px 12px', fontSize: 13 }}>Plain card</div></Card>
|
||||||
<Card accent="amber"><div style={{ padding: '8px 12px', fontSize: 13 }}>Amber accent</div></Card>
|
<Card accent="amber"><div style={{ padding: '8px 12px', fontSize: 13 }}>Amber accent</div></Card>
|
||||||
<Card accent="success"><div style={{ padding: '8px 12px', fontSize: 13 }}>Success accent</div></Card>
|
<Card accent="success"><div style={{ padding: '8px 12px', fontSize: 13 }}>Success accent</div></Card>
|
||||||
<Card accent="error"><div style={{ padding: '8px 12px', fontSize: 13 }}>Error accent</div></Card>
|
<Card accent="error"><div style={{ padding: '8px 12px', fontSize: 13 }}>Error accent</div></Card>
|
||||||
|
<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>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 6. Checkbox */}
|
{/* 6. Checkbox */}
|
||||||
@@ -351,7 +359,7 @@ export function PrimitivesSection() {
|
|||||||
description="Text input with optional leading icon and placeholder."
|
description="Text input with optional leading icon and placeholder."
|
||||||
>
|
>
|
||||||
<Input placeholder="Plain input" />
|
<Input placeholder="Plain input" />
|
||||||
<Input icon="🔍" placeholder="With icon" />
|
<Input icon={<Search size={14} />} placeholder="With icon" />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 15b. InlineEdit */}
|
{/* 15b. InlineEdit */}
|
||||||
@@ -559,7 +567,31 @@ export function PrimitivesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 29. Tag */}
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 30. Tag */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="tag"
|
id="tag"
|
||||||
title="Tag"
|
title="Tag"
|
||||||
@@ -573,7 +605,7 @@ export function PrimitivesSection() {
|
|||||||
<Tag label="removable" color="primary" onRemove={() => undefined} />
|
<Tag label="removable" color="primary" onRemove={() => undefined} />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 30. Textarea */}
|
{/* 31. Textarea */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="textarea"
|
id="textarea"
|
||||||
title="Textarea"
|
title="Textarea"
|
||||||
@@ -582,7 +614,7 @@ export function PrimitivesSection() {
|
|||||||
<Textarea placeholder="Enter a description…" style={{ width: 280 }} />
|
<Textarea placeholder="Enter a description…" style={{ width: 280 }} />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 31. Toggle */}
|
{/* 32. Toggle */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="toggle"
|
id="toggle"
|
||||||
title="Toggle"
|
title="Toggle"
|
||||||
@@ -602,7 +634,7 @@ export function PrimitivesSection() {
|
|||||||
<Toggle label="Locked off" disabled />
|
<Toggle label="Locked off" disabled />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 32. Tooltip */}
|
{/* 33. Tooltip */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="tooltip"
|
id="tooltip"
|
||||||
title="Tooltip"
|
title="Tooltip"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import styles from './RouteDetail.module.css'
|
import styles from './RouteDetail.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -337,7 +338,7 @@ export function RouteDetail() {
|
|||||||
expandedContent={(row) =>
|
expandedContent={(row) =>
|
||||||
row.errorMessage ? (
|
row.errorMessage ? (
|
||||||
<div className={styles.inlineError}>
|
<div className={styles.inlineError}>
|
||||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.errorClass}>{row.errorClass}</div>
|
<div className={styles.errorClass}>{row.errorClass}</div>
|
||||||
<div className={styles.errorText}>{row.errorMessage}</div>
|
<div className={styles.errorText}>{row.errorMessage}</div>
|
||||||
|
|||||||
@@ -35,176 +35,6 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* KPI strip */
|
|
||||||
.kpiStrip {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* KPI 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiCard::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiCardAmber::before { background: linear-gradient(90deg, var(--amber), transparent); }
|
|
||||||
.kpiCardGreen::before { background: linear-gradient(90deg, var(--success), transparent); }
|
|
||||||
.kpiCardError::before { background: linear-gradient(90deg, var(--error), transparent); }
|
|
||||||
.kpiCardTeal::before { background: linear-gradient(90deg, var(--running), transparent); }
|
|
||||||
.kpiCardWarn::before { background: linear-gradient(90deg, var(--warning), transparent); }
|
|
||||||
|
|
||||||
.kpiLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiValueRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiValueAmber { color: var(--amber); }
|
|
||||||
.kpiValueGreen { color: var(--success); }
|
|
||||||
.kpiValueError { color: var(--error); }
|
|
||||||
.kpiValueTeal { color: var(--running); }
|
|
||||||
.kpiValueWarn { color: var(--warning); }
|
|
||||||
|
|
||||||
.kpiUnit {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiTrend {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trendUpGood { color: var(--success); }
|
|
||||||
.trendUpBad { color: var(--error); }
|
|
||||||
.trendDownGood { color: var(--success); }
|
|
||||||
.trendDownBad { color: var(--error); }
|
|
||||||
.trendFlat { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.kpiDetail {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiDetailStrong {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiSparkline {
|
|
||||||
margin-top: 8px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Latency percentiles card */
|
|
||||||
.latencyValues {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latencyItem {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latencyLabel {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.latencyVal {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latValGreen { color: var(--success); }
|
|
||||||
.latValAmber { color: var(--amber); }
|
|
||||||
.latValRed { color: var(--error); }
|
|
||||||
|
|
||||||
.latencyTrend {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active routes donut */
|
|
||||||
.donutWrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donutLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.donutLegend {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.donutLegendActive {
|
|
||||||
color: var(--running);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Route performance table */
|
/* Route performance table */
|
||||||
.tableSection {
|
.tableSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -273,24 +103,6 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartCard {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
padding: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartTitle {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
|||||||
import type { Column } from '../../design-system/composites/DataTable/types'
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||||
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
import { KpiStrip } from '../../design-system/composites'
|
||||||
|
import type { KpiItem } from '../../design-system/composites'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
|
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
|
import { Card } from '../../design-system/primitives'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import {
|
import {
|
||||||
@@ -34,8 +37,8 @@ import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
|||||||
|
|
||||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
// ─── KPI Header Strip (matches mock-v3-metrics-dashboard) ────────────────────
|
// ─── Build KPI items from scoped route metrics ──────────────────────────────
|
||||||
function KpiHeader({ scopedMetrics }: { scopedMetrics: RouteMetricRow[] }) {
|
function buildKpiItems(scopedMetrics: RouteMetricRow[]): KpiItem[] {
|
||||||
const totalExchanges = scopedMetrics.reduce((sum, r) => sum + r.exchangeCount, 0)
|
const totalExchanges = scopedMetrics.reduce((sum, r) => sum + r.exchangeCount, 0)
|
||||||
const totalErrors = scopedMetrics.reduce((sum, r) => sum + r.errorCount, 0)
|
const totalErrors = scopedMetrics.reduce((sum, r) => sum + r.errorCount, 0)
|
||||||
const errorRate = totalExchanges > 0 ? ((totalErrors / totalExchanges) * 100) : 0
|
const errorRate = totalExchanges > 0 ? ((totalErrors / totalExchanges) * 100) : 0
|
||||||
@@ -45,113 +48,57 @@ function KpiHeader({ scopedMetrics }: { scopedMetrics: RouteMetricRow[] }) {
|
|||||||
const p99Latency = scopedMetrics.length > 0
|
const p99Latency = scopedMetrics.length > 0
|
||||||
? Math.max(...scopedMetrics.map((r) => r.p99DurationMs))
|
? Math.max(...scopedMetrics.map((r) => r.p99DurationMs))
|
||||||
: 0
|
: 0
|
||||||
const avgSuccessRate = scopedMetrics.length > 0
|
|
||||||
? Number((scopedMetrics.reduce((sum, r) => sum + r.successRate, 0) / scopedMetrics.length).toFixed(1))
|
|
||||||
: 0
|
|
||||||
const throughputPerSec = totalExchanges > 0 ? (totalExchanges / 360).toFixed(1) : '0'
|
const throughputPerSec = totalExchanges > 0 ? (totalExchanges / 360).toFixed(1) : '0'
|
||||||
const activeRoutes = scopedMetrics.length
|
const activeRoutes = scopedMetrics.length
|
||||||
const totalRoutes = routeMetrics.length
|
const totalRoutes = routeMetrics.length
|
||||||
|
|
||||||
return (
|
const p50 = Math.round(avgLatency * 0.5)
|
||||||
<div className={styles.kpiStrip}>
|
const p95 = Math.round(avgLatency * 1.4)
|
||||||
{/* Card 1: Total Throughput */}
|
const slaStatus = p99Latency > 300 ? 'BREACH' : 'OK'
|
||||||
<div className={`${styles.kpiCard} ${styles.kpiCardAmber}`}>
|
|
||||||
<div className={styles.kpiLabel}>Total Throughput</div>
|
|
||||||
<div className={styles.kpiValueRow}>
|
|
||||||
<span className={`${styles.kpiValue} ${styles.kpiValueAmber}`}>{totalExchanges.toLocaleString()}</span>
|
|
||||||
<span className={styles.kpiUnit}>exchanges</span>
|
|
||||||
<span className={`${styles.kpiTrend} ${styles.trendUpGood}`}>▲ +8%</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.kpiDetail}>
|
|
||||||
<span className={styles.kpiDetailStrong}>{throughputPerSec}</span> msg/s · Capacity 39%
|
|
||||||
</div>
|
|
||||||
<div className={styles.kpiSparkline}>
|
|
||||||
<Sparkline data={[44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47]} color="var(--amber)" width={200} height={32} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 2: System Error Rate */}
|
return [
|
||||||
<div className={`${styles.kpiCard} ${errorRate < 1 ? styles.kpiCardGreen : styles.kpiCardError}`}>
|
{
|
||||||
<div className={styles.kpiLabel}>System Error Rate</div>
|
label: 'Total Throughput',
|
||||||
<div className={styles.kpiValueRow}>
|
value: totalExchanges.toLocaleString(),
|
||||||
<span className={`${styles.kpiValue} ${errorRate < 1 ? styles.kpiValueGreen : styles.kpiValueError}`}>{errorRate.toFixed(2)}%</span>
|
trend: { label: '\u25B2 +8%', variant: 'success' as const },
|
||||||
<span className={`${styles.kpiTrend} ${errorRate < 1 ? styles.trendDownGood : styles.trendUpBad}`}>
|
subtitle: `${throughputPerSec} msg/s \u00B7 Capacity 39%`,
|
||||||
{errorRate < 1 ? '\u25BC -0.1%' : '\u25B2 +0.4%'}
|
sparkline: [44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47],
|
||||||
</span>
|
borderColor: 'var(--amber)',
|
||||||
</div>
|
},
|
||||||
<div className={styles.kpiDetail}>
|
{
|
||||||
<span className={styles.kpiDetailStrong}>{totalErrors}</span> errors / <span className={styles.kpiDetailStrong}>{totalExchanges.toLocaleString()}</span> total (6h)
|
label: 'System Error Rate',
|
||||||
</div>
|
value: `${errorRate.toFixed(2)}%`,
|
||||||
<div className={styles.kpiSparkline}>
|
trend: {
|
||||||
<Sparkline data={[1.2, 1.8, 1.5, 2.1, 2.4, 2.2, 2.5, 2.6, 2.7, 2.8, 2.7, 2.9, 2.8, errorRate]} color={errorRate < 1 ? 'var(--success)' : 'var(--error)'} width={200} height={32} />
|
label: errorRate < 1 ? '\u25BC -0.1%' : '\u25B2 +0.4%',
|
||||||
</div>
|
variant: errorRate < 1 ? 'success' as const : 'error' as const,
|
||||||
</div>
|
},
|
||||||
|
subtitle: `${totalErrors} errors / ${totalExchanges.toLocaleString()} total (6h)`,
|
||||||
{/* Card 3: Latency Percentiles */}
|
sparkline: [1.2, 1.8, 1.5, 2.1, 2.4, 2.2, 2.5, 2.6, 2.7, 2.8, 2.7, 2.9, 2.8, errorRate],
|
||||||
<div className={`${styles.kpiCard} ${p99Latency > 300 ? styles.kpiCardWarn : styles.kpiCardGreen}`}>
|
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
|
||||||
<div className={styles.kpiLabel}>Latency Percentiles</div>
|
},
|
||||||
<div className={styles.latencyValues}>
|
{
|
||||||
<div className={styles.latencyItem}>
|
label: 'Latency Percentiles',
|
||||||
<span className={styles.latencyLabel}>P50</span>
|
value: `${p99Latency}ms`,
|
||||||
<span className={`${styles.latencyVal} ${styles.latValGreen}`}>{Math.round(avgLatency * 0.5)}ms</span>
|
trend: { label: '\u25B2 +28', variant: p99Latency > 300 ? 'error' as const : 'warning' as const },
|
||||||
<span className={`${styles.latencyTrend} ${styles.trendDownGood}`}>▼3</span>
|
subtitle: `P50 ${p50}ms \u00B7 P95 ${p95}ms \u00B7 SLA <300ms P99: ${slaStatus}`,
|
||||||
</div>
|
borderColor: p99Latency > 300 ? 'var(--warning)' : 'var(--success)',
|
||||||
<div className={styles.latencyItem}>
|
},
|
||||||
<span className={styles.latencyLabel}>P95</span>
|
{
|
||||||
<span className={`${styles.latencyVal} ${avgLatency > 150 ? styles.latValAmber : styles.latValGreen}`}>{Math.round(avgLatency * 1.4)}ms</span>
|
label: 'Active Routes',
|
||||||
<span className={`${styles.latencyTrend} ${styles.trendUpBad}`}>▲12</span>
|
value: `${activeRoutes} / ${totalRoutes}`,
|
||||||
</div>
|
trend: { label: '\u2194 stable', variant: 'muted' as const },
|
||||||
<div className={styles.latencyItem}>
|
subtitle: `${activeRoutes} active \u00B7 ${totalRoutes - activeRoutes} stopped`,
|
||||||
<span className={styles.latencyLabel}>P99</span>
|
borderColor: 'var(--running)',
|
||||||
<span className={`${styles.latencyVal} ${p99Latency > 300 ? styles.latValRed : styles.latValAmber}`}>{p99Latency}ms</span>
|
},
|
||||||
<span className={`${styles.latencyTrend} ${styles.trendUpBad}`}>▲28</span>
|
{
|
||||||
</div>
|
label: 'In-Flight Exchanges',
|
||||||
</div>
|
value: '23',
|
||||||
<div className={styles.kpiDetail}>
|
trend: { label: '\u2194', variant: 'muted' as const },
|
||||||
SLA: <300ms P99 · {p99Latency > 300
|
subtitle: 'High-water: 67 (2h ago)',
|
||||||
? <span style={{ color: 'var(--error)', fontWeight: 600 }}>BREACH</span>
|
sparkline: [16, 14, 18, 12, 10, 15, 8, 6, 4, 3, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 18, 16, 18, 20, 18, 23],
|
||||||
: <span style={{ color: 'var(--success)', fontWeight: 600 }}>OK</span>}
|
borderColor: 'var(--amber)',
|
||||||
</div>
|
},
|
||||||
</div>
|
]
|
||||||
|
|
||||||
{/* Card 4: Active Routes */}
|
|
||||||
<div className={`${styles.kpiCard} ${styles.kpiCardTeal}`}>
|
|
||||||
<div className={styles.kpiLabel}>Active Routes</div>
|
|
||||||
<div className={styles.kpiValueRow}>
|
|
||||||
<span className={`${styles.kpiValue} ${styles.kpiValueTeal}`}>{activeRoutes}</span>
|
|
||||||
<span className={styles.kpiUnit}>of {totalRoutes}</span>
|
|
||||||
<span className={`${styles.kpiTrend} ${styles.trendFlat}`}>↔ stable</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.donutWrap}>
|
|
||||||
<svg viewBox="0 0 36 36" width="40" height="40">
|
|
||||||
<circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--bg-inset)" strokeWidth="3" />
|
|
||||||
<circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--running)" strokeWidth="3"
|
|
||||||
strokeDasharray={`${(activeRoutes / totalRoutes) * 100} ${100 - (activeRoutes / totalRoutes) * 100}`}
|
|
||||||
strokeDashoffset="25" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
<div className={styles.donutLegend}>
|
|
||||||
<span className={styles.donutLegendActive}>{activeRoutes} active</span>
|
|
||||||
<span>{totalRoutes - activeRoutes} stopped</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 5: In-Flight Exchanges */}
|
|
||||||
<div className={`${styles.kpiCard} ${styles.kpiCardAmber}`}>
|
|
||||||
<div className={styles.kpiLabel}>In-Flight Exchanges</div>
|
|
||||||
<div className={styles.kpiValueRow}>
|
|
||||||
<span className={styles.kpiValue}>23</span>
|
|
||||||
<span className={`${styles.kpiTrend} ${styles.trendFlat}`}>↔</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.kpiDetail}>
|
|
||||||
High-water: <span className={styles.kpiDetailStrong}>67</span> (2h ago)
|
|
||||||
</div>
|
|
||||||
<div className={styles.kpiSparkline}>
|
|
||||||
<Sparkline data={[16, 14, 18, 12, 10, 15, 8, 6, 4, 3, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 18, 16, 18, 20, 18, 23]} color="var(--amber)" width={200} height={32} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Route metric row with id field (required by DataTable) ──────────────────
|
// ─── Route metric row with id field (required by DataTable) ──────────────────
|
||||||
@@ -475,7 +422,7 @@ export function Routes() {
|
|||||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiHeader scopedMetrics={scopedMetricsForKpi} />
|
<KpiStrip items={buildKpiItems(scopedMetricsForKpi)} />
|
||||||
|
|
||||||
{/* Processor Performance table */}
|
{/* Processor Performance table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
@@ -520,7 +467,7 @@ export function Routes() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI header cards */}
|
{/* KPI header cards */}
|
||||||
<KpiHeader scopedMetrics={scopedMetricsForKpi} />
|
<KpiStrip items={buildKpiItems(scopedMetricsForKpi)} />
|
||||||
|
|
||||||
{/* Per-route performance table */}
|
{/* Per-route performance table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
@@ -544,8 +491,7 @@ export function Routes() {
|
|||||||
|
|
||||||
{/* 2x2 chart grid */}
|
{/* 2x2 chart grid */}
|
||||||
<div className={styles.chartGrid}>
|
<div className={styles.chartGrid}>
|
||||||
<div className={styles.chartCard}>
|
<Card title="Throughput (msg/s)">
|
||||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
|
||||||
<AreaChart
|
<AreaChart
|
||||||
series={convertSeries(throughputSeries)}
|
series={convertSeries(throughputSeries)}
|
||||||
yLabel="msg/s"
|
yLabel="msg/s"
|
||||||
@@ -553,10 +499,9 @@ export function Routes() {
|
|||||||
width={500}
|
width={500}
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<Card title="Latency (ms)">
|
||||||
<div className={styles.chartTitle}>Latency (ms)</div>
|
|
||||||
<LineChart
|
<LineChart
|
||||||
series={convertSeries(latencySeries)}
|
series={convertSeries(latencySeries)}
|
||||||
yLabel="ms"
|
yLabel="ms"
|
||||||
@@ -565,10 +510,9 @@ export function Routes() {
|
|||||||
width={500}
|
width={500}
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<Card title="Errors by Route">
|
||||||
<div className={styles.chartTitle}>Errors by Route</div>
|
|
||||||
<BarChart
|
<BarChart
|
||||||
series={ERROR_BAR_SERIES}
|
series={ERROR_BAR_SERIES}
|
||||||
stacked
|
stacked
|
||||||
@@ -576,10 +520,9 @@ export function Routes() {
|
|||||||
width={500}
|
width={500}
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<Card title="Message Volume (msg/min)">
|
||||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
|
||||||
<AreaChart
|
<AreaChart
|
||||||
series={VOLUME_SERIES}
|
series={VOLUME_SERIES}
|
||||||
yLabel="msg/min"
|
yLabel="msg/min"
|
||||||
@@ -587,7 +530,7 @@ export function Routes() {
|
|||||||
width={500}
|
width={500}
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
Reference in New Issue
Block a user