diff --git a/tests/unit/web/project-navigation.test.ts b/tests/unit/web/project-navigation.test.ts new file mode 100644 index 00000000..785a2869 --- /dev/null +++ b/tests/unit/web/project-navigation.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_PROJECT_SECTION, + PROJECT_SECTIONS, + isProjectActive, + isSectionActive, + resolveDefaultProjectPath, +} from '../../../web/src/lib/project-sections.js'; + +describe('PROJECT_SECTIONS', () => { + it('contains exactly the expected sections in order', () => { + expect(PROJECT_SECTIONS.map((s) => s.id)).toEqual([ + 'general', + 'harness', + 'work', + 'integrations', + 'agent-configs', + ]); + }); + + it('each section has a non-empty label and path', () => { + for (const section of PROJECT_SECTIONS) { + expect(section.label.length).toBeGreaterThan(0); + expect(section.path.length).toBeGreaterThan(0); + } + }); + + it('has unique ids', () => { + const ids = PROJECT_SECTIONS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('has unique paths', () => { + const paths = PROJECT_SECTIONS.map((s) => s.path); + expect(new Set(paths).size).toBe(paths.length); + }); +}); + +describe('DEFAULT_PROJECT_SECTION', () => { + it('is "general"', () => { + expect(DEFAULT_PROJECT_SECTION).toBe('general'); + }); + + it('exists in PROJECT_SECTIONS', () => { + const ids = PROJECT_SECTIONS.map((s) => s.id); + expect(ids).toContain(DEFAULT_PROJECT_SECTION); + }); +}); + +describe('section path mapping', () => { + it('maps general section to /general path', () => { + const generalSection = PROJECT_SECTIONS.find((s) => s.id === 'general'); + expect(generalSection?.path).toBe('general'); + }); + + it('maps agent-configs section to /agent-configs path', () => { + const agentConfigsSection = PROJECT_SECTIONS.find((s) => s.id === 'agent-configs'); + expect(agentConfigsSection?.path).toBe('agent-configs'); + }); + + it('maps work section to /work path', () => { + const workSection = PROJECT_SECTIONS.find((s) => s.id === 'work'); + expect(workSection?.path).toBe('work'); + }); + + it('maps integrations section to /integrations path', () => { + const integrationsSection = PROJECT_SECTIONS.find((s) => s.id === 'integrations'); + expect(integrationsSection?.path).toBe('integrations'); + }); +}); + +describe('isProjectActive', () => { + it('detects active project from section path', () => { + expect(isProjectActive('/projects/my-project/general', 'my-project')).toBe(true); + expect(isProjectActive('/projects/my-project/work', 'my-project')).toBe(true); + expect(isProjectActive('/projects/my-project/agent-configs', 'my-project')).toBe(true); + }); + + it('detects active project at root path', () => { + expect(isProjectActive('/projects/my-project', 'my-project')).toBe(true); + }); + + it('does not falsely match other projects', () => { + expect(isProjectActive('/projects/other-project/general', 'my-project')).toBe(false); + expect(isProjectActive('/projects', 'my-project')).toBe(false); + }); +}); + +describe('isSectionActive', () => { + it('returns true for matching section path', () => { + expect(isSectionActive('/projects/proj1/general', 'proj1', 'general')).toBe(true); + expect(isSectionActive('/projects/proj1/work', 'proj1', 'work')).toBe(true); + expect(isSectionActive('/projects/proj1/agent-configs', 'proj1', 'agent-configs')).toBe(true); + }); + + it('returns false for non-matching section', () => { + expect(isSectionActive('/projects/proj1/general', 'proj1', 'work')).toBe(false); + expect(isSectionActive('/projects/proj1/integrations', 'proj1', 'general')).toBe(false); + }); + + it('returns false for different project', () => { + expect(isSectionActive('/projects/proj2/general', 'proj1', 'general')).toBe(false); + }); + + it('returns true for sub-paths of a section', () => { + expect(isSectionActive('/projects/proj1/work/details', 'proj1', 'work')).toBe(true); + }); +}); + +describe('resolveDefaultProjectPath', () => { + it('resolves to /general for any project id', () => { + expect(resolveDefaultProjectPath('abc123')).toBe('/projects/abc123/general'); + expect(resolveDefaultProjectPath('my-project')).toBe('/projects/my-project/general'); + }); + + it('always uses the DEFAULT_PROJECT_SECTION', () => { + const projectId = 'test-proj'; + expect(resolveDefaultProjectPath(projectId)).toBe( + `/projects/${projectId}/${DEFAULT_PROJECT_SECTION}`, + ); + }); +}); diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 1954834c..2b89ebcc 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -1,4 +1,5 @@ import { Separator } from '@/components/ui/separator.js'; +import { PROJECT_SECTIONS, isProjectActive, isSectionActive } from '@/lib/project-sections.js'; import { trpc } from '@/lib/trpc.js'; import { cn } from '@/lib/utils.js'; import { useQuery } from '@tanstack/react-query'; @@ -7,6 +8,8 @@ import { Activity, BookOpen, Building, + ChevronDown, + ChevronRight, FolderGit2, KeyRound, LayoutDashboard, @@ -14,6 +17,7 @@ import { Users, Zap, } from 'lucide-react'; +import { useEffect, useState } from 'react'; interface SidebarProps { user: { name: string; email: string; role: string } | undefined; @@ -66,6 +70,69 @@ function NavLink({ ); } +interface ProjectNavItemProps { + project: { id: string; name: string }; + currentPath: string; +} + +function ProjectNavItem({ project, currentPath }: ProjectNavItemProps) { + const activeProject = isProjectActive(currentPath, project.id); + const [isExpanded, setIsExpanded] = useState(activeProject); + + // Sync expansion state when the active project changes due to URL navigation + useEffect(() => { + if (activeProject) { + setIsExpanded(true); + } + }, [activeProject]); + + return ( +