Files
design-system/docs/superpowers/plans/2026-03-24-admin-components.md

574 lines
16 KiB
Markdown
Raw Normal View History

# 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"
```