diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 76d5b0d9bd..8d112ffdc6 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -62,6 +62,7 @@ import { useCourseOutline } from './hooks'; import messages from './messages'; import { getTagsExportFile } from './data/api'; import OutlineAddChildButtons from './OutlineAddChildButtons'; +import { SidebarProvider } from './common/context/SidebarContext'; interface CourseOutlineProps { courseId: string, @@ -281,7 +282,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { } return ( - <> + {getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))} @@ -579,7 +580,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { {toastMessage} )} - + ); }; diff --git a/src/course-outline/common/context/SidebarContext.tsx b/src/course-outline/common/context/SidebarContext.tsx new file mode 100644 index 0000000000..b7b6ed6fda --- /dev/null +++ b/src/course-outline/common/context/SidebarContext.tsx @@ -0,0 +1,58 @@ +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..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/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index f2c37de59d..300268ada7 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(); @@ -81,28 +82,30 @@ const section = { const onEditSectionSubmit = jest.fn(); const renderComponent = (props?: object, entry = '/course/:courseId') => render( - - children - , + + + children + + , { path: '/course/:courseId', params: { courseId: '5' }, @@ -122,6 +125,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 02c1d5c644..91f0b43ac3 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; @@ -263,6 +265,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.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index 159f8bd5aa..ff01a59b86 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -5,6 +5,7 @@ import { import { XBlock } from '@src/data/types'; import cardHeaderMessages from '../card-header/messages'; import SubsectionCard from './SubsectionCard'; +import { SidebarProvider } from '../common/context/SidebarContext'; let store; const containerKey = 'lct:org:lib:unit:1'; @@ -105,29 +106,31 @@ const section: XBlock = { const onEditSubectionSubmit = jest.fn(); const renderComponent = (props?: object, entry = '/course/:courseId') => render( - - children - , + + + children + + , { path: '/course/:courseId', params: { courseId: '5' }, @@ -147,6 +150,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 389338519c..a07a1b7e29 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; @@ -263,6 +265,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.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index c8282a2735..034d878849 100644 --- a/src/course-outline/unit-card/UnitCard.test.tsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -5,6 +5,7 @@ import { import { XBlock } from '@src/data/types'; import UnitCard from './UnitCard'; import cardMessages from '../card-header/messages'; +import { SidebarProvider } from '../common/context/SidebarContext'; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); @@ -73,28 +74,30 @@ const unit = { } satisfies Partial 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' }, @@ -114,6 +117,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 8029f603cc..6ce23aa8fc 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); @@ -165,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 = (
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;