docs: add 4 implementation plans for mock deviation cleanup
Plan 1: KpiStrip + StatusText + Card title (metrics) Plan 2: SplitPane + EntityList (admin) Plan 3: LogViewer + AgentHealth DataTable refactor (observability) Plan 4: COMPONENT_GUIDE.md + Inventory updates (documentation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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"
|
||||
```
|
||||
Reference in New Issue
Block a user