docs: add historical implementation plans
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
# Composable Sidebar Migration 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:** Migrate the server UI from the old monolithic `<Sidebar apps={[...]}/>` to the new composable compound Sidebar API from `@cameleer/design-system` v0.1.23, adding admin accordion behavior and icon-rail collapse.
|
||||
|
||||
**Architecture:** Extract tree-building logic into a local `sidebar-utils.ts`, rewrite sidebar composition in `LayoutShell.tsx` using `Sidebar.Header/Section/Footer` compound components, add admin accordion behavior via route-based section state management, and simplify `AdminLayout.tsx` by removing its tab navigation (sidebar handles it now).
|
||||
|
||||
**Tech Stack:** React 19, `@cameleer/design-system` v0.1.23, react-router, lucide-react, CSS Modules
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `ui/src/components/sidebar-utils.ts` | Create | Tree-building functions, SidebarApp type, formatCount, admin node builder |
|
||||
| `ui/src/components/LayoutShell.tsx` | Modify | Rewrite sidebar composition with compound API, add accordion + collapse |
|
||||
| `ui/src/pages/Admin/AdminLayout.tsx` | Modify | Remove tab navigation (sidebar handles it), keep content wrapper |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create sidebar-utils.ts with Tree-Building Functions
|
||||
|
||||
**Files:**
|
||||
- Create: `ui/src/components/sidebar-utils.ts`
|
||||
|
||||
- [ ] **Step 1: Create the utility file**
|
||||
|
||||
This file contains the `SidebarApp` type (moved from DS), tree-building functions, and the admin node builder. These were previously inside the DS's monolithic Sidebar component.
|
||||
|
||||
```typescript
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SidebarTreeNode } from '@cameleer/design-system';
|
||||
|
||||
// ── Domain types (moved from DS) ──────────────────────────────────────────
|
||||
|
||||
export interface SidebarRoute {
|
||||
id: string;
|
||||
name: string;
|
||||
exchangeCount: number;
|
||||
}
|
||||
|
||||
export interface SidebarAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'live' | 'stale' | 'dead';
|
||||
tps: number;
|
||||
}
|
||||
|
||||
export interface SidebarApp {
|
||||
id: string;
|
||||
name: string;
|
||||
health: 'live' | 'stale' | 'dead';
|
||||
exchangeCount: number;
|
||||
routes: SidebarRoute[];
|
||||
agents: SidebarAgent[];
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ── Tree node builders ────────────────────────────────────────────────────
|
||||
|
||||
export function buildAppTreeNodes(
|
||||
apps: SidebarApp[],
|
||||
statusDot: (health: string) => ReactNode,
|
||||
chevron: ReactNode,
|
||||
): SidebarTreeNode[] {
|
||||
return apps.map((app) => ({
|
||||
id: app.id,
|
||||
label: app.name,
|
||||
icon: statusDot(app.health),
|
||||
badge: formatCount(app.exchangeCount),
|
||||
path: `/apps/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `app:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `${app.id}/${route.id}`,
|
||||
label: route.name,
|
||||
icon: chevron,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildAgentTreeNodes(
|
||||
apps: SidebarApp[],
|
||||
statusDot: (health: string) => ReactNode,
|
||||
): 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(app.health),
|
||||
badge: `${liveCount}/${app.agents.length} live`,
|
||||
path: `/agents/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `agent:${app.id}`,
|
||||
children: app.agents.map((agent) => ({
|
||||
id: `agents:${app.id}/${agent.id}`,
|
||||
label: agent.name,
|
||||
icon: statusDot(agent.status),
|
||||
badge: `${agent.tps.toFixed(1)}/s`,
|
||||
path: `/agents/${app.id}/${agent.id}`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRouteTreeNodes(
|
||||
apps: SidebarApp[],
|
||||
statusDot: (health: string) => ReactNode,
|
||||
chevron: ReactNode,
|
||||
): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.routes.length > 0)
|
||||
.map((app) => ({
|
||||
id: `routes:${app.id}`,
|
||||
label: app.name,
|
||||
icon: statusDot(app.health),
|
||||
badge: `${app.routes.length} route${app.routes.length !== 1 ? 's' : ''}`,
|
||||
path: `/routes/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `routestat:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `routes:${app.id}/${route.id}`,
|
||||
label: route.name,
|
||||
icon: chevron,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildAdminTreeNodes(): SidebarTreeNode[] {
|
||||
return [
|
||||
{ id: 'admin:rbac', label: 'User Management', path: '/admin/rbac' },
|
||||
{ id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
|
||||
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
||||
{ id: 'admin:appconfig', label: 'App Config', path: '/admin/appconfig' },
|
||||
{ id: 'admin:database', label: 'Database', path: '/admin/database' },
|
||||
{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' },
|
||||
];
|
||||
}
|
||||
|
||||
// ── localStorage-backed section collapse ──────────────────────────────────
|
||||
|
||||
export function readCollapsed(key: string, defaultValue: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw !== null) return raw === 'true';
|
||||
} catch { /* ignore */ }
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function writeCollapsed(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/components/sidebar-utils.ts
|
||||
git commit -m "feat(#112): extract sidebar tree builders and types from DS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite LayoutShell with Composable Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/components/LayoutShell.tsx`
|
||||
|
||||
This is the main migration task. The `LayoutContent` function gets a significant rewrite of its sidebar composition while preserving all existing TopBar, CommandPalette, ContentTabs, breadcrumb, and scope logic.
|
||||
|
||||
- [ ] **Step 1: Update imports**
|
||||
|
||||
Replace the old DS imports and add new ones. In `LayoutShell.tsx`, change the first 10 lines to:
|
||||
|
||||
```typescript
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { AppShell, Sidebar, SidebarTree, useStarred, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters, StatusDot } from '@cameleer/design-system';
|
||||
import type { SidebarTreeNode, SearchResult } from '@cameleer/design-system';
|
||||
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight } from 'lucide-react';
|
||||
import { useRouteCatalog } from '../api/queries/catalog';
|
||||
import { useAgents } from '../api/queries/agents';
|
||||
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { ContentTabs } from './ContentTabs';
|
||||
import { useScope } from '../hooks/useScope';
|
||||
import { buildAppTreeNodes, buildAgentTreeNodes, buildRouteTreeNodes, buildAdminTreeNodes, readCollapsed, writeCollapsed } from './sidebar-utils';
|
||||
import type { SidebarApp } from './sidebar-utils';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the old `sidebarApps` builder and `healthToColor` function**
|
||||
|
||||
Delete the `healthToColor` function (lines 12-19) and the `sidebarApps` useMemo block (lines 128-154). These are replaced by tree-building functions that produce `SidebarTreeNode[]` directly.
|
||||
|
||||
- [ ] **Step 3: Add sidebar state management inside LayoutContent**
|
||||
|
||||
Add these state and memo declarations inside `LayoutContent`, after the existing hooks (after `const { scope, setTab } = useScope();` around line 112):
|
||||
|
||||
```typescript
|
||||
// ── Sidebar state ──────────────────────────────────────────────────────
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const { starredIds, isStarred, toggleStar } = useStarred();
|
||||
|
||||
// Section collapse states — persisted to localStorage
|
||||
const [appsOpen, setAppsOpen] = useState(() => !readCollapsed('cameleer:sidebar:apps-collapsed', false));
|
||||
const [agentsOpen, setAgentsOpen] = useState(() => !readCollapsed('cameleer:sidebar:agents-collapsed', false));
|
||||
const [routesOpen, setRoutesOpen] = useState(() => !readCollapsed('cameleer:sidebar:routes-collapsed', true));
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
|
||||
const toggleApps = useCallback(() => {
|
||||
setAppsOpen((v) => { writeCollapsed('cameleer:sidebar:apps-collapsed', v); return !v; });
|
||||
}, []);
|
||||
const toggleAgents = useCallback(() => {
|
||||
setAgentsOpen((v) => { writeCollapsed('cameleer:sidebar:agents-collapsed', v); return !v; });
|
||||
}, []);
|
||||
const toggleRoutes = useCallback(() => {
|
||||
setRoutesOpen((v) => { writeCollapsed('cameleer:sidebar:routes-collapsed', v); return !v; });
|
||||
}, []);
|
||||
const toggleAdmin = useCallback(() => setAdminOpen((v) => !v), []);
|
||||
|
||||
// Accordion: entering admin expands admin, collapses operational sections
|
||||
const prevOpsState = useRef<{ apps: boolean; agents: boolean; routes: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdminPage) {
|
||||
// Save current operational state, then collapse all, expand admin
|
||||
prevOpsState.current = { apps: appsOpen, agents: agentsOpen, routes: routesOpen };
|
||||
setAppsOpen(false);
|
||||
setAgentsOpen(false);
|
||||
setRoutesOpen(false);
|
||||
setAdminOpen(true);
|
||||
} else if (prevOpsState.current) {
|
||||
// Restore operational state, collapse admin
|
||||
setAppsOpen(prevOpsState.current.apps);
|
||||
setAgentsOpen(prevOpsState.current.agents);
|
||||
setRoutesOpen(prevOpsState.current.routes);
|
||||
setAdminOpen(false);
|
||||
prevOpsState.current = null;
|
||||
}
|
||||
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Build tree nodes from catalog data
|
||||
const statusDot = useCallback((health: string) => <StatusDot status={health as any} />, []);
|
||||
const chevronIcon = useMemo(() => <ChevronRight size={12} />, []);
|
||||
|
||||
const appNodes = useMemo(
|
||||
() => buildAppTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
|
||||
id: app.appId,
|
||||
name: app.appId,
|
||||
health: app.health as 'live' | 'stale' | 'dead',
|
||||
exchangeCount: app.exchangeCount,
|
||||
routes: [...(app.routes || [])].sort((a: any, b: any) => a.routeId.localeCompare(b.routeId)).map((r: any) => ({
|
||||
id: r.routeId, name: r.routeId, exchangeCount: r.exchangeCount,
|
||||
})),
|
||||
agents: [...(app.agents || [])].sort((a: any, b: any) => a.name.localeCompare(b.name)).map((a: any) => ({
|
||||
id: a.id, name: a.name, status: a.status as 'live' | 'stale' | 'dead', tps: a.tps ?? 0,
|
||||
})),
|
||||
})) : [], statusDot, chevronIcon),
|
||||
[catalog, statusDot, chevronIcon],
|
||||
);
|
||||
|
||||
const agentNodes = useMemo(
|
||||
() => buildAgentTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
|
||||
id: app.appId,
|
||||
name: app.appId,
|
||||
health: app.health as 'live' | 'stale' | 'dead',
|
||||
exchangeCount: app.exchangeCount,
|
||||
routes: [],
|
||||
agents: [...(app.agents || [])].sort((a: any, b: any) => a.name.localeCompare(b.name)).map((a: any) => ({
|
||||
id: a.id, name: a.name, status: a.status as 'live' | 'stale' | 'dead', tps: a.tps ?? 0,
|
||||
})),
|
||||
})) : [], statusDot),
|
||||
[catalog, statusDot],
|
||||
);
|
||||
|
||||
const routeNodes = useMemo(
|
||||
() => buildRouteTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
|
||||
id: app.appId,
|
||||
name: app.appId,
|
||||
health: app.health as 'live' | 'stale' | 'dead',
|
||||
exchangeCount: app.exchangeCount,
|
||||
routes: [...(app.routes || [])].sort((a: any, b: any) => a.routeId.localeCompare(b.routeId)).map((r: any) => ({
|
||||
id: r.routeId, name: r.routeId, exchangeCount: r.exchangeCount,
|
||||
})),
|
||||
agents: [],
|
||||
})) : [], statusDot, chevronIcon),
|
||||
[catalog, statusDot, chevronIcon],
|
||||
);
|
||||
|
||||
const adminNodes = useMemo(() => buildAdminTreeNodes(), []);
|
||||
|
||||
// Sidebar reveal from Cmd-K navigation
|
||||
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarRevealPath) return;
|
||||
if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true);
|
||||
if (sidebarRevealPath.startsWith('/agents') && !agentsOpen) setAgentsOpen(true);
|
||||
if (sidebarRevealPath.startsWith('/routes') && !routesOpen) setRoutesOpen(true);
|
||||
if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true);
|
||||
}, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace the return block's sidebar composition**
|
||||
|
||||
Replace the `return` statement in `LayoutContent` (starting from `return (` to the closing `);`). The key change is replacing `<Sidebar apps={sidebarApps} onNavigate={handleSidebarNavigate} />` with the compound Sidebar:
|
||||
|
||||
```typescript
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapseToggle={() => setSidebarCollapsed((v) => !v)}
|
||||
searchValue={filterQuery}
|
||||
onSearchChange={setFilterQuery}
|
||||
>
|
||||
<Sidebar.Header
|
||||
logo={<img src="/favicon.svg" alt="" style={{ width: 28, height: 24 }} />}
|
||||
title="cameleer"
|
||||
version="v3.2.1"
|
||||
onClick={() => handleSidebarNavigate('/apps')}
|
||||
/>
|
||||
|
||||
{isAdminPage && (
|
||||
<Sidebar.Section
|
||||
label="Admin"
|
||||
icon={<Settings size={14} />}
|
||||
open={adminOpen}
|
||||
onToggle={toggleAdmin}
|
||||
active={location.pathname.startsWith('/admin')}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={adminNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
filterQuery={filterQuery}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
)}
|
||||
|
||||
<Sidebar.Section
|
||||
label="Applications"
|
||||
icon={<Box size={14} />}
|
||||
open={appsOpen}
|
||||
onToggle={() => { toggleApps(); if (isAdminPage) handleSidebarNavigate('/apps'); }}
|
||||
active={effectiveSelectedPath.startsWith('/apps') || effectiveSelectedPath.startsWith('/exchanges') || effectiveSelectedPath.startsWith('/dashboard')}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={appNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="cameleer:expanded:apps"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
label="Agents"
|
||||
icon={<Cpu size={14} />}
|
||||
open={agentsOpen}
|
||||
onToggle={() => { toggleAgents(); if (isAdminPage) handleSidebarNavigate('/agents'); }}
|
||||
active={effectiveSelectedPath.startsWith('/agents') || effectiveSelectedPath.startsWith('/runtime')}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={agentNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="cameleer:expanded:agents"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
label="Routes"
|
||||
icon={<GitBranch size={14} />}
|
||||
open={routesOpen}
|
||||
onToggle={() => { toggleRoutes(); if (isAdminPage) handleSidebarNavigate('/routes'); }}
|
||||
active={effectiveSelectedPath.startsWith('/routes')}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={routeNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="cameleer:expanded:routes"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
{!isAdminPage && (
|
||||
<Sidebar.Section
|
||||
label="Admin"
|
||||
icon={<Settings size={14} />}
|
||||
open={adminOpen}
|
||||
onToggle={() => { toggleAdmin(); handleSidebarNavigate('/admin'); }}
|
||||
active={false}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={adminNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
filterQuery={filterQuery}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
)}
|
||||
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={<FileText size={14} />}
|
||||
label="API Docs"
|
||||
onClick={() => handleSidebarNavigate('/api-docs')}
|
||||
active={location.pathname === '/api-docs'}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
}
|
||||
>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
user={username ? { name: username } : undefined}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
onOpen={() => setPaletteOpen(true)}
|
||||
onSelect={handlePaletteSelect}
|
||||
onSubmit={handlePaletteSubmit}
|
||||
onQueryChange={setPaletteQuery}
|
||||
data={searchData}
|
||||
/>
|
||||
|
||||
{!isAdminPage && (
|
||||
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
|
||||
)}
|
||||
|
||||
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, padding: isAdminPage ? '1.5rem' : 0 }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
```
|
||||
|
||||
Note: The Admin section renders in two positions — at the top when `isAdminPage` (accordion mode), at the bottom when not (collapsed section). Only one renders at a time via the conditional.
|
||||
|
||||
- [ ] **Step 5: Verify it compiles**
|
||||
|
||||
Run: `cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit`
|
||||
Expected: No errors. If `StatusDot` is not exported from the DS, check the exact export name with `grep -r "StatusDot" ui/node_modules/@cameleer/design-system/dist/index.es.d.ts`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/components/LayoutShell.tsx
|
||||
git commit -m "feat(#112): migrate to composable sidebar with accordion and collapse"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Simplify AdminLayout
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/Admin/AdminLayout.tsx`
|
||||
|
||||
The sidebar now handles admin sub-page navigation, so `AdminLayout` no longer needs its own `<Tabs>`.
|
||||
|
||||
- [ ] **Step 1: Rewrite AdminLayout**
|
||||
|
||||
Replace the full contents of `AdminLayout.tsx`:
|
||||
|
||||
```typescript
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function AdminLayout() {
|
||||
return (
|
||||
<div style={{ padding: '20px 24px 40px' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/pages/Admin/AdminLayout.tsx
|
||||
git commit -m "feat(#112): remove admin tabs, sidebar handles navigation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Visual Verification
|
||||
|
||||
- [ ] **Step 1: Verify operational mode**
|
||||
|
||||
Open `http://localhost:5173/exchanges` and verify:
|
||||
1. Sidebar shows all 4 sections: Applications, Agents, Routes, Admin
|
||||
2. Applications and Agents expanded by default, Routes and Admin collapsed
|
||||
3. Sidebar search filters tree items
|
||||
4. Clicking an app navigates to the exchanges page for that app
|
||||
5. TopBar, ContentTabs, CommandPalette all work normally
|
||||
6. Star/unstar items work
|
||||
|
||||
- [ ] **Step 2: Verify sidebar collapse**
|
||||
|
||||
Click the `<<` toggle in sidebar header:
|
||||
1. Sidebar collapses to ~48px icon rail
|
||||
2. Section icons visible (Box, Cpu, GitBranch, Settings)
|
||||
3. Footer link icon visible (FileText for API Docs)
|
||||
4. Click any section icon — sidebar expands and that section opens
|
||||
|
||||
- [ ] **Step 3: Verify admin accordion**
|
||||
|
||||
Navigate to `/admin/rbac` (click Admin section in sidebar or navigate directly):
|
||||
1. Admin section appears at top of sidebar, expanded, showing 6 sub-pages
|
||||
2. Applications, Agents, Routes sections are collapsed to single-line headers
|
||||
3. Admin sub-page items show active highlighting for current page
|
||||
4. No admin tabs visible in content area (just content with padding)
|
||||
5. Clicking between admin sub-pages (e.g., Audit Log, OIDC) works via sidebar
|
||||
|
||||
- [ ] **Step 4: Verify leaving admin**
|
||||
|
||||
From an admin page, click "Applications" section header:
|
||||
1. Navigates to `/exchanges` (or last operational tab)
|
||||
2. Admin section collapses
|
||||
3. Operational sections restore their previous open/closed states
|
||||
|
||||
- [ ] **Step 5: Final commit if any fixes were needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(#112): sidebar migration adjustments from visual review"
|
||||
```
|
||||
Reference in New Issue
Block a user