Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ vi.mock('@/components/course-outline', () => ({
CourseOutline: () => <div data-testid="course-outline">CourseOutline</div>,
}));

// 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: () => <div data-testid="course-outline-sidebar">CourseOutlineSidebar</div>,
}));

// Mock CourseOutlineDrawer
vi.mock('@/components/course-outline-drawer', () => ({
CourseOutlineDrawer: () => <div data-testid="course-outline-drawer">CourseOutlineDrawer</div>,
Expand Down Expand Up @@ -284,13 +291,13 @@ describe('CourseContentLayout', () => {
expect(screen.getByTestId('course-outline-drawer')).toBeInTheDocument();
});

it('renders CourseOutline in sidebar', () => {
it('renders CourseOutlineSidebar', () => {
render(
<CourseContentLayout params={defaultParams}>
<div>children</div>
</CourseContentLayout>,
);
expect(screen.getByTestId('course-outline')).toBeInTheDocument();
expect(screen.getByTestId('course-outline-sidebar')).toBeInTheDocument();
});

it('renders course navigation tabs (Agent, Course, Progress, Dates, Discussion)', () => {
Expand Down
17 changes: 4 additions & 13 deletions app/platform/[tenant]/course-content/[course_id]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -286,17 +286,8 @@ export default function CourseContentLayout({
}}
>
<main className="flex flex-1 overflow-hidden">
{/* Course sidebar */}
<div
className="hidden w-72 overflow-y-auto border-r border-gray-200 pl-4 md:block"
style={{ scrollbarWidth: 'none', height: 'calc(100% - 60px)' }}
>
<div className="border-b border-gray-200 p-4">
<h2 className="font-semibold text-gray-800">{course?.display_name}</h2>
</div>

<CourseOutline />
</div>
{/* Course sidebar (collapsible on tablet / small screens) */}
<CourseOutlineSidebar />

{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
Expand Down Expand Up @@ -518,7 +509,7 @@ export default function CourseContentLayout({
<div className="flex items-center bg-gray-50 px-4 py-2">
<button
onClick={() => 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"
>
<ListTree className="h-5 w-5" /> {/* Changed icon to ListTree */}
Expand Down
114 changes: 114 additions & 0 deletions components/__tests__/course-outline-sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <span data-testid="panel-left-open" />,
PanelLeftClose: () => <span data-testid="panel-left-close" />,
}));

// Mock the outline tree itself.
vi.mock('@/components/course-outline', () => ({
CourseOutline: () => <div data-testid="course-outline">CourseOutline</div>,
}));

import { CourseOutlineSidebar, OUTLINE_COLLAPSED_KEY } from '@/components/course-outline-sidebar';

const renderSidebar = (course: any = { display_name: 'My Course' }) =>
render(
<CourseOutlineContext.Provider value={{ course } as any}>
<CourseOutlineSidebar />
</CourseOutlineContext.Provider>,
);

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();
});
});
97 changes: 97 additions & 0 deletions components/course-outline-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(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 */}
<div
className={`${railClass} w-12 flex-shrink-0 flex-col items-center border-r border-gray-200 pt-2`}
style={{ height: 'calc(100% - 60px)' }}
data-testid="course-outline-rail"
>
<button
type="button"
onClick={handleExpand}
className="rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:ring-2 focus:ring-amber-500 focus:outline-none"
aria-label="Expand course outline"
title="Show course outline"
data-testid="expand-course-outline"
>
<PanelLeftOpen className="h-5 w-5" />
</button>
</div>

{/* Expanded outline — full sidebar */}
<div
className={`${fullClass} w-72 flex-shrink-0 overflow-y-auto border-r border-gray-200 pl-4`}
style={{ scrollbarWidth: 'none', height: 'calc(100% - 60px)' }}
data-testid="course-outline-sidebar"
>
<div className="flex items-center justify-between border-b border-gray-200 p-4">
<h2 className="font-semibold text-gray-800">{course?.display_name}</h2>
<button
type="button"
onClick={handleCollapse}
className={`-mr-1 ${collapseBtnClass} rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:ring-2 focus:ring-amber-500 focus:outline-none`}
aria-label="Collapse course outline"
title="Hide course outline"
data-testid="collapse-course-outline"
>
<PanelLeftClose className="h-5 w-5" />
</button>
</div>

<CourseOutline />
</div>
</>
);
};
2 changes: 1 addition & 1 deletion e2e/journeys/33-analytics-audit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading