From 06ae806746ee78e6ade2dd040d107f0353bace2d Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 4 Dec 2025 12:59:06 -0500 Subject: [PATCH 1/8] feat: Make selectable Section, Subsection, unit cards in Course Outline --- .../common/context/SidebarContext.tsx | 56 +++++++++++++++++++ .../drag-helper/SortableItem.tsx | 12 ++++ .../section-card/SectionCard.tsx | 16 +++++- .../subsection-card/SubsectionCard.tsx | 16 +++++- src/course-outline/unit-card/UnitCard.tsx | 17 +++++- src/index.scss | 5 ++ 6 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/course-outline/common/context/SidebarContext.tsx diff --git a/src/course-outline/common/context/SidebarContext.tsx b/src/course-outline/common/context/SidebarContext.tsx new file mode 100644 index 0000000000..93a3c5c5f4 --- /dev/null +++ b/src/course-outline/common/context/SidebarContext.tsx @@ -0,0 +1,56 @@ +import { createContext, useCallback, useContext, useMemo, useState } from "react"; + +export type SidebarContextData = { + selectedContainerId?: string; + openContainerInfoSidebar: (containerId: string) => void; +}; + +/** + * Course Outline Sidebar Context. + * + * Get this using `useSidebarContext()` + * + */ +const SidebarContext = createContext(undefined); + +type SidebarProviderProps = { + children?: React.ReactNode; +}; + +export const SidebarProvider = ({ children }: SidebarProviderProps) => { + const [ selectedContainerId, setSelectedContainerId ] = useState(); + + const openContainerInfoSidebar = useCallback((containerId: string) => { + setSelectedContainerId(containerId); + }, [setSelectedContainerId]); + + const context = useMemo(() => { + const contextValue = { + selectedContainerId, + openContainerInfoSidebar, + }; + + return contextValue; + }, [ + selectedContainerId, + openContainerInfoSidebar, + ]); + + return ( + + {children} + + ); +}; + +export function useSidebarContext(): SidebarContextData { + const ctx = useContext(SidebarContext); + if (ctx === undefined) { + /* istanbul ignore next */ + return { + selectedContainerId: undefined, + openContainerInfoSidebar: () => {}, + }; + } + return ctx; +} diff --git a/src/course-outline/drag-helper/SortableItem.tsx b/src/course-outline/drag-helper/SortableItem.tsx index 8f5306c646..4f88d170a2 100644 --- a/src/course-outline/drag-helper/SortableItem.tsx +++ b/src/course-outline/drag-helper/SortableItem.tsx @@ -21,6 +21,7 @@ interface SortableItemProps { isDraggable?: boolean; children: React.ReactNode; componentStyle?: object; + onClick?: (e: React.MouseEvent) => void; } const SortableItem = ({ @@ -30,6 +31,7 @@ const SortableItem = ({ componentStyle, data, children, + onClick, }: SortableItemProps) => { const intl = useIntl(); const { @@ -66,8 +68,18 @@ const SortableItem = ({ return ( { + if (!onClick) return; + + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(e); + } + }} > {children} diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 5547fc46bb..aecd70d111 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -29,6 +29,7 @@ import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import messages from './messages'; +import { useSidebarContext } from '../common/context/SidebarContext'; interface SectionCardProps { section: XBlock, @@ -77,6 +78,7 @@ const SectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); + const { selectedContainerId, openContainerInfoSidebar } = useSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === section.id; @@ -269,6 +271,12 @@ const SectionCard = ({ const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); + const onClickCard = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + openContainerInfoSidebar(section.id); + } + }, [openContainerInfoSidebar]); + return ( <>
diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 0e95a273ff..a609c0cdf6 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -30,6 +30,7 @@ import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import messages from './messages'; +import { useSidebarContext } from '../common/context/SidebarContext'; interface SubsectionCardProps { section: XBlock, @@ -88,6 +89,7 @@ const SubsectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); + const { selectedContainerId, openContainerInfoSidebar } = useSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === subsection.id; @@ -269,6 +271,12 @@ const SubsectionCard = ({ closeAddLibraryUnitModal(); }, [id, onAddUnitFromLibrary, closeAddLibraryUnitModal]); + const onClickCard = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + openContainerInfoSidebar(subsection.id); + } + }, [openContainerInfoSidebar]); + return ( <>
diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 9c153b4fdf..219cdc8425 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -4,6 +4,7 @@ import { useMemo, useRef, } from 'react'; +import classNames from 'classnames'; import { useDispatch } from 'react-redux'; import { useToggle } from '@openedx/paragon'; import { isEmpty } from 'lodash'; @@ -24,6 +25,7 @@ import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import type { XBlock } from '@src/data/types'; +import { useSidebarContext } from '../common/context/SidebarContext'; interface UnitCardProps { unit: XBlock; @@ -70,6 +72,7 @@ const UnitCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const [searchParams] = useSearchParams(); + const { selectedContainerId, openContainerInfoSidebar } = useSidebarContext(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === unit.id; const [isFormOpen, openForm, closeForm] = useToggle(false); @@ -213,6 +216,12 @@ const UnitCard = ({ && !subsection.upstreamInfo?.upstreamRef ); + const onClickCard = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + openContainerInfoSidebar(unit.id); + } + }, [openContainerInfoSidebar]); + return ( <>
diff --git a/src/index.scss b/src/index.scss index 57cad42c85..431cb9cf10 100644 --- a/src/index.scss +++ b/src/index.scss @@ -39,6 +39,11 @@ div.row:has(> div > div.highlight) { animation-timing-function: cubic-bezier(1, 0, .72, .04); } +// To apply selection style to selected Section/Subsecion/Units, in the Course Outline +div.row:has(> div > div.outline-card-selected) { + box-shadow: 0 0 3px 3px var(--pgn-color-primary-500) !important; +} + // To apply the glow effect to the selected xblock, in the Unit Outline div.xblock-highlight { animation: 5s glow; From 071c47cb5e8b4a5d49ff16f87a74118e3f265be1 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 4 Dec 2025 15:22:04 -0500 Subject: [PATCH 2/8] test: Tests added for selected cards --- .../common/context/SidebarContext.tsx | 10 +-- .../drag-helper/SortableItem.tsx | 4 +- .../section-card/SectionCard.test.tsx | 69 ++++++++++++------ .../section-card/SectionCard.tsx | 7 +- .../subsection-card/SubsectionCard.test.tsx | 71 +++++++++++++------ .../subsection-card/SubsectionCard.tsx | 7 +- .../unit-card/UnitCard.test.tsx | 69 ++++++++++++------ src/course-outline/unit-card/UnitCard.tsx | 19 ++--- 8 files changed, 168 insertions(+), 88 deletions(-) diff --git a/src/course-outline/common/context/SidebarContext.tsx b/src/course-outline/common/context/SidebarContext.tsx index 93a3c5c5f4..b7b6ed6fda 100644 --- a/src/course-outline/common/context/SidebarContext.tsx +++ b/src/course-outline/common/context/SidebarContext.tsx @@ -1,8 +1,10 @@ -import { createContext, useCallback, useContext, useMemo, useState } from "react"; +import { + createContext, useCallback, useContext, useMemo, useState, +} from 'react'; export type SidebarContextData = { selectedContainerId?: string; - openContainerInfoSidebar: (containerId: string) => void; + openContainerInfoSidebar: (containerId: string) => void; }; /** @@ -18,7 +20,7 @@ type SidebarProviderProps = { }; export const SidebarProvider = ({ children }: SidebarProviderProps) => { - const [ selectedContainerId, setSelectedContainerId ] = useState(); + const [selectedContainerId, setSelectedContainerId] = useState(); const openContainerInfoSidebar = useCallback((containerId: string) => { setSelectedContainerId(containerId); @@ -27,7 +29,7 @@ export const SidebarProvider = ({ children }: SidebarProviderProps) => { const context = useMemo(() => { const contextValue = { selectedContainerId, - openContainerInfoSidebar, + openContainerInfoSidebar, }; return contextValue; diff --git a/src/course-outline/drag-helper/SortableItem.tsx b/src/course-outline/drag-helper/SortableItem.tsx index 4f88d170a2..d51f71e690 100644 --- a/src/course-outline/drag-helper/SortableItem.tsx +++ b/src/course-outline/drag-helper/SortableItem.tsx @@ -73,9 +73,9 @@ const SortableItem = ({ className="mx-0" onClick={onClick} onKeyDown={(e) => { - if (!onClick) return; + if (!onClick) { return; } - if (e.key === "Enter" || e.key === " ") { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(e); } diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index aca9cdd15d..1d43549a3d 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -3,6 +3,7 @@ import { } from '@src/testUtils'; import { XBlock } from '@src/data/types'; import SectionCard from './SectionCard'; +import { SidebarProvider } from '../common/context/SidebarContext'; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); @@ -82,28 +83,30 @@ const section = { const onEditSectionSubmit = jest.fn(); const renderComponent = (props?: object, entry = '/course/:courseId') => render( - - children - , + + + children + + , { path: '/course/:courseId', params: { courseId: '5' }, @@ -123,6 +126,28 @@ describe('', () => { expect(screen.getByTestId('section-card-header')).toBeInTheDocument(); expect(screen.getByTestId('section-card__content')).toBeInTheDocument(); + + // The card is not selected + const card = screen.getByTestId('section-card'); + expect(card).not.toHaveClass('outline-card-selected'); + }); + + it('render SectionCard component in selected state', () => { + const { container } = renderComponent(); + + expect(screen.getByTestId('section-card-header')).toBeInTheDocument(); + + // The card is not selected + const card = screen.getByTestId('section-card'); + expect(card).not.toHaveClass('outline-card-selected'); + + // Get the that contains the card and click it to select the card + const el = container.querySelector('div.row.mx-0') as HTMLInputElement; + expect(el).not.toBeNull(); + fireEvent.click(el!); + + // The card is selected + expect(card).toHaveClass('outline-card-selected'); }); it('expands/collapses the card when the expand button is clicked', () => { diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index aecd70d111..fd5e0609dc 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -295,11 +295,12 @@ const SectionCard = ({ onClick={onClickCard} >
render( - - children - , + + + children + + , { path: '/course/:courseId', params: { courseId: '5' }, @@ -148,6 +151,28 @@ describe('', () => { renderComponent(); expect(screen.getByTestId('subsection-card-header')).toBeInTheDocument(); + + // The card is not selected + const card = screen.getByTestId('subsection-card'); + expect(card).not.toHaveClass('outline-card-selected'); + }); + + it('render SubsectionCard component in selected state', () => { + const { container } = renderComponent(); + + expect(screen.getByTestId('subsection-card-header')).toBeInTheDocument(); + + // The card is not selected + const card = screen.getByTestId('subsection-card'); + expect(card).not.toHaveClass('outline-card-selected'); + + // Get the that contains the card and click it to select the card + const el = container.querySelector('div.row.mx-0') as HTMLInputElement; + expect(el).not.toBeNull(); + fireEvent.click(el!); + + // The card is selected + expect(card).toHaveClass('outline-card-selected'); }); it('expands/collapses the card when the subsection button is clicked', async () => { diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index a609c0cdf6..b58c0ab2a7 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -297,11 +297,12 @@ const SubsectionCard = ({ onClick={onClickCard} >
as XBlock; const renderComponent = (props?: object) => render( - `/some/${id}`} - isSelfPaced={false} - isCustomRelativeDatesActive={false} - discussionsSettings={{ - providerType: '', - enableGradedUnits: false, - }} - {...props} - />, + + `/some/${id}`} + isSelfPaced={false} + isCustomRelativeDatesActive={false} + discussionsSettings={{ + providerType: '', + enableGradedUnits: false, + }} + {...props} + /> + , { path: '/course/:courseId', params: { courseId: '5' }, @@ -115,6 +118,28 @@ describe('', () => { 'href', '/some/block-v1:UNIX+UX1+2025_T3+type@unit+block@0', ); + + // The card is not selected + const card = screen.getByTestId('unit-card'); + expect(card).not.toHaveClass('outline-card-selected'); + }); + + it('render UnitCard component in selected state', () => { + const { container } = renderComponent(); + + expect(screen.getByTestId('unit-card-header')).toBeInTheDocument(); + + // The card is not selected + const card = screen.getByTestId('unit-card'); + expect(card).not.toHaveClass('outline-card-selected'); + + // Get the that contains the card and click it to select the card + const el = container.querySelector('div.row.mx-0') as HTMLInputElement; + expect(el).not.toBeNull(); + fireEvent.click(el!); + + // The card is selected + expect(card).toHaveClass('outline-card-selected'); }); it('hides header based on isHeaderVisible flag', async () => { diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 219cdc8425..151875b9b9 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -168,6 +168,12 @@ const UnitCard = ({ } }, [dispatch, section, queryClient, courseId]); + const onClickCard = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + openContainerInfoSidebar(unit.id); + } + }, [openContainerInfoSidebar]); + const titleComponent = ( { - if (e.target === e.currentTarget) { - openContainerInfoSidebar(unit.id); - } - }, [openContainerInfoSidebar]); - return ( <>
Date: Fri, 19 Dec 2025 14:40:43 -0500 Subject: [PATCH 3/8] feat: Put selectable cards behind the new design flag --- .../common/context/SidebarContext.tsx | 58 ------------------- .../outline-sidebar/OutlineSidebarContext.tsx | 15 +++++ .../section-card/SectionCard.test.tsx | 11 +++- .../section-card/SectionCard.tsx | 4 +- .../subsection-card/SubsectionCard.test.tsx | 11 +++- .../subsection-card/SubsectionCard.tsx | 4 +- .../unit-card/UnitCard.test.tsx | 12 +++- src/course-outline/unit-card/UnitCard.tsx | 4 +- 8 files changed, 46 insertions(+), 73 deletions(-) delete mode 100644 src/course-outline/common/context/SidebarContext.tsx diff --git a/src/course-outline/common/context/SidebarContext.tsx b/src/course-outline/common/context/SidebarContext.tsx deleted file mode 100644 index b7b6ed6fda..0000000000 --- a/src/course-outline/common/context/SidebarContext.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - createContext, useCallback, useContext, useMemo, useState, -} from 'react'; - -export type SidebarContextData = { - selectedContainerId?: string; - openContainerInfoSidebar: (containerId: string) => void; -}; - -/** - * Course Outline Sidebar Context. - * - * Get this using `useSidebarContext()` - * - */ -const SidebarContext = createContext(undefined); - -type SidebarProviderProps = { - children?: React.ReactNode; -}; - -export const SidebarProvider = ({ children }: SidebarProviderProps) => { - const [selectedContainerId, setSelectedContainerId] = useState(); - - const openContainerInfoSidebar = useCallback((containerId: string) => { - setSelectedContainerId(containerId); - }, [setSelectedContainerId]); - - const context = useMemo(() => { - const contextValue = { - selectedContainerId, - openContainerInfoSidebar, - }; - - return contextValue; - }, [ - selectedContainerId, - openContainerInfoSidebar, - ]); - - return ( - - {children} - - ); -}; - -export function useSidebarContext(): SidebarContextData { - const ctx = useContext(SidebarContext); - if (ctx === undefined) { - /* istanbul ignore next */ - return { - selectedContainerId: undefined, - openContainerInfoSidebar: () => {}, - }; - } - return ctx; -} diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index fbe03d99b3..16c692fc7e 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -6,6 +6,7 @@ import { useState, } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; import { useToggle } from '@openedx/paragon'; import { HelpOutline, Info } from '@openedx/paragon/icons'; @@ -25,6 +26,8 @@ interface OutlineSidebarContextData { open: () => void; toggle: () => void; sidebarPages: OutlineSidebarPages; + selectedContainerId?: string; + openContainerInfoSidebar: (containerId: string) => void; } const OutlineSidebarContext = createContext(undefined); @@ -35,6 +38,14 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const [currentPageKey, setCurrentPageKeyState] = useState('info'); const [isOpen, open, , toggle] = useToggle(true); + const [selectedContainerId, setSelectedContainerId] = useState(); + + const openContainerInfoSidebar = useCallback((containerId: string) => { + if (getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true') { + setSelectedContainerId(containerId); + } + }, [setSelectedContainerId]); + const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => { setCurrentPageKeyState(pageKey); open(); @@ -61,6 +72,8 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod isOpen, open, toggle, + selectedContainerId, + openContainerInfoSidebar, }), [ currentPageKey, @@ -69,6 +82,8 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod isOpen, open, toggle, + selectedContainerId, + openContainerInfoSidebar, ], ); diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index 1d43549a3d..e6a8741d41 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -1,9 +1,10 @@ +import { getConfig, setConfig } from '@edx/frontend-platform'; import { act, fireEvent, initializeMocks, render, screen, waitFor, within, } from '@src/testUtils'; import { XBlock } from '@src/data/types'; import SectionCard from './SectionCard'; -import { SidebarProvider } from '../common/context/SidebarContext'; +import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); @@ -83,7 +84,7 @@ const section = { const onEditSectionSubmit = jest.fn(); const renderComponent = (props?: object, entry = '/course/:courseId') => render( - + render( > children - , + , { path: '/course/:courseId', params: { courseId: '5' }, @@ -133,6 +134,10 @@ describe('', () => { }); it('render SectionCard component in selected state', () => { + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', + }); const { container } = renderComponent(); expect(screen.getByTestId('section-card-header')).toBeInTheDocument(); diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index fd5e0609dc..75beb74ec1 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -29,7 +29,7 @@ import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import messages from './messages'; -import { useSidebarContext } from '../common/context/SidebarContext'; +import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface SectionCardProps { section: XBlock, @@ -78,7 +78,7 @@ const SectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); - const { selectedContainerId, openContainerInfoSidebar } = useSidebarContext(); + const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === section.id; diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index 059783e5da..1e8ae0c1ea 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -1,3 +1,4 @@ +import { getConfig, setConfig } from '@edx/frontend-platform'; import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { act, fireEvent, initializeMocks, render, screen, waitFor, within, @@ -5,7 +6,7 @@ import { import { XBlock } from '@src/data/types'; import cardHeaderMessages from '../card-header/messages'; import SubsectionCard from './SubsectionCard'; -import { SidebarProvider } from '../common/context/SidebarContext'; +import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; let store; const containerKey = 'lct:org:lib:unit:1'; @@ -107,7 +108,7 @@ const section: XBlock = { const onEditSubectionSubmit = jest.fn(); const renderComponent = (props?: object, entry = '/course/:courseId') => render( - + render( > children - , + , { path: '/course/:courseId', params: { courseId: '5' }, @@ -158,6 +159,10 @@ describe('', () => { }); it('render SubsectionCard component in selected state', () => { + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', + }); const { container } = renderComponent(); expect(screen.getByTestId('subsection-card-header')).toBeInTheDocument(); diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index b58c0ab2a7..2f402a66ce 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -30,7 +30,7 @@ import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import messages from './messages'; -import { useSidebarContext } from '../common/context/SidebarContext'; +import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface SubsectionCardProps { section: XBlock, @@ -89,7 +89,7 @@ const SubsectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); - const { selectedContainerId, openContainerInfoSidebar } = useSidebarContext(); + const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === subsection.id; diff --git a/src/course-outline/unit-card/UnitCard.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index 3bf7a503ed..7c72f0a93f 100644 --- a/src/course-outline/unit-card/UnitCard.test.tsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -1,3 +1,4 @@ +import { getConfig, setConfig } from '@edx/frontend-platform'; import { act, fireEvent, initializeMocks, render, screen, waitFor, within, } from '@src/testUtils'; @@ -5,7 +6,7 @@ import { import { XBlock } from '@src/data/types'; import UnitCard from './UnitCard'; import cardMessages from '../card-header/messages'; -import { SidebarProvider } from '../common/context/SidebarContext'; +import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); @@ -75,7 +76,7 @@ const unit = { } satisfies Partial as XBlock; const renderComponent = (props?: object) => render( - + render( }} {...props} /> - , + , { path: '/course/:courseId', params: { courseId: '5' }, @@ -125,6 +126,11 @@ describe('', () => { }); it('render UnitCard component in selected state', () => { + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', + }); + const { container } = renderComponent(); expect(screen.getByTestId('unit-card-header')).toBeInTheDocument(); diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 151875b9b9..c7e40e53b8 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -25,7 +25,7 @@ import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import type { XBlock } from '@src/data/types'; -import { useSidebarContext } from '../common/context/SidebarContext'; +import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface UnitCardProps { unit: XBlock; @@ -72,7 +72,7 @@ const UnitCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const [searchParams] = useSearchParams(); - const { selectedContainerId, openContainerInfoSidebar } = useSidebarContext(); + const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === unit.id; const [isFormOpen, openForm, closeForm] = useToggle(false); From 675c02684cbf1ef23f5e02385643cec1faae9ed1 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 6 Jan 2026 19:12:37 -0500 Subject: [PATCH 4/8] feat: New requirements for the selected container cards --- src/course-outline/OutlineAddChildButtons.tsx | 4 +- src/course-outline/card-header/CardHeader.tsx | 15 +++++-- .../card-header/TitleButton.tsx | 42 +++++++------------ src/course-outline/card-header/TitleLink.tsx | 2 +- .../section-card/SectionCard.tsx | 38 +++++++++++++---- .../subsection-card/SubsectionCard.tsx | 20 +++++++-- src/course-outline/unit-card/UnitCard.tsx | 1 + 7 files changed, 78 insertions(+), 44 deletions(-) diff --git a/src/course-outline/OutlineAddChildButtons.tsx b/src/course-outline/OutlineAddChildButtons.tsx index 7e31a5f57c..197a15f89a 100644 --- a/src/course-outline/OutlineAddChildButtons.tsx +++ b/src/course-outline/OutlineAddChildButtons.tsx @@ -9,6 +9,7 @@ import messages from './messages'; interface NewChildButtonsProps { handleNewButtonClick: () => void; handleUseFromLibraryClick: () => void; + onClickCard?: (e: React.MouseEvent) => void; childType: ContainerType; btnVariant?: string; btnClasses?: string; @@ -18,6 +19,7 @@ interface NewChildButtonsProps { const OutlineAddChildButtons = ({ handleNewButtonClick, handleUseFromLibraryClick, + onClickCard, childType, btnVariant = 'outline-primary', btnClasses = 'mt-4 border-gray-500 rounded-0', @@ -59,7 +61,7 @@ const OutlineAddChildButtons = ({ } return ( - +
} className="item-card-header__title-btn" onClick={onTitleClick} - title={title} - > -
- {prefixIcon} -
- - {title} - - - + size="inline" + /> +
+ {prefixIcon} +
+ + {title} + + ); }; diff --git a/src/course-outline/card-header/TitleLink.tsx b/src/course-outline/card-header/TitleLink.tsx index 0c0f402b26..89dfb5f7c0 100644 --- a/src/course-outline/card-header/TitleLink.tsx +++ b/src/course-outline/card-header/TitleLink.tsx @@ -26,7 +26,7 @@ const TitleLink = ({ to={titleLink} title={title} > - + {title} diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 75beb74ec1..d69749a0b5 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -271,9 +271,10 @@ const SectionCard = ({ const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); - const onClickCard = useCallback((e: React.MouseEvent) => { - if (e.target === e.currentTarget) { + const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { + if (!preventNodeEvents || e.target === e.currentTarget) { openContainerInfoSidebar(section.id); + setIsExpanded(true); } }, [openContainerInfoSidebar]); @@ -292,7 +293,7 @@ const SectionCard = ({ padding: '1.75rem', ...borderStyle, }} - onClick={onClickCard} + onClick={(e) => onClickCard(e, true)} >
onClickCard(e, true)} isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} @@ -333,7 +335,15 @@ const SectionCard = ({ /> )}
-
+ { + /* This is a special case; we can skip accessibility here since the + SectionCard handles that. This onClick allows the user to select the card + by clicking on white areas of this component. */ + } +
onClickCard(e, true)} + >
- + { + /* This is a special case; we can skip accessibility here since the + SectionCard handles that. This onClick allows the user to select the card + by clicking on white areas of this component. */ + } +
onClickCard(e, false)} + > + +
{isExpanded && (
onClickCard(e, true)} childType={ContainerType.Subsection} /> )} diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 2f402a66ce..9915c89e4c 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -271,9 +271,10 @@ const SubsectionCard = ({ closeAddLibraryUnitModal(); }, [id, onAddUnitFromLibrary, closeAddLibraryUnitModal]); - const onClickCard = useCallback((e: React.MouseEvent) => { - if (e.target === e.currentTarget) { + const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { + if (!preventNodeEvents || e.target === e.currentTarget) { openContainerInfoSidebar(subsection.id); + setIsExpanded(true); } }, [openContainerInfoSidebar]); @@ -294,7 +295,7 @@ const SubsectionCard = ({ background: '#f8f7f6', ...borderStyle, }} - onClick={onClickCard} + onClick={(e) => onClickCard(e, true)} >
onClickCard(e, true)} isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} @@ -336,7 +338,16 @@ const SubsectionCard = ({ extraActionsComponent={extraActionsComponent} readyToSync={upstreamInfo?.readyToSync} /> -
+ { + /* This is a special case; we can skip accessibility here since the + SectionCard handles that. This onClick allows the user to select the card + by clicking on white areas of this component. */ + } +
onClickCard(e, false)} + > onClickCard(e, true)} childType={ContainerType.Unit} /> {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && ( diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index c7e40e53b8..66684e73cf 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -265,6 +265,7 @@ const UnitCard = ({ onClickMoveUp={handleUnitMoveUp} onClickMoveDown={handleUnitMoveDown} onClickSync={openSyncModal} + onClickCard={onClickCard} isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} From a6c67af4cb7a5d737e829d134239b3cc7bc798ce Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 7 Jan 2026 10:35:40 -0500 Subject: [PATCH 5/8] fix: Fix broken coverage --- src/course-outline/section-card/SectionCard.tsx | 2 ++ src/course-outline/subsection-card/SubsectionCard.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index d69749a0b5..c4f4222099 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -342,6 +342,7 @@ const SectionCard = ({ }
onClickCard(e, true)} > diff --git a/src/course-outline/unit-card/UnitCard.scss b/src/course-outline/unit-card/UnitCard.scss index 707f6dd1b9..0fbe11c318 100644 --- a/src/course-outline/unit-card/UnitCard.scss +++ b/src/course-outline/unit-card/UnitCard.scss @@ -11,5 +11,7 @@ line-height: var(--pgn-typography-headings-line-height); color: var(--pgn-color-headings-base); align-self: center; + min-width: 10px !important; + max-width: 300px; } } From 66aad66a1e54dab1e02754d5c4305f749beed666 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 9 Jan 2026 14:16:04 -0500 Subject: [PATCH 7/8] refactor: `isOutlineNewDesignEnabled` created --- src/course-outline/CourseOutline.tsx | 4 +- src/course-outline/card-header/CardHeader.tsx | 6 +- .../outline-sidebar/OutlineSidebar.tsx | 5 +- .../outline-sidebar/OutlineSidebarContext.tsx | 4 +- .../section-card/SectionCard.tsx | 12 +-- .../subsection-card/SubsectionCard.tsx | 6 +- src/course-outline/utils.tsx | 9 +++ .../CourseOutlineHeaderActionsSlot/index.tsx | 77 +++++++++---------- 8 files changed, 64 insertions(+), 59 deletions(-) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 8e8e4e21e2..5198dc1fca 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; import { Container, Row, @@ -69,6 +68,7 @@ import OutlineAddChildButtons from './OutlineAddChildButtons'; import { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext'; import { StatusBar } from './status-bar/StatusBar'; import { LegacyStatusBar } from './status-bar/LegacyStatusBar'; +import { isOutlineNewDesignEnabled } from './utils'; const CourseOutline = () => { const intl = useIntl(); @@ -148,7 +148,7 @@ const CourseOutline = () => { // Show the new actions bar if it is enabled in the configuration. // This is a temporary flag until the new design feature is fully implemented. - const showNewActionsBar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; + const showNewActionsBar = isOutlineNewDesignEnabled(); // Use `setToastMessage` to show the toast. const [toastMessage, setToastMessage] = useState(null); diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 0ad444e46a..329529f4d7 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -158,9 +158,9 @@ const CardHeader = ({ return ( <> { - /* This is a special case; we can skip accessibility here since the - {Container}Card handles that. This onClick allows the user to select the card - by clicking on white areas of this component. */ + /* This is a special case; we can skip accessibility here (tabbing and select with keyboard) since the + `SortableItem` component handles that for the whole `{Container}Card`. + This `onClick` allows the user to select the Card by clicking on white areas of this component. */ }
{ const isMedium = useMediaQuery({ maxWidth: breakpoints.medium.maxWidth }); - const showNewSidebar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; const { currentPageKey, @@ -20,7 +19,7 @@ const OutlineSideBar = () => { } = useOutlineSidebarContext(); // Returns the previous help sidebar component if the waffle flag is disabled - if (!showNewSidebar) { + if (!isOutlineNewDesignEnabled()) { // On screens smaller than medium, the help sidebar is shown below the course outline const colSpan = isMedium ? 'col-12' : 'col-3'; return ( diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 354858b8a8..02a315d261 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -6,7 +6,6 @@ import { useState, } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; import { useToggle } from '@openedx/paragon'; import { HelpOutline, Info, Plus } from '@openedx/paragon/icons'; @@ -16,6 +15,7 @@ import { OutlineInfoSidebar } from './OutlineInfoSidebar'; import messages from './messages'; import { AddSidebar } from './AddSidebar'; +import { isOutlineNewDesignEnabled } from '../utils'; export type OutlineSidebarPageKeys = 'help' | 'info' | 'add'; export type OutlineSidebarPages = Record; @@ -42,7 +42,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const [selectedContainerId, setSelectedContainerId] = useState(); const openContainerInfoSidebar = useCallback((containerId: string) => { - if (getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true') { + if (isOutlineNewDesignEnabled()) { setSelectedContainerId(containerId); } }, [setSelectedContainerId]); diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index bcf0afd9f2..b427b16b2a 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -333,9 +333,9 @@ const SectionCard = ({ )}
{ - /* This is a special case; we can skip accessibility here since the - SectionCard handles that. This onClick allows the user to select the card - by clicking on white areas of this component. */ + /* This is a special case; we can skip accessibility here (tabbing and select with keyboard) since the + `SortableItem` component handles that for the whole `SectionCard`. + This `onClick` allows the user to select the Card by clicking on white areas of this component. */ }
{ - /* This is a special case; we can skip accessibility here since the - SectionCard handles that. This onClick allows the user to select the card - by clicking on white areas of this component. */ + /* This is a special case; we can skip accessibility here (tabbing and select with keyboard) since the + `SortableItem` component handles that for the whole `SectionCard`. + This `onClick` allows the user to select the Card by clicking on white areas of this component. */ }
{ - /* This is a special case; we can skip accessibility here since the - SectionCard handles that. This onClick allows the user to select the card - by clicking on white areas of this component. */ + /* This is a special case; we can skip accessibility here (tabbing and select with keyboard) since the + `SortableItem` component handles that for the whole `SubsectionCard`. + This `onClick` allows the user to select the Card by clicking on white areas of this component. */ }
( + getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true' +); + export { getItemStatus, getItemStatusBadgeContent, @@ -211,4 +219,5 @@ export { getHighlightsFormValues, getVideoSharingOptionText, scrollToElement, + isOutlineNewDesignEnabled, }; diff --git a/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx b/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx index 452039afc9..a036e2d3f5 100644 --- a/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx +++ b/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx @@ -1,8 +1,8 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework'; -import { getConfig } from '@edx/frontend-platform'; import HeaderNavigations, { HeaderNavigationsProps } from 'CourseAuthoring/course-outline/header-navigations/HeaderNavigations'; import HeaderActions from 'CourseAuthoring/course-outline/header-navigations/HeaderActions'; +import { isOutlineNewDesignEnabled } from '@src/course-outline/utils'; interface CourseOutlineHeaderActionsSlotProps extends HeaderNavigationsProps { sections: Array<({ @@ -20,44 +20,41 @@ const CourseOutlineHeaderActionsSlot = ({ courseActions, errors, sections, -}: CourseOutlineHeaderActionsSlotProps) => { - const showNewActionsBar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; - return ( - - {showNewActionsBar - ? ( - - ) - : ( - - )} - - ); -}; +}: CourseOutlineHeaderActionsSlotProps) => ( + + {isOutlineNewDesignEnabled() + ? ( + + ) + : ( + + )} + +); export default CourseOutlineHeaderActionsSlot; From dcd87d8e83d9a7243a0b2ba7ba8815a239a00b4b Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 12 Jan 2026 20:58:45 -0500 Subject: [PATCH 8/8] fix: Broken coverage --- src/course-outline/section-card/SectionCard.tsx | 12 ++++++++---- .../subsection-card/SubsectionCard.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index b427b16b2a..838580ec2a 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -339,8 +339,10 @@ const SectionCard = ({ }
onClickCard(e, true)} + onClick={ + /* istanbul ignore next */ + (e) => onClickCard(e, true) + } >