Two regressions surfaced by running the L1 e2e suite against current main (which carries PR #174's /home routing migration): 1. L1 post-login redirect keyed off `pathname === '/'`, but the authed index moved to /home in #174 — so L1 users landed on the engineer dashboard instead of /l1. Replace the ad-hoc '/' and /pilot|/assistant checks with a single allowlist: l1_tech users may only reach /l1*, /guides, /account, /change-password; everything else (incl. /home, /pilot, /trees/*, /escalations) bounces to /l1. Runs before the requiredRole check so L1 users never trip the engineer-route role logic. 2. Rail nav Links exposed only the truncated shortLabel as their accessible name (title= is not an accessible-name source when visible text exists), so the "L1 Workspace" coverage-engineer link was unreachable by role+name. Add aria-label={item.label} for an accurate accessible name on every rail link. Fixes all 3 failing cases in e2e/l1-workspace.spec.ts. tsc + eslint clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
498 lines
20 KiB
TypeScript
498 lines
20 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
|
import { Link, useLocation } from 'react-router-dom'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
import {
|
|
LayoutGrid, Clock, AlertTriangle, GitBranch,
|
|
ListChecks, BarChart3,
|
|
Settings, Pin, PinOff,
|
|
FileText, Ticket, BookOpen,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
|
import { sidebarApi } from '@/api'
|
|
import type { SidebarStatsResponse } from '@/api/sidebar'
|
|
import { prefetchForRoute } from '@/lib/routePrefetch'
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
|
|
/* ── Types ──────────────────────────────────────────── */
|
|
|
|
interface NavSubItem {
|
|
href: string
|
|
label: string
|
|
count?: number
|
|
}
|
|
|
|
interface NavEntry {
|
|
href: string
|
|
icon: LucideIcon
|
|
label: string
|
|
shortLabel: string
|
|
badge?: number
|
|
matchPaths?: string[]
|
|
children?: NavSubItem[]
|
|
}
|
|
|
|
/* ── Sidebar component ──────────────────────────────── */
|
|
|
|
export function Sidebar() {
|
|
const location = useLocation()
|
|
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
|
|
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
|
|
const { isL1Tech, canCoverL1 } = usePermissions()
|
|
|
|
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
|
|
// Phase 6: pending-drafts badge on the Scripts nav. Fetched independently
|
|
// of the main stats endpoint so backend changes aren't coupled — worst
|
|
// case the badge doesn't show, rest of the sidebar still renders.
|
|
const [pendingDraftCount, setPendingDraftCount] = useState<number>(0)
|
|
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
|
|
const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const sidebarRef = useRef<HTMLElement>(null)
|
|
const statsRequestId = useRef(0)
|
|
|
|
/* ── Stats fetching ───────────────────────────────── */
|
|
|
|
const refreshStats = useCallback(() => {
|
|
const requestId = ++statsRequestId.current
|
|
sidebarApi.getStats()
|
|
.then(data => { if (requestId === statsRequestId.current) setStats(data) })
|
|
.catch(() => {})
|
|
// Phase 6: pending draft templates — soft-fail, optional import keeps
|
|
// the sidebar robust if the endpoint is momentarily unavailable.
|
|
import('@/api/draftTemplates').then(({ draftTemplatesApi }) => {
|
|
draftTemplatesApi.list(true)
|
|
.then(drafts => setPendingDraftCount(drafts.length))
|
|
.catch(() => {})
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('session-changed', refreshStats)
|
|
return () => window.removeEventListener('session-changed', refreshStats)
|
|
}, [refreshStats])
|
|
|
|
/* ── Navigation data ──────────────────────────────── */
|
|
|
|
/* Single source-of-truth IA. Same items, same order, in both rail
|
|
* and pinned modes. Pin/unpin is a width/label affordance, not an
|
|
* IA switch. A hairline divider separates the two groups; no labels. */
|
|
|
|
// L1 users get a focused sidebar with only their surfaces.
|
|
// Engineers/owners get the full sidebar; those with canCoverL1 also get
|
|
// an appended "L1 Workspace" entry in the library group.
|
|
const workItems: NavEntry[] = isL1Tech
|
|
? [
|
|
{ href: '/l1', icon: LayoutGrid, label: 'Workspace', shortLabel: 'Work', matchPaths: ['/l1'] },
|
|
{ href: '/l1/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/l1/tickets'] },
|
|
{ href: '/l1/drafts', icon: FileText, label: 'My Drafts', shortLabel: 'Drafts', matchPaths: ['/l1/drafts'] },
|
|
]
|
|
: [
|
|
{
|
|
href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash',
|
|
matchPaths: ['/'],
|
|
},
|
|
{
|
|
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
|
|
matchPaths: ['/tickets'],
|
|
},
|
|
{
|
|
href: '/sessions', icon: Clock, label: 'Sessions', shortLabel: 'Sessions',
|
|
badge: stats?.active_count || undefined,
|
|
matchPaths: ['/sessions'],
|
|
},
|
|
{
|
|
href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal',
|
|
badge: stats?.escalation_count || undefined,
|
|
matchPaths: ['/escalations'],
|
|
},
|
|
]
|
|
|
|
const libraryItems: NavEntry[] = isL1Tech
|
|
? []
|
|
: [
|
|
{
|
|
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
|
badge: stats?.tree_counts.total || undefined,
|
|
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/network-diagrams'],
|
|
children: [
|
|
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
|
{ href: '/step-library', label: 'Solutions Library' },
|
|
{ href: '/network-diagrams', label: 'Network Maps' },
|
|
],
|
|
},
|
|
{
|
|
href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts',
|
|
badge: pendingDraftCount || undefined,
|
|
matchPaths: ['/scripts', '/script-builder'],
|
|
children: [
|
|
{ href: '/script-builder', label: 'Script Builder' },
|
|
],
|
|
},
|
|
{
|
|
href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review',
|
|
matchPaths: ['/review-queue'],
|
|
},
|
|
{
|
|
href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats',
|
|
matchPaths: ['/analytics', '/shares'],
|
|
children: [
|
|
{ href: '/shares', label: 'Exports' },
|
|
],
|
|
},
|
|
// Engineers/owners with L1 coverage access also get the L1 Workspace entry
|
|
...(canCoverL1 ? [{
|
|
href: '/l1', icon: LayoutGrid, label: 'L1 Workspace', shortLabel: 'L1',
|
|
matchPaths: ['/l1'],
|
|
}] : []),
|
|
]
|
|
|
|
const footerItems: NavEntry[] = [
|
|
{ href: '/guides', icon: BookOpen, label: 'Guides', shortLabel: 'Guides', matchPaths: ['/guides'] },
|
|
{ href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct', matchPaths: ['/account'] },
|
|
]
|
|
|
|
/* ── Active detection ─────────────────────────────── */
|
|
|
|
const isActive = (item: NavEntry) => {
|
|
if (item.matchPaths) return item.matchPaths.some(p =>
|
|
p === '/' ? location.pathname === '/' : location.pathname.startsWith(p)
|
|
)
|
|
if (item.href === '/') return location.pathname === '/'
|
|
return location.pathname.startsWith(item.href)
|
|
}
|
|
|
|
const isChildActive = (child: NavSubItem) => {
|
|
const fullPath = location.pathname + location.search
|
|
return fullPath === child.href || fullPath.startsWith(child.href + '&')
|
|
}
|
|
|
|
/* ── Flyout management ────────────────────────────── */
|
|
|
|
const openFlyout = (key: string) => {
|
|
if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current)
|
|
setFlyoutIndex(key)
|
|
}
|
|
|
|
const closeFlyout = () => {
|
|
flyoutTimeout.current = setTimeout(() => setFlyoutIndex(null), 400)
|
|
}
|
|
|
|
const keepFlyout = () => {
|
|
if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current)
|
|
}
|
|
|
|
/* ── Drawer resize ───────────────────────────────── */
|
|
|
|
const [drawerWidth, setDrawerWidth] = useState(240)
|
|
const isResizing = useRef(false)
|
|
|
|
const handleResizeStart = (e: ReactPointerEvent) => {
|
|
e.preventDefault()
|
|
isResizing.current = true
|
|
const startX = e.clientX
|
|
const startWidth = drawerWidth
|
|
|
|
const onMove = (ev: globalThis.PointerEvent) => {
|
|
if (!isResizing.current) return
|
|
const newWidth = Math.max(180, Math.min(400, startWidth + (ev.clientX - startX)))
|
|
setDrawerWidth(newWidth)
|
|
}
|
|
|
|
const onUp = () => {
|
|
isResizing.current = false
|
|
document.removeEventListener('pointermove', onMove)
|
|
document.removeEventListener('pointerup', onUp)
|
|
}
|
|
|
|
document.addEventListener('pointermove', onMove)
|
|
document.addEventListener('pointerup', onUp)
|
|
}
|
|
|
|
/* Close flyout on Escape */
|
|
useEffect(() => {
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setFlyoutIndex(null)
|
|
}
|
|
document.addEventListener('keydown', handleKey)
|
|
return () => document.removeEventListener('keydown', handleKey)
|
|
}, [])
|
|
|
|
/* ── Wheel forwarding (when sidebar can't scroll) ── */
|
|
|
|
const handleWheel = (e: React.WheelEvent<HTMLElement>) => {
|
|
const el = e.currentTarget
|
|
const canScroll = el.scrollHeight > el.clientHeight
|
|
const atTop = el.scrollTop <= 0
|
|
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
|
|
if (!canScroll || (e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
|
|
const main = document.querySelector('.main-content') as HTMLElement | null
|
|
if (main) { main.scrollTop += e.deltaY; e.preventDefault() }
|
|
}
|
|
}
|
|
|
|
/* ── Render helpers ───────────────────────────────── */
|
|
|
|
const renderRailItem = (item: NavEntry, key: string) => {
|
|
const active = isActive(item)
|
|
const Icon = item.icon
|
|
const hasChildren = item.children && item.children.length > 0
|
|
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="relative w-full"
|
|
onMouseEnter={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined}
|
|
>
|
|
<Link
|
|
to={item.href}
|
|
onMouseEnter={() => prefetchForRoute(item.href)}
|
|
onFocus={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined}
|
|
className={cn(
|
|
'group relative flex flex-col items-center justify-center rounded-lg px-2 py-3 transition-all duration-150',
|
|
active
|
|
? 'bg-accent-dim text-accent-text'
|
|
: 'text-text-rail-label hover:text-foreground'
|
|
)}
|
|
title={item.label}
|
|
aria-label={item.label}
|
|
>
|
|
<span className="relative">
|
|
<Icon size={24} strokeWidth={1.6} className={active ? 'opacity-100' : 'opacity-60 group-hover:opacity-85'} />
|
|
{item.badge !== undefined && item.badge > 0 && (
|
|
<span className="absolute -right-2 -top-1.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary px-1 text-[0.5rem] font-bold text-background">
|
|
{item.badge > 99 ? '99+' : item.badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="mt-1.5 text-[0.625rem] font-sans font-medium leading-tight truncate max-w-[64px]">
|
|
{item.shortLabel}
|
|
</span>
|
|
</Link>
|
|
|
|
{/* Flyout rendered as drawer below */}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderPinnedItem = (item: NavEntry, key: string) => {
|
|
const active = isActive(item)
|
|
const Icon = item.icon
|
|
const fullPath = location.pathname + location.search
|
|
const activeChild = item.children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&'))
|
|
const isParentDimmed = !!activeChild && active
|
|
|
|
return (
|
|
<div key={key} className="group/nav">
|
|
<Link
|
|
to={item.href}
|
|
onMouseEnter={() => prefetchForRoute(item.href)}
|
|
className={cn(
|
|
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-150',
|
|
active
|
|
? isParentDimmed
|
|
? 'bg-[rgba(96,165,250,0.05)] text-foreground/70'
|
|
: 'bg-accent-dim text-foreground'
|
|
: 'text-muted-foreground hover:bg-input hover:text-foreground'
|
|
)}
|
|
>
|
|
{active && !isParentDimmed && (
|
|
<div
|
|
className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2"
|
|
style={{ background: 'var(--color-accent)', borderRadius: '0 3px 3px 0' }}
|
|
/>
|
|
)}
|
|
<Icon size={18} className={cn('shrink-0', active ? 'opacity-100' : 'opacity-70')} />
|
|
<span className="truncate">{item.label}</span>
|
|
{item.badge !== undefined && item.badge > 0 && (
|
|
<span className="ml-auto shrink-0 rounded-full px-2 text-[0.6875rem] font-mono text-text-muted"
|
|
style={{ background: 'var(--color-bg-card)', border: '1px solid var(--color-border-default)' }}>
|
|
{item.badge}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
|
|
{/* Sub-items for pinned mode */}
|
|
{item.children && item.children.length > 0 && (
|
|
<div className={cn(
|
|
'mt-0.5 space-y-0.5 overflow-hidden transition-all duration-200',
|
|
active || activeChild
|
|
? 'max-h-40 opacity-100'
|
|
: 'max-h-0 opacity-0 group-hover/nav:max-h-40 group-hover/nav:opacity-100'
|
|
)}>
|
|
{item.children.map(child => {
|
|
const childActive = isChildActive(child)
|
|
return (
|
|
<Link
|
|
key={child.href}
|
|
to={child.href}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-lg pl-9 pr-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
|
childActive
|
|
? 'bg-accent-dim text-foreground'
|
|
: 'text-muted-foreground hover:bg-input hover:text-foreground'
|
|
)}
|
|
>
|
|
<span className="truncate">{child.label}</span>
|
|
{child.count !== undefined && (
|
|
<span className="ml-auto shrink-0 rounded-full px-2 text-[0.6875rem] font-mono text-text-muted"
|
|
style={{ background: 'var(--color-bg-card)', border: '1px solid var(--color-border-default)' }}>
|
|
{child.count}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ── Find active flyout group for drawer ── */
|
|
|
|
const allRailItems = [...workItems, ...libraryItems, ...footerItems]
|
|
const activeFlyoutGroup = flyoutIndex && !sidebarPinned
|
|
? allRailItems.find(item => item.href === flyoutIndex) || null
|
|
: null
|
|
|
|
/* ── Main render ──────────────────────────────────── */
|
|
|
|
if (sidebarPinned) {
|
|
return (
|
|
<nav
|
|
ref={sidebarRef}
|
|
className="sidebar flex flex-col h-full"
|
|
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)' }}
|
|
onWheel={handleWheel}
|
|
>
|
|
{/* Pinned sidebar content */}
|
|
<div className="px-3 py-2 space-y-0.5">
|
|
{workItems.map(item => renderPinnedItem(item, item.href))}
|
|
<div
|
|
className="my-3 border-t"
|
|
style={{ borderColor: 'var(--color-border-default)' }}
|
|
aria-hidden="true"
|
|
/>
|
|
{libraryItems.map(item => renderPinnedItem(item, item.href))}
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Footer */}
|
|
<div className="px-3 pt-2 pb-4 space-y-0.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
|
{footerItems.map(item => renderPinnedItem(item, item.href))}
|
|
<button
|
|
type="button"
|
|
onClick={toggleSidebarPinned}
|
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium text-muted-foreground hover:bg-input hover:text-foreground transition-colors"
|
|
title="Unpin sidebar"
|
|
>
|
|
<PinOff size={18} className="shrink-0" />
|
|
<span>Unpin</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
/* Icon rail (default, unpinned) — same items as pinned mode, narrower. */
|
|
return (
|
|
<div
|
|
className="flex h-full"
|
|
onMouseLeave={closeFlyout}
|
|
>
|
|
{/* Rail */}
|
|
<nav
|
|
ref={sidebarRef}
|
|
className="sidebar flex flex-col items-center h-full shrink-0"
|
|
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)', width: '72px' }}
|
|
onWheel={handleWheel}
|
|
>
|
|
{/* Nav items */}
|
|
<div className="flex flex-col items-center w-full px-1 space-y-1.5">
|
|
{workItems.map(item => renderRailItem(item, item.href))}
|
|
<div
|
|
className="w-8 my-1 border-t self-center"
|
|
style={{ borderColor: 'var(--color-border-default)' }}
|
|
aria-hidden="true"
|
|
/>
|
|
{libraryItems.map(item => renderRailItem(item, item.href))}
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Footer: Guides, Account + Pin */}
|
|
<div className="flex flex-col items-center w-full px-1 pb-5 pt-3 space-y-1.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
|
{footerItems.map(item => renderRailItem(item, item.href))}
|
|
<button
|
|
type="button"
|
|
onClick={toggleSidebarPinned}
|
|
className="flex flex-col items-center justify-center rounded-lg px-2 py-3 text-text-rail-label hover:text-muted-foreground transition-colors"
|
|
title="Pin sidebar"
|
|
>
|
|
<Pin size={22} strokeWidth={1.6} className="opacity-60 hover:opacity-85" />
|
|
<span className="mt-1.5 text-[0.625rem] font-sans font-medium leading-tight">Pin</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Drawer panel — fixed position, full height, resizable, overlays main content */}
|
|
{activeFlyoutGroup && activeFlyoutGroup.children && (
|
|
<div
|
|
className="fixed bottom-0 z-50 flex"
|
|
style={{ top: 'var(--topbar-h)', left: '72px' }}
|
|
onMouseEnter={keepFlyout}
|
|
onMouseLeave={closeFlyout}
|
|
>
|
|
<div
|
|
className="flex flex-col h-full overflow-y-auto py-4 px-2"
|
|
style={{
|
|
width: drawerWidth,
|
|
background: 'var(--color-bg-sidebar)',
|
|
borderRight: '1px solid var(--color-border-default)',
|
|
boxShadow: '4px 0 12px rgba(0,0,0,0.2)',
|
|
}}
|
|
>
|
|
{/* Drawer header */}
|
|
<div className="px-3 mb-3">
|
|
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-text-muted">
|
|
{activeFlyoutGroup.label}
|
|
</h3>
|
|
</div>
|
|
|
|
{/* Drawer items */}
|
|
<div className="space-y-0.5 px-1">
|
|
{activeFlyoutGroup.children.map(child => (
|
|
<Link
|
|
key={child.href}
|
|
to={child.href}
|
|
className={cn(
|
|
'flex items-center justify-between rounded-md px-3 py-2 text-[0.8125rem] transition-colors',
|
|
isChildActive(child)
|
|
? 'bg-accent-dim text-accent-text'
|
|
: 'text-muted-foreground hover:bg-input hover:text-foreground'
|
|
)}
|
|
>
|
|
<span>{child.label}</span>
|
|
{child.count !== undefined && (
|
|
<span className="text-[0.6875rem] font-mono text-text-muted">{child.count}</span>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Resize handle */}
|
|
<div
|
|
className="w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors shrink-0"
|
|
onPointerDown={handleResizeStart}
|
|
title="Drag to resize"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|