From c906903a53896ce61324df443e1d2ae7072f3b7d Mon Sep 17 00:00:00 2001 From: ANIMASHAUN Michael Date: Thu, 18 Jun 2026 14:51:10 +0100 Subject: [PATCH 1/3] feat: outline collapsible on tablet and small pc screens --- .../[course_id]/__tests__/layout.test.tsx | 11 +- .../course-content/[course_id]/layout.tsx | 17 +- .../__tests__/course-outline-sidebar.test.tsx | 164 ++++++++++++++++++ components/course-outline-sidebar.tsx | 142 +++++++++++++++ 4 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 components/__tests__/course-outline-sidebar.test.tsx create mode 100644 components/course-outline-sidebar.tsx 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({
, + PopoverContent: ({ children, 'data-testid': dataTestId, className }: any) => ( +
+ {children} +
+ ), +})); + +import { + CourseOutlineSidebar, + OUTLINE_COLLAPSED_KEY, + OUTLINE_HINT_SEEN_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.isDesktop = false; + mockMedia.isTablet = 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 collapsed (rail shown, full outline hidden) in the tablet range', () => { + renderSidebar(); + + expect(tokens('course-outline-rail')).toContain('flex'); + expect(tokens('course-outline-rail')).not.toContain('hidden'); + expect(tokens('course-outline-sidebar')).toContain('hidden'); + }); + + it('expands and persists collapsed=false when the rail button is clicked', () => { + renderSidebar(); + + fireEvent.click(screen.getByTestId('expand-course-outline')); + + expect(window.localStorage.getItem(OUTLINE_COLLAPSED_KEY)).toBe('false'); + // Expanding also dismisses the first-time hint. + expect(window.localStorage.getItem(OUTLINE_HINT_SEEN_KEY)).toBe('true'); + + // Rail is hidden and the full outline is shown once expanded. + expect(tokens('course-outline-rail')).toContain('hidden'); + expect(tokens('course-outline-sidebar')).toContain('block'); + }); + + it('collapses and persists collapsed=true when the header button is clicked', () => { + window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'false'); + 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('reads the persisted expanded state from localStorage', () => { + window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'false'); + renderSidebar(); + + expect(tokens('course-outline-sidebar')).toContain('block'); + expect(tokens('course-outline-rail')).toContain('hidden'); + }); + + it('always shows the full outline and no collapse controls at xl (>=1280px)', () => { + mockMedia.isDesktop = true; + mockMedia.isTablet = false; + renderSidebar(); // default collapsed — must not affect desktop + + expect(tokens('course-outline-sidebar')).toContain('block'); + expect(tokens('course-outline-rail')).toContain('hidden'); + expect(tokens('collapse-course-outline')).toContain('hidden'); + }); + + it('keeps the full outline visible at xl even when collapsed was set on tablet', () => { + window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'true'); + mockMedia.isDesktop = true; + mockMedia.isTablet = false; + renderSidebar(); + + expect(tokens('course-outline-sidebar')).toContain('block'); + expect(tokens('course-outline-rail')).toContain('hidden'); + }); + + it('shows the first-time hint in the tablet range and hides it once dismissed', () => { + renderSidebar(); + + expect(screen.getByTestId('popover').getAttribute('data-open')).toBe('true'); + + fireEvent.click(screen.getByText('Got it')); + + expect(window.localStorage.getItem(OUTLINE_HINT_SEEN_KEY)).toBe('true'); + expect(screen.getByTestId('popover').getAttribute('data-open')).toBe('false'); + }); + + it('does not show the hint when it has already been seen', () => { + window.localStorage.setItem(OUTLINE_HINT_SEEN_KEY, 'true'); + renderSidebar(); + + expect(screen.getByTestId('popover').getAttribute('data-open')).toBe('false'); + }); + + it('does not show the hint outside the tablet range', () => { + mockMedia.isTablet = false; + mockMedia.isDesktop = true; + renderSidebar(); + + expect(screen.getByTestId('popover').getAttribute('data-open')).toBe('false'); + }); +}); diff --git a/components/course-outline-sidebar.tsx b/components/course-outline-sidebar.tsx new file mode 100644 index 0000000..46e0e47 --- /dev/null +++ b/components/course-outline-sidebar.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useContext, useEffect, useState } from 'react'; + +import { ListTree, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; +import { useMediaQuery } from 'react-responsive'; + +import { CourseOutline } from '@/components/course-outline'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { CourseOutlineContext } from '@/contexts/course-outline-context'; +import { useLocalStorage } from '@/hooks/localstorage/use-local-storage'; + +export const OUTLINE_COLLAPSED_KEY = 'course-outline-collapsed'; +export const OUTLINE_HINT_SEEN_KEY = 'course-outline-collapse-hint-seen'; + +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); + const [collapsed, setCollapsed] = useLocalStorage(OUTLINE_COLLAPSED_KEY, true, { + initializeWithValue: false, + ...BOOLEAN_STORAGE, + }); + const [hintSeen, setHintSeen] = useLocalStorage(OUTLINE_HINT_SEEN_KEY, false, { + initializeWithValue: false, + ...BOOLEAN_STORAGE, + }); + + const isDesktop = useMediaQuery({ minWidth: 1280 }); // xl and up + const isTabletRange = useMediaQuery({ minWidth: 768, maxWidth: 1279 }); // md–xl + + useEffect(() => { + setMounted(true); + }, []); + + const showFull = isDesktop || (isTabletRange && !collapsed); + + const showRail = isTabletRange && collapsed; + + const showCollapseControl = isTabletRange; + const showHint = mounted && isTabletRange && collapsed && !hintSeen; + + 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); + setHintSeen(true); + }; + + const handleCollapse = () => { + setCollapsed(true); + }; + + const dismissHint = () => { + setHintSeen(true); + }; + + return ( + <> + {/* Collapsed rail — tablet range only, while collapsed */} +
+ { + if (!open) dismissHint(); + }} + > + + + + +
+ +
+

Course outline hidden

+

+ We collapsed the outline to give the content more room. Tap this button to show it + anytime. +

+ +
+
+
+
+
+ + {/* Expanded outline — full sidebar */} +
+
+

{course?.display_name}

+ +
+ + +
+ + ); +}; From bcbfe79a165b3604917809f08600303ce4605a9f Mon Sep 17 00:00:00 2001 From: ANIMASHAUN Michael Date: Thu, 18 Jun 2026 16:06:49 +0100 Subject: [PATCH 2/3] feat: outline should appear uncollapse and no longer have the tooltips --- .../__tests__/course-outline-sidebar.test.tsx | 106 +++++------------- components/course-outline-sidebar.tsx | 83 ++++---------- 2 files changed, 47 insertions(+), 142 deletions(-) diff --git a/components/__tests__/course-outline-sidebar.test.tsx b/components/__tests__/course-outline-sidebar.test.tsx index 3379b59..7c73ace 100644 --- a/components/__tests__/course-outline-sidebar.test.tsx +++ b/components/__tests__/course-outline-sidebar.test.tsx @@ -5,12 +5,11 @@ import React from 'react'; import { CourseOutlineContext } from '@/contexts/course-outline-context'; -// Controllable media-query results. Default: tablet range, not desktop. -const mockMedia = vi.hoisted(() => ({ isDesktop: false, isTablet: true })); +// 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 === 1280 && !q.maxWidth) return mockMedia.isDesktop; - if (q.minWidth === 768 && q.maxWidth === 1279) return mockMedia.isTablet; + if (q.minWidth === 768 && !q.maxWidth) return mockMedia.isWide; return false; }), })); @@ -19,7 +18,6 @@ vi.mock('react-responsive', () => ({ vi.mock('lucide-react', () => ({ PanelLeftOpen: () => , PanelLeftClose: () => , - ListTree: () => , })); // Mock the outline tree itself. @@ -27,28 +25,7 @@ vi.mock('@/components/course-outline', () => ({ CourseOutline: () =>
CourseOutline
, })); -// Popover mock that reflects the controlled `open` prop so we can assert the -// first-time hint visibility, and forwards `asChild` triggers cleanly. -vi.mock('@/components/ui/popover', () => ({ - Popover: ({ children, open }: any) => ( -
- {children} -
- ), - PopoverTrigger: ({ children, asChild, ...rest }: any) => - asChild ? children : , - PopoverContent: ({ children, 'data-testid': dataTestId, className }: any) => ( -
- {children} -
- ), -})); - -import { - CourseOutlineSidebar, - OUTLINE_COLLAPSED_KEY, - OUTLINE_HINT_SEEN_KEY, -} from '@/components/course-outline-sidebar'; +import { CourseOutlineSidebar, OUTLINE_COLLAPSED_KEY } from '@/components/course-outline-sidebar'; const renderSidebar = (course: any = { display_name: 'My Course' }) => render( @@ -62,8 +39,7 @@ const tokens = (testId: string) => screen.getByTestId(testId).className.split(/\ describe('CourseOutlineSidebar', () => { beforeEach(() => { window.localStorage.clear(); - mockMedia.isDesktop = false; - mockMedia.isTablet = true; + mockMedia.isWide = true; }); it('renders the rail expand button, header collapse button, and the outline', () => { @@ -75,30 +51,20 @@ describe('CourseOutlineSidebar', () => { expect(screen.getByText('My Course')).toBeInTheDocument(); }); - it('defaults to collapsed (rail shown, full outline hidden) in the tablet range', () => { + it('defaults to expanded (full outline shown, rail hidden)', () => { renderSidebar(); - expect(tokens('course-outline-rail')).toContain('flex'); - expect(tokens('course-outline-rail')).not.toContain('hidden'); - expect(tokens('course-outline-sidebar')).toContain('hidden'); + expect(tokens('course-outline-sidebar')).toContain('block'); + expect(tokens('course-outline-rail')).toContain('hidden'); }); - it('expands and persists collapsed=false when the rail button is clicked', () => { + it('offers the collapse control at every width >= 768px (incl. desktop)', () => { renderSidebar(); - fireEvent.click(screen.getByTestId('expand-course-outline')); - - expect(window.localStorage.getItem(OUTLINE_COLLAPSED_KEY)).toBe('false'); - // Expanding also dismisses the first-time hint. - expect(window.localStorage.getItem(OUTLINE_HINT_SEEN_KEY)).toBe('true'); - - // Rail is hidden and the full outline is shown once expanded. - expect(tokens('course-outline-rail')).toContain('hidden'); - expect(tokens('course-outline-sidebar')).toContain('block'); + expect(tokens('collapse-course-outline')).toContain('inline-flex'); }); it('collapses and persists collapsed=true when the header button is clicked', () => { - window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'false'); renderSidebar(); fireEvent.click(screen.getByTestId('collapse-course-outline')); @@ -108,57 +74,41 @@ describe('CourseOutlineSidebar', () => { expect(tokens('course-outline-sidebar')).toContain('hidden'); }); - it('reads the persisted expanded state from localStorage', () => { - window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'false'); + it('expands and persists collapsed=false when the rail button is clicked', () => { + window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'true'); renderSidebar(); - expect(tokens('course-outline-sidebar')).toContain('block'); - expect(tokens('course-outline-rail')).toContain('hidden'); - }); + // Collapsed → rail is shown. + expect(tokens('course-outline-rail')).toContain('flex'); - it('always shows the full outline and no collapse controls at xl (>=1280px)', () => { - mockMedia.isDesktop = true; - mockMedia.isTablet = false; - renderSidebar(); // default collapsed — must not affect desktop + fireEvent.click(screen.getByTestId('expand-course-outline')); - expect(tokens('course-outline-sidebar')).toContain('block'); + expect(window.localStorage.getItem(OUTLINE_COLLAPSED_KEY)).toBe('false'); expect(tokens('course-outline-rail')).toContain('hidden'); - expect(tokens('collapse-course-outline')).toContain('hidden'); - }); - - it('keeps the full outline visible at xl even when collapsed was set on tablet', () => { - window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'true'); - mockMedia.isDesktop = true; - mockMedia.isTablet = false; - renderSidebar(); - expect(tokens('course-outline-sidebar')).toContain('block'); - expect(tokens('course-outline-rail')).toContain('hidden'); }); - it('shows the first-time hint in the tablet range and hides it once dismissed', () => { + it('reads the persisted collapsed state from localStorage', () => { + window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'true'); renderSidebar(); - expect(screen.getByTestId('popover').getAttribute('data-open')).toBe('true'); - - fireEvent.click(screen.getByText('Got it')); - - expect(window.localStorage.getItem(OUTLINE_HINT_SEEN_KEY)).toBe('true'); - expect(screen.getByTestId('popover').getAttribute('data-open')).toBe('false'); + expect(tokens('course-outline-rail')).toContain('flex'); + expect(tokens('course-outline-sidebar')).toContain('hidden'); }); - it('does not show the hint when it has already been seen', () => { - window.localStorage.setItem(OUTLINE_HINT_SEEN_KEY, 'true'); + it('hides everything below 768px (drawer handles the outline there)', () => { + mockMedia.isWide = false; renderSidebar(); - expect(screen.getByTestId('popover').getAttribute('data-open')).toBe('false'); + expect(tokens('course-outline-rail')).toContain('hidden'); + expect(tokens('course-outline-sidebar')).toContain('hidden'); }); - it('does not show the hint outside the tablet range', () => { - mockMedia.isTablet = false; - mockMedia.isDesktop = true; + it('does not render the first-time hint popover', () => { + window.localStorage.setItem(OUTLINE_COLLAPSED_KEY, 'true'); renderSidebar(); - expect(screen.getByTestId('popover').getAttribute('data-open')).toBe('false'); + 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 index 46e0e47..b0c3577 100644 --- a/components/course-outline-sidebar.tsx +++ b/components/course-outline-sidebar.tsx @@ -2,16 +2,14 @@ import { useContext, useEffect, useState } from 'react'; -import { ListTree, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; +import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { useMediaQuery } from 'react-responsive'; import { CourseOutline } from '@/components/course-outline'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { CourseOutlineContext } from '@/contexts/course-outline-context'; import { useLocalStorage } from '@/hooks/localstorage/use-local-storage'; export const OUTLINE_COLLAPSED_KEY = 'course-outline-collapsed'; -export const OUTLINE_HINT_SEEN_KEY = 'course-outline-collapse-hint-seen'; const BOOLEAN_STORAGE = { serializer: (value: boolean) => (value ? 'true' : 'false'), @@ -22,28 +20,23 @@ export const CourseOutlineSidebar = () => { const { course } = useContext(CourseOutlineContext); const [mounted, setMounted] = useState(false); - const [collapsed, setCollapsed] = useLocalStorage(OUTLINE_COLLAPSED_KEY, true, { - initializeWithValue: false, - ...BOOLEAN_STORAGE, - }); - const [hintSeen, setHintSeen] = useLocalStorage(OUTLINE_HINT_SEEN_KEY, false, { + // Expanded by default — the user has to explicitly collapse the outline. + const [collapsed, setCollapsed] = useLocalStorage(OUTLINE_COLLAPSED_KEY, false, { initializeWithValue: false, ...BOOLEAN_STORAGE, }); - const isDesktop = useMediaQuery({ minWidth: 1280 }); // xl and up - const isTabletRange = useMediaQuery({ minWidth: 768, maxWidth: 1279 }); // md–xl + // 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 = isDesktop || (isTabletRange && !collapsed); - - const showRail = isTabletRange && collapsed; - - const showCollapseControl = isTabletRange; - const showHint = mounted && isTabletRange && collapsed && !hintSeen; + 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'; @@ -51,68 +44,30 @@ export const CourseOutlineSidebar = () => { const handleExpand = () => { setCollapsed(false); - setHintSeen(true); }; const handleCollapse = () => { setCollapsed(true); }; - const dismissHint = () => { - setHintSeen(true); - }; - return ( <> - {/* Collapsed rail — tablet range only, while collapsed */} + {/* Collapsed rail — md (768px) and up, while collapsed */}
- { - if (!open) dismissHint(); - }} + - - -
- -
-

Course outline hidden

-

- We collapsed the outline to give the content more room. Tap this button to show it - anytime. -

- -
-
-
-
+ +
{/* Expanded outline — full sidebar */} From 596d50470d8cf70fb7a57e1d300b2ab55429f0ee Mon Sep 17 00:00:00 2001 From: ANIMASHAUN Michael Date: Thu, 18 Jun 2026 18:28:01 +0100 Subject: [PATCH 3/3] fix: failing audit playwright test fixed --- e2e/journeys/33-analytics-audit.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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);