10-task plan covering compound component, CSS, exports, tests, LayoutShell, route migration, and page wrapper stripping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1610 lines
54 KiB
Markdown
1610 lines
54 KiB
Markdown
# Composable Sidebar Refactor — 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:** Replace the monolithic `Sidebar` component with a composable compound component (`Sidebar`, `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`), export `SidebarTree` and `useStarred` publicly, and migrate the mock app to a single `LayoutShell` using the new API.
|
||
|
||
**Architecture:** The DS `Sidebar` becomes a composable shell providing frame, search input, and collapse toggle via React context. Application-specific logic (tree building, starred grouping, section ordering) moves into `LayoutShell.tsx` in the mock app. React Router layout routes replace per-page `<AppShell sidebar={...}>` wrappers.
|
||
|
||
**Tech Stack:** React 18, TypeScript, CSS Modules, React Router v6 (layout routes + `<Outlet />`), Vitest + React Testing Library
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-02-composable-sidebar-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Design System (create/modify)
|
||
|
||
| File | Action | Purpose |
|
||
|------|--------|---------|
|
||
| `src/design-system/layout/Sidebar/SidebarContext.ts` | Create | React context for `collapsed` + `onCollapseToggle` |
|
||
| `src/design-system/layout/Sidebar/Sidebar.tsx` | Rewrite | Compound component shell with sub-components |
|
||
| `src/design-system/layout/Sidebar/Sidebar.module.css` | Modify | Remove app-specific styles, add section/footer/collapsed/tooltip styles |
|
||
| `src/design-system/layout/Sidebar/SidebarTree.tsx` | No change | Already data-driven, just newly exported |
|
||
| `src/design-system/layout/Sidebar/useStarred.ts` | No change | Already standalone, just newly exported |
|
||
| `src/design-system/layout/Sidebar/Sidebar.test.tsx` | Rewrite | Tests for compound component API |
|
||
| `src/design-system/layout/index.ts` | Modify | Export `SidebarTree`, `SidebarTreeNode`, `useStarred`; remove `SidebarApp`/`SidebarRoute`/`SidebarAgent` |
|
||
|
||
### Mock App (create/modify)
|
||
|
||
| File | Action | Purpose |
|
||
|------|--------|---------|
|
||
| `src/layout/LayoutShell.tsx` | Create | Single sidebar composition with compound API + `<Outlet />` |
|
||
| `src/App.tsx` | Modify | Layout route wrapping all pages |
|
||
| `src/pages/Dashboard/Dashboard.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper |
|
||
| `src/pages/Routes/Routes.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper (two return sites) |
|
||
| `src/pages/ExchangeDetail/ExchangeDetail.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper (two return sites) |
|
||
| `src/pages/AgentHealth/AgentHealth.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper |
|
||
| `src/pages/AgentInstance/AgentInstance.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper (two return sites) |
|
||
| `src/pages/Admin/Admin.tsx` | Modify | `AdminLayout` drops `<AppShell sidebar={...}>`, becomes content-only |
|
||
| `src/pages/ApiDocs/ApiDocs.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper |
|
||
| `src/pages/AppDetail/AppDetail.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper |
|
||
| `src/pages/Inventory/sections/LayoutSection.tsx` | Modify | Update Sidebar demo to use compound API |
|
||
|
||
---
|
||
|
||
## Task 1: SidebarContext
|
||
|
||
**Files:**
|
||
- Create: `src/design-system/layout/Sidebar/SidebarContext.ts`
|
||
|
||
- [ ] **Step 1: Create the context file**
|
||
|
||
```ts
|
||
// src/design-system/layout/Sidebar/SidebarContext.ts
|
||
import { createContext, useContext } from 'react'
|
||
|
||
export interface SidebarContextValue {
|
||
collapsed: boolean
|
||
onCollapseToggle?: () => void
|
||
}
|
||
|
||
export const SidebarContext = createContext<SidebarContextValue>({
|
||
collapsed: false,
|
||
})
|
||
|
||
export function useSidebarContext(): SidebarContextValue {
|
||
return useContext(SidebarContext)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/design-system/layout/Sidebar/SidebarContext.ts
|
||
git commit -m "feat(sidebar): add SidebarContext for collapsed state"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Rewrite Sidebar as compound component
|
||
|
||
**Files:**
|
||
- Rewrite: `src/design-system/layout/Sidebar/Sidebar.tsx`
|
||
|
||
This replaces the entire file. The old monolithic component (~560 lines) becomes ~150 lines of compound component shell. All application-specific code (type definitions, tree builders, starred logic, hardcoded sections) is deleted.
|
||
|
||
- [ ] **Step 1: Replace Sidebar.tsx with compound component**
|
||
|
||
```tsx
|
||
// src/design-system/layout/Sidebar/Sidebar.tsx
|
||
import { type ReactNode } from 'react'
|
||
import { Search, X, ChevronsLeft, ChevronsRight, ChevronRight, ChevronDown } from 'lucide-react'
|
||
import styles from './Sidebar.module.css'
|
||
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
||
|
||
// ── Sidebar (shell) ─────────────────────────────────────────────────────────
|
||
|
||
interface SidebarProps {
|
||
collapsed?: boolean
|
||
onCollapseToggle?: () => void
|
||
searchValue?: string
|
||
onSearchChange?: (query: string) => void
|
||
children: ReactNode
|
||
className?: string
|
||
}
|
||
|
||
function SidebarRoot({
|
||
collapsed = false,
|
||
onCollapseToggle,
|
||
searchValue = '',
|
||
onSearchChange,
|
||
children,
|
||
className,
|
||
}: SidebarProps) {
|
||
const showSearch = onSearchChange != null && !collapsed
|
||
|
||
return (
|
||
<SidebarContext.Provider value={{ collapsed, onCollapseToggle }}>
|
||
<aside
|
||
className={[
|
||
styles.sidebar,
|
||
collapsed ? styles.sidebarCollapsed : '',
|
||
className ?? '',
|
||
].filter(Boolean).join(' ')}
|
||
>
|
||
{/* Collapse toggle */}
|
||
{onCollapseToggle && (
|
||
<button
|
||
className={styles.collapseToggle}
|
||
onClick={onCollapseToggle}
|
||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||
>
|
||
{collapsed ? <ChevronsRight size={14} /> : <ChevronsLeft size={14} />}
|
||
</button>
|
||
)}
|
||
|
||
{/* Search */}
|
||
{showSearch && (
|
||
<div className={styles.searchWrap}>
|
||
<div className={styles.searchInner}>
|
||
<span className={styles.searchIcon} aria-hidden="true">
|
||
<Search size={12} />
|
||
</span>
|
||
<input
|
||
className={styles.searchInput}
|
||
type="text"
|
||
placeholder="Filter..."
|
||
value={searchValue}
|
||
onChange={(e) => onSearchChange(e.target.value)}
|
||
/>
|
||
{searchValue && (
|
||
<button
|
||
type="button"
|
||
className={styles.searchClear}
|
||
onClick={() => onSearchChange('')}
|
||
aria-label="Clear search"
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Children — Sidebar.Header, Sidebar.Section(s), Sidebar.Footer.
|
||
Sections are wrapped in navArea (scrollable) by convention.
|
||
Footer uses flex-shrink: 0 + margin-top: auto to pin to bottom.
|
||
We render children directly so Footer sits outside the scroll area. */}
|
||
{children}
|
||
</aside>
|
||
</SidebarContext.Provider>
|
||
)
|
||
}
|
||
|
||
// ── Sidebar.Header ────────────────────────────────<E29480><E29480>─────────────────────────
|
||
|
||
interface SidebarHeaderProps {
|
||
logo: ReactNode
|
||
title: string
|
||
version?: string
|
||
onClick?: () => void
|
||
}
|
||
|
||
function SidebarHeader({ logo, title, version, onClick }: SidebarHeaderProps) {
|
||
const { collapsed } = useSidebarContext()
|
||
|
||
return (
|
||
<div
|
||
className={styles.logo}
|
||
onClick={onClick}
|
||
style={onClick ? { cursor: 'pointer' } : undefined}
|
||
>
|
||
<span className={styles.logoImg}>{logo}</span>
|
||
{!collapsed && (
|
||
<div>
|
||
<span className={styles.brand}>{title}</span>
|
||
{version && <span className={styles.version}>{version}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Sidebar.Section ─────────────────────────────────────────────────────────
|
||
|
||
interface SidebarSectionProps {
|
||
label: string
|
||
icon?: ReactNode
|
||
collapsed?: boolean
|
||
onToggle?: () => void
|
||
active?: boolean
|
||
children?: ReactNode
|
||
}
|
||
|
||
function SidebarSection({
|
||
label,
|
||
icon,
|
||
collapsed: sectionCollapsed = false,
|
||
onToggle,
|
||
active = false,
|
||
children,
|
||
}: SidebarSectionProps) {
|
||
const { collapsed: sidebarCollapsed, onCollapseToggle } = useSidebarContext()
|
||
|
||
// Icon-rail mode: render centered icon with tooltip
|
||
if (sidebarCollapsed) {
|
||
return (
|
||
<div
|
||
className={[styles.sectionRailItem, active ? styles.sectionRailItemActive : ''].filter(Boolean).join(' ')}
|
||
onClick={() => {
|
||
onCollapseToggle?.()
|
||
onToggle?.()
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
onCollapseToggle?.()
|
||
onToggle?.()
|
||
}
|
||
}}
|
||
title={label}
|
||
>
|
||
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Expanded mode: accordion section
|
||
return (
|
||
<div className={[styles.treeSection, active ? styles.treeSectionActive : ''].filter(Boolean).join(' ')}>
|
||
<div className={styles.treeSectionToggle}>
|
||
{onToggle && (
|
||
<button
|
||
className={styles.treeSectionChevronBtn}
|
||
onClick={onToggle}
|
||
aria-expanded={!sectionCollapsed}
|
||
aria-label={sectionCollapsed ? `Expand ${label}` : `Collapse ${label}`}
|
||
>
|
||
{sectionCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||
</button>
|
||
)}
|
||
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
||
<span
|
||
className={[styles.treeSectionLabel, active ? styles.treeSectionLabelActive : ''].filter(Boolean).join(' ')}
|
||
onClick={onToggle}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onToggle?.() }}
|
||
>
|
||
{label}
|
||
</span>
|
||
</div>
|
||
{!sectionCollapsed && children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Sidebar.Footer ──────────────────────────────────────────────────────────
|
||
|
||
interface SidebarFooterProps {
|
||
children: ReactNode
|
||
}
|
||
|
||
function SidebarFooter({ children }: SidebarFooterProps) {
|
||
return <div className={styles.bottom} role="group" aria-label="Footer links">{children}</div>
|
||
}
|
||
|
||
// ── Sidebar.FooterLink ──────────────────────────────────────────────────────
|
||
|
||
interface SidebarFooterLinkProps {
|
||
icon: ReactNode
|
||
label: string
|
||
onClick?: () => void
|
||
active?: boolean
|
||
}
|
||
|
||
function SidebarFooterLink({ icon, label, onClick, active = false }: SidebarFooterLinkProps) {
|
||
const { collapsed } = useSidebarContext()
|
||
|
||
return (
|
||
<div
|
||
className={[
|
||
styles.bottomItem,
|
||
active ? styles.bottomItemActive : '',
|
||
].filter(Boolean).join(' ')}
|
||
onClick={onClick}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick?.() }}
|
||
title={collapsed ? label : undefined}
|
||
>
|
||
<span className={styles.bottomIcon}>{icon}</span>
|
||
{!collapsed && (
|
||
<div className={styles.itemInfo}>
|
||
<div className={styles.itemName}>{label}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Compound export ───────────────────────────────────────────────────────<E29480><E29480><EFBFBD>─
|
||
|
||
export const Sidebar = Object.assign(SidebarRoot, {
|
||
Header: SidebarHeader,
|
||
Section: SidebarSection,
|
||
Footer: SidebarFooter,
|
||
FooterLink: SidebarFooterLink,
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Verify TypeScript compiles**
|
||
|
||
Run: `npx tsc --noEmit 2>&1 | head -30`
|
||
|
||
This will have errors because consumers still use the old API. That's expected — we fix them in later tasks. The Sidebar file itself should be error-free.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/design-system/layout/Sidebar/Sidebar.tsx
|
||
git commit -m "feat(sidebar): rewrite as compound component
|
||
|
||
Replaces monolithic Sidebar with composable API:
|
||
Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer,
|
||
Sidebar.FooterLink. Application-specific logic removed."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Update Sidebar.module.css
|
||
|
||
**Files:**
|
||
- Modify: `src/design-system/layout/Sidebar/Sidebar.module.css`
|
||
|
||
Remove application-specific styles (starred section, hardcoded section headers). Add collapsed/icon-rail styles, section icon slot, and tooltip support. Keep search, tree, and bottom link styles.
|
||
|
||
- [ ] **Step 1: Replace the CSS file contents**
|
||
|
||
The file keeps: `.sidebar`, `.logo*`, `.brand`, `.version`, `.searchWrap`, `.searchInput`, `.searchIcon`, `.searchClear`, all `.tree*` styles, `.bottom`, `.bottomItem`, `.bottomItemActive`, `.bottomIcon`, `.itemInfo`, `.itemName`, `.noResults`.
|
||
|
||
Remove `.navArea` (no longer used — children render directly in the sidebar flex column).
|
||
|
||
Delete: `.section` (old header), `.starredSection`, `.starredHeader`, `.starredList`, `.starredGroup`, `.starredGroupLabel`, `.starredItem`, `.starredItemInfo`, `.starredItemName`, `.starredItemContext`, `.starredRemove`, `.items`, `.item`, `.item.active`, `.navIcon`, `.routeArrow`.
|
||
|
||
Add the following new styles **after the existing `.navArea` block** (around line 114):
|
||
|
||
```css
|
||
/* ── Collapsed sidebar (icon rail) ──────────────────────────<E29480><E29480><EFBFBD>───────────── */
|
||
|
||
.sidebarCollapsed {
|
||
width: 48px;
|
||
}
|
||
|
||
.sidebar {
|
||
transition: width 200ms ease;
|
||
}
|
||
|
||
/* Collapse toggle button */
|
||
.collapseToggle {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 8px;
|
||
z-index: 1;
|
||
background: none;
|
||
border: none;
|
||
padding: 4px;
|
||
margin: 0;
|
||
color: var(--sidebar-muted);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: var(--radius-sm);
|
||
transition: color 0.12s, background 0.12s;
|
||
}
|
||
|
||
.collapseToggle:hover {
|
||
color: var(--sidebar-text);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
/* ── Section icon (in expanded header and rail mode) ─────────────────<E29480><E29480>──── */
|
||
|
||
.sectionIcon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
color: var(--sidebar-muted);
|
||
width: 16px;
|
||
}
|
||
|
||
/* ── Icon-rail section item ───────────────────────────<E29480><E29480><EFBFBD>─────────────────── */
|
||
|
||
.sectionRailItem {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 10px 0;
|
||
cursor: pointer;
|
||
border-left: 3px solid transparent;
|
||
transition: background 0.12s;
|
||
}
|
||
|
||
.sectionRailItem:hover {
|
||
background: var(--sidebar-hover);
|
||
}
|
||
|
||
.sectionRailItemActive {
|
||
border-left-color: var(--amber);
|
||
}
|
||
|
||
.sectionRailItemActive .sectionIcon {
|
||
color: var(--amber);
|
||
}
|
||
|
||
/* ── Active section (expanded mode) ────────────────────────<E29480><E29480><EFBFBD>────────────── */
|
||
|
||
.treeSectionActive {
|
||
border-left-color: var(--amber);
|
||
}
|
||
```
|
||
|
||
Also modify the existing `.sidebar` rule to add `position: relative` and `overflow-y` (children are rendered directly in the flex column; sections scroll naturally):
|
||
|
||
Change line 1-8 from:
|
||
```css
|
||
.sidebar {
|
||
width: 260px;
|
||
flex-shrink: 0;
|
||
background: var(--sidebar-bg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
```
|
||
to:
|
||
```css
|
||
.sidebar {
|
||
width: 260px;
|
||
flex-shrink: 0;
|
||
background: var(--sidebar-bg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
overflow-y: auto;
|
||
position: relative;
|
||
transition: width 200ms ease;
|
||
}
|
||
```
|
||
|
||
Update `.bottom` to pin to the bottom of the sidebar:
|
||
```css
|
||
.bottom {
|
||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||
padding: 6px;
|
||
flex-shrink: 0;
|
||
margin-top: auto;
|
||
}
|
||
```
|
||
|
||
And modify `.logo` to handle collapsed centering — change padding from `padding: 16px 18px;` to:
|
||
```css
|
||
.logo {
|
||
padding: 16px 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebarCollapsed .logo {
|
||
padding: 16px 0;
|
||
justify-content: center;
|
||
}
|
||
```
|
||
|
||
Remove the following class blocks entirely (starred section, old flat items, old section header):
|
||
- `.section` (lines 117-124)
|
||
- `.items` (lines 127-129)
|
||
- `.item` through `.routeArrow` (lines 132-174)
|
||
- `.starredSection` through `.starredRemove:hover` (lines 386-472)
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/design-system/layout/Sidebar/Sidebar.module.css
|
||
git commit -m "style(sidebar): update CSS for compound component + collapsed mode"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Update layout barrel exports
|
||
|
||
**Files:**
|
||
- Modify: `src/design-system/layout/index.ts`
|
||
|
||
- [ ] **Step 1: Replace the layout index**
|
||
|
||
```ts
|
||
// src/design-system/layout/index.ts
|
||
export { AppShell } from './AppShell/AppShell'
|
||
export { Sidebar } from './Sidebar/Sidebar'
|
||
export { SidebarTree } from './Sidebar/SidebarTree'
|
||
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||
export { useStarred } from './Sidebar/useStarred'
|
||
export { TopBar } from './TopBar/TopBar'
|
||
```
|
||
|
||
Note: `SidebarApp`, `SidebarRoute`, `SidebarAgent` type exports are intentionally removed — they are application-domain types.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/design-system/layout/index.ts
|
||
git commit -m "feat(sidebar): export SidebarTree, SidebarTreeNode, useStarred from layout barrel"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Rewrite Sidebar tests
|
||
|
||
**Files:**
|
||
- Rewrite: `src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||
|
||
- [ ] **Step 1: Replace test file with compound component tests**
|
||
|
||
```tsx
|
||
// src/design-system/layout/Sidebar/Sidebar.test.tsx
|
||
import { describe, it, expect, vi } from 'vitest'
|
||
import { render, screen } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
import { MemoryRouter } from 'react-router-dom'
|
||
import { Sidebar } from './Sidebar'
|
||
import { ThemeProvider } from '../../providers/ThemeProvider'
|
||
|
||
function renderSidebar(ui: React.ReactElement) {
|
||
return render(
|
||
<ThemeProvider>
|
||
<MemoryRouter>
|
||
{ui}
|
||
</MemoryRouter>
|
||
</ThemeProvider>,
|
||
)
|
||
}
|
||
|
||
describe('Sidebar compound component', () => {
|
||
it('renders Header with logo, title, and version', () => {
|
||
renderSidebar(
|
||
<Sidebar>
|
||
<Sidebar.Header logo={<span data-testid="logo">🐪</span>} title="cameleer" version="v1.0" />
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.getByTestId('logo')).toBeInTheDocument()
|
||
expect(screen.getByText('cameleer')).toBeInTheDocument()
|
||
expect(screen.getByText('v1.0')).toBeInTheDocument()
|
||
})
|
||
|
||
it('hides Header title and version when collapsed', () => {
|
||
renderSidebar(
|
||
<Sidebar collapsed>
|
||
<Sidebar.Header logo={<span data-testid="logo">🐪</span>} title="cameleer" version="v1.0" />
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.getByTestId('logo')).toBeInTheDocument()
|
||
expect(screen.queryByText('cameleer')).not.toBeInTheDocument()
|
||
expect(screen.queryByText('v1.0')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('renders Section with label', () => {
|
||
renderSidebar(
|
||
<Sidebar>
|
||
<Sidebar.Section label="Applications">
|
||
<div>section content</div>
|
||
</Sidebar.Section>
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.getByText('Applications')).toBeInTheDocument()
|
||
expect(screen.getByText('section content')).toBeInTheDocument()
|
||
})
|
||
|
||
it('hides Section children when section is collapsed', () => {
|
||
renderSidebar(
|
||
<Sidebar>
|
||
<Sidebar.Section label="Applications" collapsed>
|
||
<div>hidden content</div>
|
||
</Sidebar.Section>
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.getByText('Applications')).toBeInTheDocument()
|
||
expect(screen.queryByText('hidden content')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('calls onToggle when Section header is clicked', async () => {
|
||
const onToggle = vi.fn()
|
||
const user = userEvent.setup()
|
||
renderSidebar(
|
||
<Sidebar>
|
||
<Sidebar.Section label="Agents" onToggle={onToggle}>
|
||
<div>content</div>
|
||
</Sidebar.Section>
|
||
</Sidebar>,
|
||
)
|
||
await user.click(screen.getByText('Agents'))
|
||
expect(onToggle).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('renders collapse toggle and calls onCollapseToggle', async () => {
|
||
const onCollapseToggle = vi.fn()
|
||
const user = userEvent.setup()
|
||
renderSidebar(
|
||
<Sidebar onCollapseToggle={onCollapseToggle}>
|
||
<Sidebar.Header logo={<span>🐪</span>} title="test" />
|
||
</Sidebar>,
|
||
)
|
||
const toggle = screen.getByLabelText('Collapse sidebar')
|
||
await user.click(toggle)
|
||
expect(onCollapseToggle).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('renders expand toggle label when collapsed', () => {
|
||
renderSidebar(
|
||
<Sidebar collapsed onCollapseToggle={() => {}}>
|
||
<Sidebar.Header logo={<span>🐪</span>} title="test" />
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.getByLabelText('Expand sidebar')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders search input and calls onSearchChange', async () => {
|
||
const onSearchChange = vi.fn()
|
||
const user = userEvent.setup()
|
||
renderSidebar(
|
||
<Sidebar searchValue="" onSearchChange={onSearchChange}>
|
||
<Sidebar.Header logo={<span>🐪</span>} title="test" />
|
||
</Sidebar>,
|
||
)
|
||
const input = screen.getByPlaceholderText('Filter...')
|
||
await user.type(input, 'a')
|
||
expect(onSearchChange).toHaveBeenCalledWith('a')
|
||
})
|
||
|
||
it('hides search input when sidebar is collapsed', () => {
|
||
renderSidebar(
|
||
<Sidebar collapsed searchValue="" onSearchChange={() => {}}>
|
||
<Sidebar.Header logo={<span>🐪</span>} title="test" />
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('hides search when onSearchChange is not provided', () => {
|
||
renderSidebar(
|
||
<Sidebar>
|
||
<Sidebar.Header logo={<span>🐪</span>} title="test" />
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('renders FooterLinks with icons and labels', () => {
|
||
renderSidebar(
|
||
<Sidebar>
|
||
<Sidebar.Footer>
|
||
<Sidebar.FooterLink icon={<span data-testid="admin-icon">⚙</span>} label="Admin" />
|
||
<Sidebar.FooterLink icon={<span data-testid="docs-icon">📄</span>} label="API Docs" />
|
||
</Sidebar.Footer>
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.getByText('Admin')).toBeInTheDocument()
|
||
expect(screen.getByText('API Docs')).toBeInTheDocument()
|
||
expect(screen.getByTestId('admin-icon')).toBeInTheDocument()
|
||
})
|
||
|
||
it('hides FooterLink labels when collapsed and sets title', () => {
|
||
renderSidebar(
|
||
<Sidebar collapsed>
|
||
<Sidebar.Footer>
|
||
<Sidebar.FooterLink icon={<span data-testid="icon">⚙</span>} label="Admin" />
|
||
</Sidebar.Footer>
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
||
expect(screen.getByTitle('Admin')).toBeInTheDocument()
|
||
})
|
||
|
||
it('calls FooterLink onClick', async () => {
|
||
const onClick = vi.fn()
|
||
const user = userEvent.setup()
|
||
renderSidebar(
|
||
<Sidebar>
|
||
<Sidebar.Footer>
|
||
<Sidebar.FooterLink icon={<span>⚙</span>} label="Admin" onClick={onClick} />
|
||
</Sidebar.Footer>
|
||
</Sidebar>,
|
||
)
|
||
await user.click(screen.getByText('Admin'))
|
||
expect(onClick).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('renders Section as icon-rail item when sidebar is collapsed', () => {
|
||
renderSidebar(
|
||
<Sidebar collapsed>
|
||
<Sidebar.Section label="Applications" icon={<span data-testid="apps-icon">📦</span>} />
|
||
</Sidebar>,
|
||
)
|
||
expect(screen.getByTestId('apps-icon')).toBeInTheDocument()
|
||
expect(screen.getByTitle('Applications')).toBeInTheDocument()
|
||
// Label text should NOT be rendered as visible text in rail mode
|
||
expect(screen.queryByText('Applications')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
|
||
const onCollapseToggle = vi.fn()
|
||
const onToggle = vi.fn()
|
||
const user = userEvent.setup()
|
||
renderSidebar(
|
||
<Sidebar collapsed onCollapseToggle={onCollapseToggle}>
|
||
<Sidebar.Section
|
||
label="Applications"
|
||
icon={<span data-testid="apps-icon">📦</span>}
|
||
collapsed
|
||
onToggle={onToggle}
|
||
/>
|
||
</Sidebar>,
|
||
)
|
||
await user.click(screen.getByTitle('Applications'))
|
||
expect(onCollapseToggle).toHaveBeenCalledOnce()
|
||
expect(onToggle).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('applies active highlight to FooterLink', () => {
|
||
renderSidebar(
|
||
<Sidebar>
|
||
<Sidebar.Footer>
|
||
<Sidebar.FooterLink icon={<span>⚙</span>} label="Admin" active />
|
||
</Sidebar.Footer>
|
||
</Sidebar>,
|
||
)
|
||
const adminEl = screen.getByText('Admin').closest('[role="button"]')!
|
||
expect(adminEl.className).toContain('bottomItemActive')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they pass**
|
||
|
||
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||
|
||
Expected: All tests pass. If any fail, fix the component or test to match.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/design-system/layout/Sidebar/Sidebar.test.tsx
|
||
git commit -m "test(sidebar): rewrite tests for compound component API"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Create LayoutShell
|
||
|
||
**Files:**
|
||
- Create: `src/layout/LayoutShell.tsx`
|
||
|
||
This is the central migration piece. All application-specific sidebar logic (tree building, starred section, section collapse persistence, sidebarReveal) moves here from the old `Sidebar.tsx`.
|
||
|
||
- [ ] **Step 1: Create the LayoutShell file**
|
||
|
||
```tsx
|
||
// src/layout/LayoutShell.tsx
|
||
import { useState, useEffect, useMemo } from 'react'
|
||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, X } from 'lucide-react'
|
||
import { AppShell } from '../design-system/layout/AppShell/AppShell'
|
||
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
|
||
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
|
||
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
|
||
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
|
||
import { StatusDot } from '../design-system/primitives/StatusDot/StatusDot'
|
||
import { SIDEBAR_APPS } from '../mocks/sidebar'
|
||
import type { SidebarApp } from '../mocks/sidebar'
|
||
import camelLogoUrl from '../assets/camel-logo.svg'
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function formatCount(n: number): string {
|
||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||
return String(n)
|
||
}
|
||
|
||
// ── Tree node builders ──────────────────────────────────────────────────────
|
||
|
||
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||
return apps.map((app) => ({
|
||
id: `app:${app.id}`,
|
||
label: app.name,
|
||
icon: <StatusDot variant={app.health} />,
|
||
badge: formatCount(app.exchangeCount),
|
||
path: `/apps/${app.id}`,
|
||
starrable: true,
|
||
starKey: app.id,
|
||
children: app.routes.map((route) => ({
|
||
id: `route:${app.id}:${route.id}`,
|
||
starKey: `${app.id}:${route.id}`,
|
||
label: route.name,
|
||
icon: <ChevronRight size={12} />,
|
||
badge: formatCount(route.exchangeCount),
|
||
path: `/apps/${app.id}/${route.id}`,
|
||
starrable: true,
|
||
})),
|
||
}))
|
||
}
|
||
|
||
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||
return apps
|
||
.filter((app) => app.routes.length > 0)
|
||
.map((app) => ({
|
||
id: `routes:${app.id}`,
|
||
label: app.name,
|
||
icon: <StatusDot variant={app.health} />,
|
||
badge: `${app.routes.length} routes`,
|
||
path: `/routes/${app.id}`,
|
||
starrable: true,
|
||
starKey: `routes:${app.id}`,
|
||
children: app.routes.map((route) => ({
|
||
id: `routestat:${app.id}:${route.id}`,
|
||
starKey: `routes:${app.id}:${route.id}`,
|
||
label: route.name,
|
||
icon: <ChevronRight size={12} />,
|
||
badge: formatCount(route.exchangeCount),
|
||
path: `/routes/${app.id}/${route.id}`,
|
||
starrable: true,
|
||
})),
|
||
}))
|
||
}
|
||
|
||
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||
return apps
|
||
.filter((app) => app.agents.length > 0)
|
||
.map((app) => {
|
||
const liveCount = app.agents.filter((a) => a.status === 'live').length
|
||
return {
|
||
id: `agents:${app.id}`,
|
||
label: app.name,
|
||
icon: <StatusDot variant={app.health} />,
|
||
badge: `${liveCount}/${app.agents.length} live`,
|
||
path: `/agents/${app.id}`,
|
||
starrable: true,
|
||
starKey: `agents:${app.id}`,
|
||
children: app.agents.map((agent) => ({
|
||
id: `agent:${app.id}:${agent.id}`,
|
||
starKey: `${app.id}:${agent.id}`,
|
||
label: agent.name,
|
||
badge: `${agent.tps.toFixed(1)}/s`,
|
||
path: `/agents/${app.id}/${agent.id}`,
|
||
starrable: true,
|
||
})),
|
||
}
|
||
})
|
||
}
|
||
|
||
// ── Starred section ────────────────────────────────<E29480><E29480>────────────────────────
|
||
|
||
interface StarredItem {
|
||
starKey: string
|
||
label: string
|
||
icon?: React.ReactNode
|
||
path: string
|
||
type: 'application' | 'route' | 'agent' | 'routestat'
|
||
parentApp?: string
|
||
}
|
||
|
||
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
|
||
const items: StarredItem[] = []
|
||
for (const app of apps) {
|
||
if (starredIds.has(app.id)) {
|
||
items.push({ starKey: app.id, label: app.name, icon: <StatusDot variant={app.health} />, path: `/apps/${app.id}`, type: 'application' })
|
||
}
|
||
for (const route of app.routes) {
|
||
const key = `${app.id}:${route.id}`
|
||
if (starredIds.has(key)) {
|
||
items.push({ starKey: key, label: route.name, path: `/apps/${app.id}/${route.id}`, type: 'route', parentApp: app.name })
|
||
}
|
||
}
|
||
const agentsAppKey = `agents:${app.id}`
|
||
if (starredIds.has(agentsAppKey)) {
|
||
items.push({ starKey: agentsAppKey, label: app.name, icon: <StatusDot variant={app.health} />, path: `/agents/${app.id}`, type: 'agent' })
|
||
}
|
||
for (const agent of app.agents) {
|
||
const key = `${app.id}:${agent.id}`
|
||
if (starredIds.has(key)) {
|
||
items.push({ starKey: key, label: agent.name, path: `/agents/${app.id}/${agent.id}`, type: 'agent', parentApp: app.name })
|
||
}
|
||
}
|
||
const routesAppKey = `routes:${app.id}`
|
||
if (starredIds.has(routesAppKey)) {
|
||
items.push({ starKey: routesAppKey, label: app.name, icon: <StatusDot variant={app.health} />, path: `/routes/${app.id}`, type: 'routestat' })
|
||
}
|
||
for (const route of app.routes) {
|
||
const routeKey = `routes:${app.id}:${route.id}`
|
||
if (starredIds.has(routeKey)) {
|
||
items.push({ starKey: routeKey, label: route.name, path: `/routes/${app.id}/${route.id}`, type: 'routestat', parentApp: app.name })
|
||
}
|
||
}
|
||
}
|
||
return items
|
||
}
|
||
|
||
// ── StarredGroup sub-component ──────────────────────────────────────────────
|
||
|
||
function StarredGroup({ label, items, onNavigate, onRemove }: {
|
||
label: string
|
||
items: StarredItem[]
|
||
onNavigate: (path: string) => void
|
||
onRemove: (starKey: string) => void
|
||
}) {
|
||
return (
|
||
<div style={{ marginBottom: 4 }}>
|
||
<div style={{ padding: '4px 12px 2px', fontSize: 10, color: 'var(--sidebar-muted)', fontWeight: 500 }}>{label}</div>
|
||
{items.map((item) => (
|
||
<div
|
||
key={item.starKey}
|
||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 12px', borderRadius: 'var(--radius-sm)', color: 'var(--sidebar-text)', fontSize: 12, cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => onNavigate(item.path)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
|
||
>
|
||
{item.icon}
|
||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: 500 }}>{item.label}</span>
|
||
{item.parentApp && <span style={{ fontSize: 10, color: 'var(--sidebar-muted)' }}>{item.parentApp}</span>}
|
||
</div>
|
||
<button
|
||
style={{ background: 'none', border: 'none', padding: 2, color: 'var(--sidebar-muted)', cursor: 'pointer', display: 'flex' }}
|
||
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
|
||
tabIndex={-1}
|
||
aria-label={`Remove ${item.label} from starred`}
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── LayoutShell ─────────────────────────────────────────────────────────────
|
||
|
||
export function LayoutShell() {
|
||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||
const [filterQuery, setFilterQuery] = useState('')
|
||
|
||
// Section collapse state with localStorage persistence
|
||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
|
||
|
||
const setAppsCollapsed = (fn: (v: boolean) => boolean) => {
|
||
_setAppsCollapsed((prev) => {
|
||
const next = fn(prev)
|
||
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
|
||
return next
|
||
})
|
||
}
|
||
const setAgentsCollapsed = (fn: (v: boolean) => boolean) => {
|
||
_setAgentsCollapsed((prev) => {
|
||
const next = fn(prev)
|
||
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
|
||
return next
|
||
})
|
||
}
|
||
const setRoutesCollapsed = (fn: (v: boolean) => boolean) => {
|
||
_setRoutesCollapsed((prev) => {
|
||
const next = fn(prev)
|
||
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
|
||
return next
|
||
})
|
||
}
|
||
|
||
const navigate = useNavigate()
|
||
const location = useLocation()
|
||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||
|
||
// Build tree data
|
||
const appNodes = useMemo(() => buildAppTreeNodes(SIDEBAR_APPS), [])
|
||
const agentNodes = useMemo(() => buildAgentTreeNodes(SIDEBAR_APPS), [])
|
||
const routeNodes = useMemo(() => buildRouteTreeNodes(SIDEBAR_APPS), [])
|
||
|
||
// Sidebar reveal from Cmd-K navigation
|
||
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||
|
||
useEffect(() => {
|
||
if (!sidebarRevealPath) return
|
||
const matchesAppTree = appNodes.some((node) =>
|
||
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||
)
|
||
if (matchesAppTree && appsCollapsed) {
|
||
_setAppsCollapsed(false)
|
||
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
|
||
}
|
||
const matchesAgentTree = agentNodes.some((node) =>
|
||
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||
)
|
||
if (matchesAgentTree && agentsCollapsed) {
|
||
_setAgentsCollapsed(false)
|
||
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
|
||
}
|
||
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname
|
||
|
||
// Starred items
|
||
const starredItems = useMemo(() => collectStarredItems(SIDEBAR_APPS, starredIds), [starredIds])
|
||
const starredApps = starredItems.filter((i) => i.type === 'application')
|
||
const starredRoutes = starredItems.filter((i) => i.type === 'route')
|
||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
|
||
const hasStarred = starredItems.length > 0
|
||
|
||
return (
|
||
<AppShell
|
||
sidebar={
|
||
<Sidebar
|
||
collapsed={sidebarCollapsed}
|
||
onCollapseToggle={() => setSidebarCollapsed((v) => !v)}
|
||
searchValue={filterQuery}
|
||
onSearchChange={setFilterQuery}
|
||
>
|
||
<Sidebar.Header
|
||
logo={<img src={camelLogoUrl} alt="" aria-hidden="true" style={{ width: 28, height: 24, filter: 'brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%)' }} />}
|
||
title="cameleer"
|
||
version="v3.2.1"
|
||
onClick={() => navigate('/apps')}
|
||
/>
|
||
|
||
<Sidebar.Section
|
||
label="Applications"
|
||
icon={<Box size={14} />}
|
||
collapsed={appsCollapsed}
|
||
onToggle={() => setAppsCollapsed((v) => !v)}
|
||
active={location.pathname.startsWith('/apps')}
|
||
>
|
||
<SidebarTree
|
||
nodes={appNodes}
|
||
selectedPath={effectiveSelectedPath}
|
||
isStarred={isStarred}
|
||
onToggleStar={toggleStar}
|
||
filterQuery={filterQuery}
|
||
persistKey="cameleer:expanded:apps"
|
||
autoRevealPath={sidebarRevealPath}
|
||
/>
|
||
</Sidebar.Section>
|
||
|
||
<Sidebar.Section
|
||
label="Agents"
|
||
icon={<Cpu size={14} />}
|
||
collapsed={agentsCollapsed}
|
||
onToggle={() => setAgentsCollapsed((v) => !v)}
|
||
active={location.pathname.startsWith('/agents')}
|
||
>
|
||
<SidebarTree
|
||
nodes={agentNodes}
|
||
selectedPath={effectiveSelectedPath}
|
||
isStarred={isStarred}
|
||
onToggleStar={toggleStar}
|
||
filterQuery={filterQuery}
|
||
persistKey="cameleer:expanded:agents"
|
||
autoRevealPath={sidebarRevealPath}
|
||
/>
|
||
</Sidebar.Section>
|
||
|
||
<Sidebar.Section
|
||
label="Routes"
|
||
icon={<GitBranch size={14} />}
|
||
collapsed={routesCollapsed}
|
||
onToggle={() => setRoutesCollapsed((v) => !v)}
|
||
active={location.pathname.startsWith('/routes')}
|
||
>
|
||
<SidebarTree
|
||
nodes={routeNodes}
|
||
selectedPath={effectiveSelectedPath}
|
||
isStarred={isStarred}
|
||
onToggleStar={toggleStar}
|
||
filterQuery={filterQuery}
|
||
persistKey="cameleer:expanded:routes"
|
||
autoRevealPath={sidebarRevealPath}
|
||
/>
|
||
</Sidebar.Section>
|
||
|
||
{/* Starred section */}
|
||
{hasStarred && (
|
||
<Sidebar.Section label="★ Starred" collapsed={false}>
|
||
{starredApps.length > 0 && <StarredGroup label="Applications" items={starredApps} onNavigate={navigate} onRemove={toggleStar} />}
|
||
{starredRoutes.length > 0 && <StarredGroup label="Routes" items={starredRoutes} onNavigate={navigate} onRemove={toggleStar} />}
|
||
{starredAgents.length > 0 && <StarredGroup label="Agents" items={starredAgents} onNavigate={navigate} onRemove={toggleStar} />}
|
||
{starredRouteStats.length > 0 && <StarredGroup label="Routes" items={starredRouteStats} onNavigate={navigate} onRemove={toggleStar} />}
|
||
</Sidebar.Section>
|
||
)}
|
||
|
||
<Sidebar.Footer>
|
||
<Sidebar.FooterLink
|
||
icon={<Settings size={14} />}
|
||
label="Admin"
|
||
onClick={() => navigate('/admin')}
|
||
active={location.pathname.startsWith('/admin')}
|
||
/>
|
||
<Sidebar.FooterLink
|
||
icon={<FileText size={14} />}
|
||
label="API Docs"
|
||
onClick={() => navigate('/api-docs')}
|
||
active={location.pathname === '/api-docs'}
|
||
/>
|
||
</Sidebar.Footer>
|
||
</Sidebar>
|
||
}
|
||
>
|
||
<Outlet />
|
||
</AppShell>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/layout/LayoutShell.tsx
|
||
git commit -m "feat: create LayoutShell with compound Sidebar and Outlet"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Update App.tsx to use layout routes
|
||
|
||
**Files:**
|
||
- Modify: `src/App.tsx`
|
||
|
||
- [ ] **Step 1: Replace App.tsx with layout route structure**
|
||
|
||
```tsx
|
||
// src/App.tsx
|
||
import { useMemo, useCallback } from 'react'
|
||
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||
import { LayoutShell } from './layout/LayoutShell'
|
||
import { Dashboard } from './pages/Dashboard/Dashboard'
|
||
import { Routes as RoutesPage } from './pages/Routes/Routes'
|
||
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
|
||
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
|
||
import { AgentInstance } from './pages/AgentInstance/AgentInstance'
|
||
import { Inventory } from './pages/Inventory/Inventory'
|
||
import { AuditLog } from './pages/Admin/AuditLog/AuditLog'
|
||
import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig'
|
||
import { UserManagement } from './pages/Admin/UserManagement/UserManagement'
|
||
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
|
||
|
||
import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette'
|
||
import type { SearchResult } from './design-system/composites/CommandPalette/types'
|
||
import { useCommandPalette } from './design-system/providers/CommandPaletteProvider'
|
||
import { useGlobalFilters } from './design-system/providers/GlobalFilterProvider'
|
||
import { buildSearchData } from './mocks/searchData'
|
||
import { exchanges } from './mocks/exchanges'
|
||
import { routes } from './mocks/routes'
|
||
import { agents } from './mocks/agents'
|
||
import { buildRouteToAppMap } from './mocks/sidebar'
|
||
|
||
const routeToApp = buildRouteToAppMap()
|
||
|
||
/** Compute which sidebar path to reveal for a given search result */
|
||
function computeSidebarRevealPath(result: SearchResult): string | undefined {
|
||
if (!result.path) return undefined
|
||
|
||
if (result.category === 'application') return result.path
|
||
if (result.category === 'route') return result.path
|
||
if (result.category === 'agent') return result.path
|
||
|
||
if (result.category === 'exchange') {
|
||
const exchange = exchanges.find((e) => e.id === result.id)
|
||
if (exchange) {
|
||
const appId = routeToApp.get(exchange.route)
|
||
if (appId) return `/apps/${appId}/${exchange.route}`
|
||
}
|
||
}
|
||
|
||
return result.path
|
||
}
|
||
|
||
export default function App() {
|
||
const navigate = useNavigate()
|
||
const { open: paletteOpen, setOpen } = useCommandPalette()
|
||
const { isInTimeRange, statusFilters } = useGlobalFilters()
|
||
|
||
const filteredSearchData = useMemo(() => {
|
||
let filteredExchanges = exchanges.filter((e) => isInTimeRange(e.timestamp))
|
||
if (statusFilters.size > 0) {
|
||
filteredExchanges = filteredExchanges.filter((e) => statusFilters.has(e.status))
|
||
}
|
||
return buildSearchData(filteredExchanges, routes, agents)
|
||
}, [isInTimeRange, statusFilters])
|
||
|
||
const handleSelect = useCallback(
|
||
(result: SearchResult) => {
|
||
if (result.path) {
|
||
const sidebarReveal = computeSidebarRevealPath(result)
|
||
navigate(result.path, { state: sidebarReveal ? { sidebarReveal } : undefined })
|
||
}
|
||
setOpen(false)
|
||
},
|
||
[navigate, setOpen],
|
||
)
|
||
|
||
return (
|
||
<>
|
||
<Routes>
|
||
<Route element={<LayoutShell />}>
|
||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||
<Route path="/apps" element={<Dashboard />} />
|
||
<Route path="/apps/:id" element={<Dashboard />} />
|
||
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
|
||
<Route path="/routes" element={<RoutesPage />} />
|
||
<Route path="/routes/:appId" element={<RoutesPage />} />
|
||
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
|
||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
|
||
<Route path="/agents/*" element={<AgentHealth />} />
|
||
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||
<Route path="/admin/audit" element={<AuditLog />} />
|
||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||
<Route path="/admin/rbac" element={<UserManagement />} />
|
||
<Route path="/api-docs" element={<ApiDocs />} />
|
||
</Route>
|
||
<Route path="/inventory" element={<Inventory />} />
|
||
</Routes>
|
||
<CommandPalette
|
||
open={paletteOpen}
|
||
onClose={() => setOpen(false)}
|
||
onOpen={() => setOpen(true)}
|
||
data={filteredSearchData}
|
||
onSelect={handleSelect}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
```
|
||
|
||
Note: The Inventory route stays **outside** the `LayoutShell` because it has its own layout and renders a standalone Sidebar demo.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/App.tsx
|
||
git commit -m "refactor: wrap routes in LayoutShell layout route"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Strip AppShell+Sidebar wrappers from page components
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Dashboard/Dashboard.tsx`
|
||
- Modify: `src/pages/Routes/Routes.tsx`
|
||
- Modify: `src/pages/ExchangeDetail/ExchangeDetail.tsx`
|
||
- Modify: `src/pages/AgentHealth/AgentHealth.tsx`
|
||
- Modify: `src/pages/AgentInstance/AgentInstance.tsx`
|
||
- Modify: `src/pages/Admin/Admin.tsx`
|
||
- Modify: `src/pages/ApiDocs/ApiDocs.tsx`
|
||
- Modify: `src/pages/AppDetail/AppDetail.tsx`
|
||
|
||
Each page currently wraps its content in `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>...</AppShell>`. Since `LayoutShell` now provides the `AppShell` + sidebar, each page must return **only the content** that was previously inside `<AppShell>` (the TopBar + page body). Pages with `DetailPanel` passed as `detail` prop to `AppShell` need special handling — see below.
|
||
|
||
- [ ] **Step 1: Dashboard.tsx**
|
||
|
||
Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`). Remove the `<AppShell sidebar={...} detail={...}>` wrapper.
|
||
|
||
The `detail` prop passed to `AppShell` is for `DetailPanel`. Since `AppShell` uses a portal (`<div id="cameleer-detail-panel-root" />`), the `DetailPanel` component already portals itself. The `detail` prop on `AppShell` is deprecated (comment says so). So we just render `DetailPanel` alongside the other content.
|
||
|
||
Change the return from:
|
||
```tsx
|
||
return (
|
||
<AppShell
|
||
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
|
||
detail={selectedExchange ? (<DetailPanel ...>) : undefined}
|
||
>
|
||
<TopBar ... />
|
||
<div className={styles.content}>...</div>
|
||
<ShortcutsBar ... />
|
||
</AppShell>
|
||
)
|
||
```
|
||
to:
|
||
```tsx
|
||
return (
|
||
<>
|
||
<TopBar ... />
|
||
<div className={styles.content}>...</div>
|
||
<ShortcutsBar ... />
|
||
{selectedExchange && (<DetailPanel .../>)}
|
||
</>
|
||
)
|
||
```
|
||
|
||
Remove these import lines:
|
||
```tsx
|
||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||
```
|
||
And remove `SIDEBAR_APPS` from the sidebar import (keep `buildRouteToAppMap`):
|
||
```tsx
|
||
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||
```
|
||
|
||
- [ ] **Step 2: Routes.tsx**
|
||
|
||
This file has **two return statements** (route detail view and top-level view). Both wrap in `<AppShell sidebar={...}>`. Strip both.
|
||
|
||
Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`).
|
||
|
||
Replace each `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>...</AppShell>` with a fragment `<>...</>`.
|
||
|
||
- [ ] **Step 3: ExchangeDetail.tsx**
|
||
|
||
Has **two return sites** (not found + normal). Strip both.
|
||
|
||
Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`).
|
||
|
||
Replace each `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>...</AppShell>` with `<>...</>`.
|
||
|
||
- [ ] **Step 4: AgentHealth.tsx**
|
||
|
||
One return with `<AppShell sidebar={...} detail={...}>`.
|
||
|
||
Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.
|
||
|
||
Replace wrapper with fragment. Render `DetailPanel` alongside content.
|
||
|
||
- [ ] **Step 5: AgentInstance.tsx**
|
||
|
||
**Two return sites** (not found + normal).
|
||
|
||
Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.
|
||
|
||
Replace both wrappers with fragments.
|
||
|
||
- [ ] **Step 6: Admin.tsx (AdminLayout)**
|
||
|
||
`AdminLayout` currently wraps in `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>`. Strip it.
|
||
|
||
Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.
|
||
|
||
Change `AdminLayout` return from:
|
||
```tsx
|
||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||
<TopBar ... />
|
||
<Tabs ... />
|
||
<div className={styles.adminContent}>{children}</div>
|
||
</AppShell>
|
||
```
|
||
to:
|
||
```tsx
|
||
<>
|
||
<TopBar ... />
|
||
<Tabs ... />
|
||
<div className={styles.adminContent}>{children}</div>
|
||
</>
|
||
```
|
||
|
||
- [ ] **Step 7: ApiDocs.tsx**
|
||
|
||
Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.
|
||
|
||
Return:
|
||
```tsx
|
||
<>
|
||
<TopBar
|
||
breadcrumb={[{ label: 'API Documentation' }]}
|
||
environment="PRODUCTION"
|
||
user={{ name: 'hendrik' }}
|
||
/>
|
||
<EmptyState
|
||
title="API Documentation"
|
||
description="API documentation coming soon."
|
||
/>
|
||
</>
|
||
```
|
||
|
||
- [ ] **Step 8: AppDetail.tsx**
|
||
|
||
Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.
|
||
|
||
Return:
|
||
```tsx
|
||
<>
|
||
<TopBar
|
||
breadcrumb={[
|
||
{ label: 'Applications', href: '/apps' },
|
||
{ label: id ?? '' },
|
||
]}
|
||
environment="PRODUCTION"
|
||
user={{ name: 'hendrik' }}
|
||
/>
|
||
<EmptyState
|
||
title="Application Detail"
|
||
description="Application detail view coming soon."
|
||
/>
|
||
</>
|
||
```
|
||
|
||
- [ ] **Step 9: Verify TypeScript compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
Expected: Zero errors. All pages now return content fragments, `LayoutShell` provides the `AppShell` wrapper.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/pages/Dashboard/Dashboard.tsx src/pages/Routes/Routes.tsx src/pages/ExchangeDetail/ExchangeDetail.tsx src/pages/AgentHealth/AgentHealth.tsx src/pages/AgentInstance/AgentInstance.tsx src/pages/Admin/Admin.tsx src/pages/ApiDocs/ApiDocs.tsx src/pages/AppDetail/AppDetail.tsx
|
||
git commit -m "refactor: strip AppShell+Sidebar wrappers from all page components
|
||
|
||
Pages now render content only. LayoutShell provides the AppShell
|
||
wrapper with compound Sidebar via React Router layout route."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Update Inventory LayoutSection
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Inventory/sections/LayoutSection.tsx`
|
||
|
||
The Inventory page's Sidebar demo needs to use the compound API. This is a standalone showcase, not wrapped by `LayoutShell`.
|
||
|
||
- [ ] **Step 1: Update LayoutSection to use compound Sidebar API**
|
||
|
||
Replace the imports and data section:
|
||
|
||
Remove:
|
||
```tsx
|
||
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
|
||
import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar'
|
||
```
|
||
|
||
Add:
|
||
```tsx
|
||
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
|
||
import { SidebarTree } from '../../../design-system/layout/Sidebar/SidebarTree'
|
||
import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/SidebarTree'
|
||
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
|
||
import { Box, Cpu, Settings, FileText, ChevronRight } from 'lucide-react'
|
||
```
|
||
|
||
Replace the `SAMPLE_APPS` data and the Sidebar demo card:
|
||
|
||
```tsx
|
||
// ── Sample tree nodes for demo ─────────────────────<E29480><E29480><EFBFBD>────────────────────────
|
||
|
||
const SAMPLE_APP_NODES: SidebarTreeNode[] = [
|
||
{
|
||
id: 'app:app1',
|
||
label: 'cameleer-prod',
|
||
icon: <StatusDot variant="live" />,
|
||
badge: '14.3k',
|
||
path: '#',
|
||
starrable: true,
|
||
starKey: 'app1',
|
||
children: [
|
||
{ id: 'route:app1:r1', label: 'order-ingest', icon: <ChevronRight size={12} />, badge: '5.4k', path: '#', starrable: true, starKey: 'app1:r1' },
|
||
{ id: 'route:app1:r2', label: 'payment-validate', icon: <ChevronRight size={12} />, badge: '3.1k', path: '#', starrable: true, starKey: 'app1:r2' },
|
||
],
|
||
},
|
||
{
|
||
id: 'app:app2',
|
||
label: 'cameleer-staging',
|
||
icon: <StatusDot variant="stale" />,
|
||
badge: '871',
|
||
path: '#',
|
||
starrable: true,
|
||
starKey: 'app2',
|
||
children: [
|
||
{ id: 'route:app2:r3', label: 'notify-customer', icon: <ChevronRight size={12} />, badge: '2.2k', path: '#', starrable: true, starKey: 'app2:r3' },
|
||
],
|
||
},
|
||
{
|
||
id: 'app:app3',
|
||
label: 'cameleer-dev',
|
||
icon: <StatusDot variant="dead" />,
|
||
badge: '42',
|
||
path: '#',
|
||
starrable: true,
|
||
starKey: 'app3',
|
||
},
|
||
]
|
||
```
|
||
|
||
Then update the Sidebar DemoCard:
|
||
|
||
```tsx
|
||
{/* 2. Sidebar */}
|
||
<DemoCard
|
||
id="sidebar"
|
||
title="Sidebar"
|
||
description="Composable navigation sidebar with sections, tree navigation, and icon-rail collapse mode."
|
||
>
|
||
<div className={styles.sidebarPreview}>
|
||
<Sidebar>
|
||
<Sidebar.Header logo={<span style={{ fontSize: 20 }}>🐪</span>} title="cameleer" version="v3.2.1" />
|
||
<Sidebar.Section label="Applications" icon={<Box size={14} />}>
|
||
<SidebarTree
|
||
nodes={SAMPLE_APP_NODES}
|
||
isStarred={() => false}
|
||
onToggleStar={() => {}}
|
||
/>
|
||
</Sidebar.Section>
|
||
<Sidebar.Footer>
|
||
<Sidebar.FooterLink icon={<Settings size={14} />} label="Admin" />
|
||
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" />
|
||
</Sidebar.Footer>
|
||
</Sidebar>
|
||
</div>
|
||
</DemoCard>
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
Expected: Zero errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/pages/Inventory/sections/LayoutSection.tsx
|
||
git commit -m "refactor(inventory): update Sidebar demo to compound API"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Run full test suite and build
|
||
|
||
- [ ] **Step 1: Run all tests**
|
||
|
||
Run: `npx vitest run`
|
||
|
||
Expected: All tests pass. The `useStarred.test.ts` tests are unchanged and should still pass.
|
||
|
||
- [ ] **Step 2: Run TypeScript check**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
Expected: Zero errors.
|
||
|
||
- [ ] **Step 3: Run build**
|
||
|
||
Run: `npm run build`
|
||
|
||
Expected: Clean Vite build with no errors.
|
||
|
||
- [ ] **Step 4: Fix any issues found**
|
||
|
||
If tests fail or build breaks, fix the specific issue. Common problems:
|
||
- Missing imports after removing old Sidebar types
|
||
- CSS class references that were removed
|
||
- SidebarTree props that changed shape
|
||
|
||
- [ ] **Step 5: Commit any fixes**
|
||
|
||
```bash
|
||
git add -u
|
||
git commit -m "fix: resolve test/build issues from sidebar refactor"
|
||
```
|
||
|
||
---
|
||
|
||
## Summary of changes
|
||
|
||
| Area | Before | After |
|
||
|------|--------|-------|
|
||
| `Sidebar.tsx` | 560 lines, monolithic, hardcoded 3 sections | ~150 lines, compound component shell |
|
||
| `SidebarTree.tsx` | Internal, not exported | Exported publicly |
|
||
| `useStarred.ts` | Internal, not exported | Exported publicly |
|
||
| Layout exports | `Sidebar`, `SidebarApp/Route/Agent` types | `Sidebar`, `SidebarTree`, `SidebarTreeNode`, `useStarred` |
|
||
| Page components | Each wraps `<AppShell sidebar={...}>` (11 files) | Return content only, `LayoutShell` provides shell |
|
||
| `App.tsx` | Flat route list | Layout route (`<LayoutShell />`) wrapping all pages |
|
||
| Application logic | In DS package | In `src/layout/LayoutShell.tsx` |
|