feat: Make sections/subsections/units selectable in course outline [FC-0114] (#2732)
This commit is contained in:
@@ -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<string | null>(null);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<Stack direction="horizontal" gap={3} onClick={onClickCard}>
|
||||
<Button
|
||||
className={btnClasses}
|
||||
variant={btnVariant}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Icon,
|
||||
IconButton,
|
||||
IconButtonWithTooltip,
|
||||
Stack,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ interface CardHeaderProps {
|
||||
onClickMoveUp: () => void;
|
||||
onClickMoveDown: () => void;
|
||||
onClickCopy?: () => void;
|
||||
onClickCard?: (e: React.MouseEvent) => void;
|
||||
titleComponent: ReactNode;
|
||||
namePrefix: string;
|
||||
proctoringExamConfigurationLink?: string,
|
||||
@@ -90,6 +92,7 @@ const CardHeader = ({
|
||||
onClickMoveUp,
|
||||
onClickMoveDown,
|
||||
onClickCopy,
|
||||
onClickCard,
|
||||
titleComponent,
|
||||
namePrefix,
|
||||
actions,
|
||||
@@ -154,10 +157,16 @@ const CardHeader = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
{
|
||||
/* 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. */
|
||||
}
|
||||
<div // eslint-disable-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
className="item-card-header"
|
||||
data-testid={`${namePrefix}-card-header`}
|
||||
ref={cardHeaderRef}
|
||||
onClick={onClickCard}
|
||||
>
|
||||
{isFormOpen ? (
|
||||
<Form.Group className="m-0 w-75">
|
||||
@@ -178,7 +187,7 @@ const CardHeader = ({
|
||||
/>
|
||||
</Form.Group>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
{titleComponent}
|
||||
<IconButtonWithTooltip
|
||||
className="item-card-button-icon"
|
||||
@@ -190,7 +199,7 @@ const CardHeader = ({
|
||||
// @ts-ignore
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
<div className="ml-auto d-flex">
|
||||
{(isVertical || isSequential) && (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
OverlayTrigger,
|
||||
Tooltip,
|
||||
IconButtonWithTooltip,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
ArrowDropDown as ArrowDownIcon,
|
||||
@@ -29,32 +28,23 @@ const TitleButton = ({
|
||||
const titleTooltipMessage = intl.formatMessage(messages.expandTooltip);
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={(
|
||||
<Tooltip
|
||||
id={`${title}-${titleTooltipMessage}`}
|
||||
>
|
||||
{titleTooltipMessage}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
iconBefore={isExpanded ? ArrowDownIcon : ArrowRightIcon}
|
||||
variant="tertiary"
|
||||
<Stack direction="horizontal">
|
||||
<IconButtonWithTooltip
|
||||
src={isExpanded ? ArrowDownIcon : ArrowRightIcon}
|
||||
data-testid={`${namePrefix}-card-header__expanded-btn`}
|
||||
alt={title}
|
||||
tooltipContent={<div>{titleTooltipMessage}</div>}
|
||||
className="item-card-header__title-btn"
|
||||
onClick={onTitleClick}
|
||||
title={title}
|
||||
>
|
||||
<div className="mr-2">
|
||||
{prefixIcon}
|
||||
</div>
|
||||
<span className={`${namePrefix}-card-title mb-0 truncate-1-line`}>
|
||||
{title}
|
||||
</span>
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
size="inline"
|
||||
/>
|
||||
<div className="mr-2">
|
||||
{prefixIcon}
|
||||
</div>
|
||||
<span className={`${namePrefix}-card-title mb-0 truncate-1-line`}>
|
||||
{title}
|
||||
</span>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const TitleLink = ({
|
||||
to={titleLink}
|
||||
title={title}
|
||||
>
|
||||
<span className={`${namePrefix}-card-title mb-0 truncate-1-line text-left`}>
|
||||
<span className={`${namePrefix}-card-title truncate-1-line mb-0 text-left`}>
|
||||
{title}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -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 (
|
||||
<Row
|
||||
ref={setNodeRef}
|
||||
tabIndex={onClick ? 0 : -1}
|
||||
style={style}
|
||||
className="mx-0"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (!onClick) { return; }
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
{children}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<OutlineSidebarPageKeys, SidebarPage>;
|
||||
@@ -26,6 +27,8 @@ interface OutlineSidebarContextData {
|
||||
open: () => void;
|
||||
toggle: () => void;
|
||||
sidebarPages: OutlineSidebarPages;
|
||||
selectedContainerId?: string;
|
||||
openContainerInfoSidebar: (containerId: string) => void;
|
||||
}
|
||||
|
||||
const OutlineSidebarContext = createContext<OutlineSidebarContextData | undefined>(undefined);
|
||||
@@ -36,6 +39,14 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
|
||||
const [currentPageKey, setCurrentPageKeyState] = useState<OutlineSidebarPageKeys>('info');
|
||||
const [isOpen, open, , toggle] = useToggle(true);
|
||||
|
||||
const [selectedContainerId, setSelectedContainerId] = useState<string | undefined>();
|
||||
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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('<SectionCard />', () => {
|
||||
|
||||
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 <Row> 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', () => {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SortableItem
|
||||
@@ -281,9 +290,16 @@ const SectionCard = ({
|
||||
padding: '1.75rem',
|
||||
...borderStyle,
|
||||
}}
|
||||
onClick={(e) => onClickCard(e, true)}
|
||||
>
|
||||
<div
|
||||
className={`section-card ${isScrolledToElement ? 'highlight' : ''}`}
|
||||
className={classNames(
|
||||
'section-card',
|
||||
{
|
||||
highlight: isScrolledToElement,
|
||||
'outline-card-selected': section.id === selectedContainerId,
|
||||
},
|
||||
)}
|
||||
data-testid="section-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
@@ -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,18 @@ const SectionCard = ({
|
||||
/>
|
||||
)}
|
||||
<div className="section-card__content" data-testid="section-card__content">
|
||||
<div className="outline-section__status mb-1">
|
||||
{
|
||||
/* 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. */
|
||||
}
|
||||
<div // eslint-disable-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
className="outline-section__status mb-1"
|
||||
onClick={
|
||||
/* istanbul ignore next */
|
||||
(e) => onClickCard(e, true)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="p-0 bg-transparent"
|
||||
data-destid="section-card-highlights-button"
|
||||
@@ -328,11 +356,23 @@ const SectionCard = ({
|
||||
<p className="m-0 text-black">{messages.sectionHighlightsBadge.defaultMessage}</p>
|
||||
</Button>
|
||||
</div>
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={section}
|
||||
/>
|
||||
{
|
||||
/* 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. */
|
||||
}
|
||||
<div // eslint-disable-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
onClick={
|
||||
/* istanbul ignore next */
|
||||
(e) => onClickCard(e, false)
|
||||
}
|
||||
>
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={section}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div
|
||||
@@ -344,6 +384,7 @@ const SectionCard = ({
|
||||
<OutlineAddChildButtons
|
||||
handleNewButtonClick={() => handleNewSubsectionSubmit(id)}
|
||||
handleUseFromLibraryClick={openAddLibrarySubsectionModal}
|
||||
onClickCard={(e) => onClickCard(e, true)}
|
||||
childType={ContainerType.Subsection}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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('<SubsectionCard />', () => {
|
||||
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 <Row> 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 () => {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SortableItem
|
||||
@@ -275,9 +284,16 @@ const SubsectionCard = ({
|
||||
background: '#f8f7f6',
|
||||
...borderStyle,
|
||||
}}
|
||||
onClick={(e) => onClickCard(e, true)}
|
||||
>
|
||||
<div
|
||||
className={`subsection-card ${isScrolledToElement ? 'highlight' : ''}`}
|
||||
className={classNames(
|
||||
'subsection-card',
|
||||
{
|
||||
highlight: isScrolledToElement,
|
||||
'outline-card-selected': subsection.id === selectedContainerId,
|
||||
},
|
||||
)}
|
||||
data-testid="subsection-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
@@ -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,19 @@ const SubsectionCard = ({
|
||||
extraActionsComponent={extraActionsComponent}
|
||||
readyToSync={upstreamInfo?.readyToSync}
|
||||
/>
|
||||
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
|
||||
{
|
||||
/* 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. */
|
||||
}
|
||||
<div // eslint-disable-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
className="subsection-card__content item-children"
|
||||
data-testid="subsection-card__content"
|
||||
onClick={
|
||||
/* istanbul ignore next */
|
||||
(e) => onClickCard(e, false)
|
||||
}
|
||||
>
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
@@ -330,6 +359,7 @@ const SubsectionCard = ({
|
||||
<OutlineAddChildButtons
|
||||
handleNewButtonClick={handleNewButtonClick}
|
||||
handleUseFromLibraryClick={openAddLibraryUnitModal}
|
||||
onClickCard={(e) => onClickCard(e, true)}
|
||||
childType={ContainerType.Unit}
|
||||
/>
|
||||
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('<UnitCard />', () => {
|
||||
'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 <Row> 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 () => {
|
||||
|
||||
@@ -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 = (
|
||||
<TitleLink
|
||||
title={displayName}
|
||||
@@ -228,9 +237,16 @@ const UnitCard = ({
|
||||
background: '#fdfdfd',
|
||||
...borderStyle,
|
||||
}}
|
||||
onClick={onClickCard}
|
||||
>
|
||||
<div
|
||||
className={`unit-card ${isScrolledToElement ? 'highlight' : ''}`}
|
||||
className={classNames(
|
||||
'unit-card',
|
||||
{
|
||||
highlight: isScrolledToElement,
|
||||
'outline-card-selected': unit.id === selectedContainerId,
|
||||
},
|
||||
)}
|
||||
data-testid="unit-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
@@ -248,6 +264,7 @@ const UnitCard = ({
|
||||
onClickMoveUp={handleUnitMoveUp}
|
||||
onClickMoveDown={handleUnitMoveDown}
|
||||
onClickSync={openSyncModal}
|
||||
onClickCard={onClickCard}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.authoring.course_outline_header_actions.v1"
|
||||
idAliases={['course_outline_header_actions_slot']}
|
||||
pluginProps={{
|
||||
isReIndexShow,
|
||||
isSectionsExpanded,
|
||||
isDisabledReindexButton,
|
||||
headerNavigationsActions,
|
||||
hasSections,
|
||||
courseActions,
|
||||
errors,
|
||||
sections,
|
||||
}}
|
||||
>
|
||||
{showNewActionsBar
|
||||
? (
|
||||
<HeaderActions
|
||||
actions={headerNavigationsActions}
|
||||
courseActions={courseActions}
|
||||
errors={errors}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<HeaderNavigations
|
||||
headerNavigationsActions={headerNavigationsActions}
|
||||
isReIndexShow={isReIndexShow}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
isDisabledReindexButton={isDisabledReindexButton}
|
||||
hasSections={hasSections}
|
||||
courseActions={courseActions}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</PluginSlot>
|
||||
);
|
||||
};
|
||||
}: CourseOutlineHeaderActionsSlotProps) => (
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.authoring.course_outline_header_actions.v1"
|
||||
idAliases={['course_outline_header_actions_slot']}
|
||||
pluginProps={{
|
||||
isReIndexShow,
|
||||
isSectionsExpanded,
|
||||
isDisabledReindexButton,
|
||||
headerNavigationsActions,
|
||||
hasSections,
|
||||
courseActions,
|
||||
errors,
|
||||
sections,
|
||||
}}
|
||||
>
|
||||
{isOutlineNewDesignEnabled()
|
||||
? (
|
||||
<HeaderActions
|
||||
actions={headerNavigationsActions}
|
||||
courseActions={courseActions}
|
||||
errors={errors}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<HeaderNavigations
|
||||
headerNavigationsActions={headerNavigationsActions}
|
||||
isReIndexShow={isReIndexShow}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
isDisabledReindexButton={isDisabledReindexButton}
|
||||
hasSections={hasSections}
|
||||
courseActions={courseActions}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
export default CourseOutlineHeaderActionsSlot;
|
||||
|
||||
Reference in New Issue
Block a user