diff --git a/app/platform/[tenant]/course-content/[course_id]/__tests__/layout.test.tsx b/app/platform/[tenant]/course-content/[course_id]/__tests__/layout.test.tsx
index 609cc49..ca1dae9 100644
--- a/app/platform/[tenant]/course-content/[course_id]/__tests__/layout.test.tsx
+++ b/app/platform/[tenant]/course-content/[course_id]/__tests__/layout.test.tsx
@@ -128,6 +128,13 @@ vi.mock('@/components/course-outline', () => ({
CourseOutline: () =>
CourseOutline
,
}));
+// Mock CourseOutlineSidebar — the collapsible-sidebar internals (rail, hint
+// popover, media queries) are exercised in its own test; here we only need to
+// confirm the layout mounts it.
+vi.mock('@/components/course-outline-sidebar', () => ({
+ CourseOutlineSidebar: () => CourseOutlineSidebar
,
+}));
+
// Mock CourseOutlineDrawer
vi.mock('@/components/course-outline-drawer', () => ({
CourseOutlineDrawer: () => CourseOutlineDrawer
,
@@ -284,13 +291,13 @@ describe('CourseContentLayout', () => {
expect(screen.getByTestId('course-outline-drawer')).toBeInTheDocument();
});
- it('renders CourseOutline in sidebar', () => {
+ it('renders CourseOutlineSidebar', () => {
render(
children
,
);
- expect(screen.getByTestId('course-outline')).toBeInTheDocument();
+ expect(screen.getByTestId('course-outline-sidebar')).toBeInTheDocument();
});
it('renders course navigation tabs (Agent, Course, Progress, Dates, Discussion)', () => {
diff --git a/app/platform/[tenant]/course-content/[course_id]/layout.tsx b/app/platform/[tenant]/course-content/[course_id]/layout.tsx
index 6face2e..20944a8 100644
--- a/app/platform/[tenant]/course-content/[course_id]/layout.tsx
+++ b/app/platform/[tenant]/course-content/[course_id]/layout.tsx
@@ -14,7 +14,7 @@ import { AgentMode, EdxIframeContext } from '@/hooks/courses/edx-iframe-context'
import { getUserId, getUserName } from '@/utils/helpers';
import { useTenantParam } from '@/hooks/use-tenant-param';
import { CourseOutlineContext } from '@/contexts/course-outline-context';
-import { CourseOutline } from '@/components/course-outline';
+import { CourseOutlineSidebar } from '@/components/course-outline-sidebar';
import { CourseOutlineDrawer } from '@/components/course-outline-drawer';
import { CourseAccessGuard } from '@/components/course-access-guard';
import { CourseLessonNavigator } from '@/components/course-lesson-navigator';
@@ -286,17 +286,8 @@ export default function CourseContentLayout({
}}
>
- {/* Course sidebar */}
-
-
-
{course?.display_name}
-
-
-
-
+ {/* Course sidebar (collapsible on tablet / small screens) */}
+
{/* Main content area */}
@@ -518,7 +509,7 @@ export default function CourseContentLayout({
setCourseOutlineDrawerOpen(true)} // Open the new course outline drawer
- className="mr-2 -ml-2 p-2 text-gray-600 hover:text-gray-900 focus:ring-2 focus:ring-amber-500 focus:outline-none focus:ring-inset xl:hidden" // Updated here
+ className="mr-2 -ml-2 p-2 text-gray-600 hover:text-gray-900 focus:ring-2 focus:ring-amber-500 focus:outline-none focus:ring-inset md:hidden" // Mobile only; tablet/laptop use the inline collapsible sidebar
aria-label="Open course outline"
>
{/* Changed icon to ListTree */}
diff --git a/components/__tests__/course-outline-sidebar.test.tsx b/components/__tests__/course-outline-sidebar.test.tsx
new file mode 100644
index 0000000..7c73ace
--- /dev/null
+++ b/components/__tests__/course-outline-sidebar.test.tsx
@@ -0,0 +1,114 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import React from 'react';
+
+import { CourseOutlineContext } from '@/contexts/course-outline-context';
+
+// Controllable media-query result. `isWide` = md (768px) and up. Default: wide.
+const mockMedia = vi.hoisted(() => ({ isWide: true }));
+vi.mock('react-responsive', () => ({
+ useMediaQuery: vi.fn((q: any) => {
+ if (q.minWidth === 768 && !q.maxWidth) return mockMedia.isWide;
+ return false;
+ }),
+}));
+
+// Mock lucide icons used by the component.
+vi.mock('lucide-react', () => ({
+ PanelLeftOpen: () => ,
+ PanelLeftClose: () => ,
+}));
+
+// Mock the outline tree itself.
+vi.mock('@/components/course-outline', () => ({
+ CourseOutline: () => CourseOutline
,
+}));
+
+import { CourseOutlineSidebar, OUTLINE_COLLAPSED_KEY } from '@/components/course-outline-sidebar';
+
+const renderSidebar = (course: any = { display_name: 'My Course' }) =>
+ render(
+
+
+ ,
+ );
+
+const tokens = (testId: string) => screen.getByTestId(testId).className.split(/\s+/);
+
+describe('CourseOutlineSidebar', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ mockMedia.isWide = true;
+ });
+
+ it('renders the rail expand button, header collapse button, and the outline', () => {
+ renderSidebar({ display_name: 'My Course' });
+
+ expect(screen.getByTestId('expand-course-outline')).toBeInTheDocument();
+ expect(screen.getByTestId('collapse-course-outline')).toBeInTheDocument();
+ expect(screen.getByTestId('course-outline')).toBeInTheDocument();
+ expect(screen.getByText('My Course')).toBeInTheDocument();
+ });
+
+ it('defaults to expanded (full outline shown, rail hidden)', () => {
+ renderSidebar();
+
+ expect(tokens('course-outline-sidebar')).toContain('block');
+ expect(tokens('course-outline-rail')).toContain('hidden');
+ });
+
+ it('offers the collapse control at every width >= 768px (incl. desktop)', () => {
+ renderSidebar();
+
+ expect(tokens('collapse-course-outline')).toContain('inline-flex');
+ });
+
+ it('collapses and persists collapsed=true when the header button is clicked', () => {
+ renderSidebar();
+
+ fireEvent.click(screen.getByTestId('collapse-course-outline'));
+
+ expect(window.localStorage.getItem(OUTLINE_COLLAPSED_KEY)).toBe('true');
+ expect(tokens('course-outline-rail')).toContain('flex');
+ expect(tokens('course-outline-sidebar')).toContain('hidden');
+ });
+
+ it('expands and persists collapsed=false when the rail button is clicked', () => {
+ window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'true');
+ renderSidebar();
+
+ // Collapsed → rail is shown.
+ expect(tokens('course-outline-rail')).toContain('flex');
+
+ fireEvent.click(screen.getByTestId('expand-course-outline'));
+
+ expect(window.localStorage.getItem(OUTLINE_COLLAPSED_KEY)).toBe('false');
+ expect(tokens('course-outline-rail')).toContain('hidden');
+ expect(tokens('course-outline-sidebar')).toContain('block');
+ });
+
+ it('reads the persisted collapsed state from localStorage', () => {
+ window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'true');
+ renderSidebar();
+
+ expect(tokens('course-outline-rail')).toContain('flex');
+ expect(tokens('course-outline-sidebar')).toContain('hidden');
+ });
+
+ it('hides everything below 768px (drawer handles the outline there)', () => {
+ mockMedia.isWide = false;
+ renderSidebar();
+
+ expect(tokens('course-outline-rail')).toContain('hidden');
+ expect(tokens('course-outline-sidebar')).toContain('hidden');
+ });
+
+ it('does not render the first-time hint popover', () => {
+ window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'true');
+ renderSidebar();
+
+ expect(screen.queryByTestId('course-outline-hint')).not.toBeInTheDocument();
+ expect(screen.queryByText('Course outline hidden')).not.toBeInTheDocument();
+ });
+});
diff --git a/components/course-outline-sidebar.tsx b/components/course-outline-sidebar.tsx
new file mode 100644
index 0000000..b0c3577
--- /dev/null
+++ b/components/course-outline-sidebar.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { useContext, useEffect, useState } from 'react';
+
+import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
+import { useMediaQuery } from 'react-responsive';
+
+import { CourseOutline } from '@/components/course-outline';
+import { CourseOutlineContext } from '@/contexts/course-outline-context';
+import { useLocalStorage } from '@/hooks/localstorage/use-local-storage';
+
+export const OUTLINE_COLLAPSED_KEY = 'course-outline-collapsed';
+
+const BOOLEAN_STORAGE = {
+ serializer: (value: boolean) => (value ? 'true' : 'false'),
+ deserializer: (value: string) => value === 'true',
+};
+
+export const CourseOutlineSidebar = () => {
+ const { course } = useContext(CourseOutlineContext);
+
+ const [mounted, setMounted] = useState(false);
+ // Expanded by default — the user has to explicitly collapse the outline.
+ const [collapsed, setCollapsed] = useLocalStorage(OUTLINE_COLLAPSED_KEY, false, {
+ initializeWithValue: false,
+ ...BOOLEAN_STORAGE,
+ });
+
+ // The collapse feature is available at every width from md (768px) upwards;
+ // below md the outline lives in the drawer (handled in the layout).
+ const isWide = useMediaQuery({ minWidth: 768 });
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const showFull = isWide && !collapsed;
+ const showRail = isWide && collapsed;
+ const showCollapseControl = isWide;
+
+ const railClass = !mounted ? 'hidden' : showRail ? 'flex' : 'hidden';
+ const fullClass = !mounted ? 'hidden md:block' : showFull ? 'block' : 'hidden';
+ const collapseBtnClass = mounted && showCollapseControl ? 'inline-flex' : 'hidden';
+
+ const handleExpand = () => {
+ setCollapsed(false);
+ };
+
+ const handleCollapse = () => {
+ setCollapsed(true);
+ };
+
+ return (
+ <>
+ {/* Collapsed rail — md (768px) and up, while collapsed */}
+
+
+ {/* Expanded outline — full sidebar */}
+
+
+
{course?.display_name}
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/e2e/journeys/33-analytics-audit.spec.ts b/e2e/journeys/33-analytics-audit.spec.ts
index 47b793a..bccef8d 100644
--- a/e2e/journeys/33-analytics-audit.spec.ts
+++ b/e2e/journeys/33-analytics-audit.spec.ts
@@ -81,7 +81,7 @@ test.describe('Journey 33: Analytics Audit', () => {
}
logger.info('CP-2: checking user search and action filters');
- const userSearch = page.getByRole('button', { name: 'Search for User' });
+ const userSearch = page.getByRole('combobox', { name: 'Search for User' });
const actionFilter = page.getByText('All Actions');
const hasUserSearch = await userSearch.isVisible({ timeout: 120_000 }).catch(() => false);