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/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 ( - + - + size="inline" + /> +
+ {prefixIcon} +
+ + {title} + +
); }; diff --git a/src/course-outline/card-header/TitleLink.tsx b/src/course-outline/card-header/TitleLink.tsx index 0c0f402b26..58961c2491 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/drag-helper/SortableItem.tsx b/src/course-outline/drag-helper/SortableItem.tsx index 8f5306c646..d51f71e690 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/outline-sidebar/OutlineSidebar.tsx b/src/course-outline/outline-sidebar/OutlineSidebar.tsx index f9eb13c86e..b320f1fa0d 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebar.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebar.tsx @@ -1,4 +1,3 @@ -import { getConfig } from '@edx/frontend-platform'; import { breakpoints } from '@openedx/paragon'; import { useMediaQuery } from 'react-responsive'; @@ -6,10 +5,10 @@ import { Sidebar } from '@src/generic/sidebar'; import OutlineHelpSidebar from './OutlineHelpSidebar'; import { useOutlineSidebarContext } from './OutlineSidebarContext'; +import { isOutlineNewDesignEnabled } from '../utils'; const OutlineSideBar = () => { 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 60f28785fa..02a315d261 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -15,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; @@ -26,6 +27,8 @@ interface OutlineSidebarContextData { open: () => void; toggle: () => void; sidebarPages: OutlineSidebarPages; + selectedContainerId?: string; + openContainerInfoSidebar: (containerId: string) => void; } const OutlineSidebarContext = createContext(undefined); @@ -36,6 +39,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 (isOutlineNewDesignEnabled()) { + setSelectedContainerId(containerId); + } + }, [setSelectedContainerId]); + const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => { setCurrentPageKeyState(pageKey); open(); @@ -68,6 +79,8 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod isOpen, open, toggle, + selectedContainerId, + openContainerInfoSidebar, }), [ currentPageKey, @@ -76,6 +89,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 dc698a2baf..84681dc082 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -1,8 +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 { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); @@ -116,6 +118,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( routerProps: { initialEntries: [entry], }, + extraWrapper: OutlineSidebarProvider, }, ); @@ -129,6 +132,32 @@ 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', () => { + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', + }); + 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 281b712302..b427b16b2a 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -30,6 +30,7 @@ import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import messages from './messages'; +import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface SectionCardProps { section: XBlock, @@ -74,6 +75,7 @@ const SectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); + const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === section.id; @@ -266,6 +268,13 @@ const SectionCard = ({ const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); + const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { + if (!preventNodeEvents || e.target === e.currentTarget) { + openContainerInfoSidebar(section.id); + setIsExpanded(true); + } + }, [openContainerInfoSidebar]); + return ( <> onClickCard(e, true)} >
@@ -303,6 +319,7 @@ const SectionCard = ({ onClickMoveUp={handleSectionMoveUp} onClickMoveDown={handleSectionMoveDown} onClickSync={openSyncModal} + onClickCard={(e) => onClickCard(e, true)} isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} @@ -315,7 +332,16 @@ const SectionCard = ({ /> )}
-
+ { + /* 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. */ + } +
onClickCard(e, true)} + >
- + { + /* 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. */ + } +
onClickCard(e, false)} + > + +
{isExpanded && (
handleNewSubsectionSubmit(id)} handleUseFromLibraryClick={openAddLibrarySubsectionModal} + onClickCard={(e) => onClickCard(e, true)} childType={ContainerType.Subsection} /> )} diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index e0dd4d6c16..54d75f8496 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,6 +6,7 @@ import { import { XBlock } from '@src/data/types'; import cardHeaderMessages from '../card-header/messages'; import SubsectionCard from './SubsectionCard'; +import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; let store; const containerKey = 'lct:org:lib:unit:1'; @@ -141,6 +143,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( routerProps: { initialEntries: [entry], }, + extraWrapper: OutlineSidebarProvider, }, ); @@ -154,6 +157,32 @@ 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', () => { + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', + }); + 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 7fefb098c3..9a7df0f095 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -31,6 +31,7 @@ import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import messages from './messages'; +import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface SubsectionCardProps { section: XBlock, @@ -77,6 +78,7 @@ const SubsectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); + const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === subsection.id; @@ -258,6 +260,13 @@ const SubsectionCard = ({ closeAddLibraryUnitModal(); }, [id, handleAddUnitFromLibrary, closeAddLibraryUnitModal]); + const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { + if (!preventNodeEvents || e.target === e.currentTarget) { + openContainerInfoSidebar(subsection.id); + setIsExpanded(true); + } + }, [openContainerInfoSidebar]); + return ( <> onClickCard(e, true)} >
@@ -297,6 +313,7 @@ const SubsectionCard = ({ onClickMoveDown={handleSubsectionMoveDown} onClickConfigure={onOpenConfigureModal} onClickSync={openSyncModal} + onClickCard={(e) => onClickCard(e, true)} isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} @@ -310,7 +327,17 @@ const SubsectionCard = ({ extraActionsComponent={extraActionsComponent} readyToSync={upstreamInfo?.readyToSync} /> -
+ { + /* 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. */ + } +
onClickCard(e, false)} + > onClickCard(e, true)} childType={ContainerType.Unit} /> {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && ( 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; } } diff --git a/src/course-outline/unit-card/UnitCard.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index 96b17724d9..0b37aad0be 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,6 +6,7 @@ import { import { XBlock } from '@src/data/types'; import UnitCard from './UnitCard'; import cardMessages from '../card-header/messages'; +import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); @@ -105,6 +107,7 @@ const renderComponent = (props?: object) => render( { path: '/course/:courseId', params: { courseId: '5' }, + extraWrapper: OutlineSidebarProvider, }, ); @@ -121,6 +124,33 @@ 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', () => { + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', + }); + + 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 8ab7ba809b..d8f092765e 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'; @@ -25,6 +26,7 @@ import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import type { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface UnitCardProps { unit: XBlock; @@ -69,6 +71,7 @@ const UnitCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const [searchParams] = useSearchParams(); + const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === unit.id; const [isFormOpen, openForm, closeForm] = useToggle(false); @@ -164,6 +167,12 @@ const UnitCard = ({ } }, [dispatch, section, queryClient, courseId]); + const onClickCard = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + openContainerInfoSidebar(unit.id); + } + }, [openContainerInfoSidebar]); + const titleComponent = (
@@ -248,6 +264,7 @@ const UnitCard = ({ onClickMoveUp={handleUnitMoveUp} onClickMoveDown={handleUnitMoveDown} onClickSync={openSyncModal} + onClickCard={onClickCard} isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} diff --git a/src/course-outline/utils.tsx b/src/course-outline/utils.tsx index e7ba5398f4..b5922f6bb8 100644 --- a/src/course-outline/utils.tsx +++ b/src/course-outline/utils.tsx @@ -1,4 +1,5 @@ import type { IntlShape, MessageDescriptor } from 'react-intl'; +import { getConfig } from '@edx/frontend-platform'; import { CheckCircle as CheckCircleIcon, Lock as LockIcon, @@ -204,6 +205,13 @@ const getVideoSharingOptionText = ( } }; +/** + * Returns `true` if the new design for the course outline is enabled + */ +const isOutlineNewDesignEnabled = () => ( + 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/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; 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;