574 lines
16 KiB
Markdown
574 lines
16 KiB
Markdown
|
|
# 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"
|
||
|
|
```
|