feat: New align sidebar for Course Outline [FC-0114] (#2812)

- Adds the align sidebar for Course and Section/Subsection/Unit cards (https://github.com/openedx/frontend-app-authoring/issues/2625)
- Add a new library lock icon to tags imported from upstream (https://github.com/openedx/frontend-app-authoring/issues/2234)
This commit is contained in:
Chris Chávez
2026-01-19 11:59:43 -05:00
committed by GitHub
parent d5352b8632
commit 0b2b8e142c
22 changed files with 540 additions and 136 deletions

View File

@@ -7,6 +7,7 @@ import type {} from 'react-select/base';
export interface TagTreeEntry {
explicit: boolean;
children: Record<string, TagTreeEntry>;
isCopied: boolean;
canChangeObjecttag: boolean;
canDeleteObjecttag: boolean;
}
@@ -37,4 +38,14 @@ declare module 'react-select/base' {
}
}
export type TagTree = {
[key: string]: {
children: TagTree,
canChangeObjecttag: boolean,
canDeleteObjecttag: boolean,
explicit: boolean,
isCopied: boolean,
}
};
export default ContentTagsCollapsible;

View File

@@ -191,6 +191,7 @@ const useContentTagsCollapsibleHelper = (
children: {},
canChangeObjecttag: item.canChangeObjecttag,
canDeleteObjecttag: item.canDeleteObjecttag,
isCopied: item.isCopied,
};
// Populating the SelectableBox with "selected" (explicit) tags

View File

@@ -57,4 +57,17 @@ describe('<TagsTree>', () => {
fireEvent.click(xButton);
expect(mockRemoveTagHandler).toHaveBeenCalled();
});
it('should render library lock icon', async () => {
render(
<RootWrapper
tags={contentTaxonomyTagsTreeMock}
removeTagHandler={mockRemoveTagHandler}
isEditMode
/>,
);
const view = screen.getByText(/hierarchical taxonomy tag 3\.4\.50/i);
expect(within(view).getByTestId('lock-icon')).toBeInTheDocument();
});
});

View File

@@ -1,23 +1,54 @@
// @ts-check
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Stack, IconButton } from '@openedx/paragon';
import { Tag, Close } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Icon, Stack, IconButton, OverlayTrigger, Tooltip,
} from '@openedx/paragon';
import { Tag, Close, Lock } from '@openedx/paragon/icons';
import messages from './messages';
import { ContentTagsDrawerContext } from './common/context';
import { TagTree } from './ContentTagsCollapsible';
const LibraryLockIcon = ({ key }: { key: string }) => (
<OverlayTrigger
placement="top"
overlay={(
<Tooltip
id={`tooltip-lock-${key}`}
>
<FormattedMessage {...messages.libraryLockIconTooltip} />
</Tooltip>
)}
>
<Icon
src={Lock}
size="xs"
className="ml-1"
data-testid="lock-icon"
/>
</OverlayTrigger>
);
interface TagComponentProps {
value: string;
lineage: string[];
canDelete: boolean;
explicit: boolean;
removeTagHandler?: (value: string) => void;
afterComponent?: React.ReactNode;
}
const TagComponent = ({
value,
canDelete,
explicit,
removeTagHandler,
lineage,
}) => {
removeTagHandler,
canDelete = false,
explicit,
afterComponent,
}: TagComponentProps) => {
const intl = useIntl();
const handleClick = React.useCallback(() => {
if (explicit && canDelete) {
if (explicit && canDelete && removeTagHandler) {
removeTagHandler(lineage.join(','));
}
}, [explicit, lineage, canDelete, removeTagHandler]);
@@ -38,23 +69,19 @@ const TagComponent = ({
className="tags-tree-delete-button ml-2 text-gray-600"
/>
)}
{afterComponent}
</Stack>
);
};
TagComponent.propTypes = {
value: PropTypes.string.isRequired,
canDelete: PropTypes.bool,
explicit: PropTypes.bool,
lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
removeTagHandler: PropTypes.func,
};
TagComponent.defaultProps = {
removeTagHandler: undefined,
canDelete: false,
explicit: false,
};
interface TagsTreeProps {
tags: TagTree;
parentKey?: string;
rootDepth?: number;
lineage?: string[];
removeTagHandler?: (value: string) => void;
afterTagsComponent?: React.ReactNode;
}
/**
* Component that renders Tags under a Taxonomy in the nested tree format.
@@ -92,23 +119,21 @@ TagComponent.defaultProps = {
* }
* };
*
* @param {Object} props - The component props.
* @param {Object} props.tags - Array of taxonomy tags that are applied to the content.
* @param {number} props.rootDepth - Depth of the parent tag (root), used to render tabs for the tree.
* @param {string} props.parentKey - Key of the parent tag.
* @param {string[]} props.lineage - Lineage of the tag.
* @param {(
* tagSelectableBoxValue: string,
* checked: boolean
* ) => void} props.removeTagHandler - Function that is called when removing tags from the tree.
*/
const TagsTree = ({
/** Array of taxonomy tags that are applied to the content. */
tags,
rootDepth,
/** Depth of the parent tag (root), used to render tabs for the tree. */
rootDepth = 0,
/** Key of the parent tag. */
parentKey,
lineage,
/** Lineage of the tag. */
lineage = [],
/** Function that is called when removing tags from the tree. */
removeTagHandler,
}) => {
/** Optional component to render after the tags components. */
afterTagsComponent,
}: TagsTreeProps) => {
const { isEditMode } = useContext(ContentTagsDrawerContext);
if (Object.keys(tags).length === 0) {
@@ -132,6 +157,11 @@ const TagsTree = ({
explicit={tags[key].explicit}
lineage={[...lineage, encodeURIComponent(key)]}
removeTagHandler={removeTagHandler}
afterComponent={isEditMode && tags[key].explicit && tags[key].isCopied && (
// So far, `isCopied` is only used to check if the tag has been imported from a library.
// If another function is added to `isCopied`, this may change.
<LibraryLockIcon key={key} />
)}
/>
</div>
{ tags[key].children
@@ -142,6 +172,7 @@ const TagsTree = ({
parentKey={key}
lineage={[...lineage, encodeURIComponent(key)]}
removeTagHandler={removeTagHandler}
afterTagsComponent={afterTagsComponent}
/>
)}
</div>
@@ -150,19 +181,4 @@ const TagsTree = ({
);
};
TagsTree.propTypes = {
tags: PropTypes.shape({}).isRequired,
parentKey: PropTypes.string,
rootDepth: PropTypes.number,
lineage: PropTypes.arrayOf(PropTypes.string),
removeTagHandler: PropTypes.func,
};
TagsTree.defaultProps = {
rootDepth: 0,
parentKey: undefined,
lineage: [],
removeTagHandler: undefined,
};
export default TagsTree;

View File

@@ -2,14 +2,17 @@ module.exports = {
'hierarchical taxonomy tag 1': {
explicit: false,
canDeleteObjecttag: true,
isCopied: false,
children: {
'hierarchical taxonomy tag 1.7': {
explicit: false,
canDeleteObjecttag: true,
isCopied: false,
children: {
'hierarchical taxonomy tag 1.7.59': {
explicit: true,
canDeleteObjecttag: true,
isCopied: false,
children: {},
},
},
@@ -19,14 +22,17 @@ module.exports = {
'hierarchical taxonomy tag 2': {
explicit: false,
canDeleteObjecttag: true,
isCopied: false,
children: {
'hierarchical taxonomy tag 2.13': {
explicit: false,
canDeleteObjecttag: true,
isCopied: false,
children: {
'hierarchical taxonomy tag 2.13.46': {
explicit: true,
canDeleteObjecttag: true,
isCopied: false,
children: {},
},
},
@@ -36,14 +42,17 @@ module.exports = {
'hierarchical taxonomy tag 3': {
explicit: false,
canDeleteObjecttag: true,
isCopied: true,
children: {
'hierarchical taxonomy tag 3.4': {
explicit: false,
canDeleteObjecttag: true,
isCopied: true,
children: {
'hierarchical taxonomy tag 3.4.50': {
explicit: false,
canDeleteObjecttag: true,
explicit: true,
canDeleteObjecttag: false,
isCopied: true,
children: {},
},
},

View File

@@ -5,6 +5,7 @@ import {
useQueries,
useMutation,
useQueryClient,
skipToken,
} from '@tanstack/react-query';
import { useParams } from 'react-router';
import { TagData, TagListData } from '@src/taxonomy/data/types';
@@ -115,11 +116,10 @@ export const useContentTaxonomyTagsData = (contentId: string) => (
* @param contentId The id of the content object
* @param enabled Flag to enable/disable the query
*/
export const useContentData = (contentId: string, enabled: boolean) => (
export const useContentData = (contentId?: string, enabled: boolean = true) => (
useQuery({
queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId),
enabled,
queryFn: (enabled && contentId) ? () => getContentData(contentId) : skipToken,
})
);

View File

@@ -8,6 +8,7 @@ export interface Tag {
lineage: string[];
canChangeObjecttag: boolean;
canDeleteObjecttag: boolean;
isCopied: boolean;
}
/** A list of the tags from one taxonomy that are applied to a content object. */

View File

@@ -139,6 +139,11 @@ const messages = defineMessages({
defaultMessage: 'enable a taxonomy',
description: 'Message of the link used in empty drawer message.',
},
libraryLockIconTooltip: {
id: 'course-authoring.content-tags-drawer.tag.library-lock.tooltip',
defaultMessage: 'Tags applied within a library cannot be removed',
description: 'Tooltip message for the library lock icon.',
},
});
export default messages;

View File

@@ -12,15 +12,12 @@ import { useContentTaxonomyTagsData } from '../data/apiHooks';
import type { ContentTaxonomyTagData, Tag } from '../data/types';
import { LoadingSpinner } from '../../generic/Loading';
import TagsTree from '../TagsTree';
import { TagTree } from '../ContentTagsCollapsible';
interface TagsSidebarBodyProps {
readOnly: boolean
}
type TagTree = {
[key: string]: { children: TagTree, canChangeObjecttag: boolean, canDeleteObjecttag: boolean }
};
const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
const intl = useIntl();
const [showManageTags, setShowManageTags] = useState(false);
@@ -43,6 +40,8 @@ const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
children: {},
canChangeObjecttag: item.canChangeObjecttag,
canDeleteObjecttag: item.canDeleteObjecttag,
explicit: false,
isCopied: item.isCopied,
};
}

View File

@@ -8,6 +8,7 @@ import CardHeader from './CardHeader';
import TitleButton from './TitleButton';
import messages from './messages';
import { RequestStatus } from '../../data/constants';
import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext';
const onExpandMock = jest.fn();
const onClickMenuButtonMock = jest.fn();
@@ -79,6 +80,7 @@ const renderComponent = (props?: object, entry = '/') => {
routerProps: {
initialEntries: [entry],
},
extraWrapper: OutlineSidebarProvider,
},
);
};

View File

@@ -1,5 +1,5 @@
import {
ReactNode, useEffect, useRef, useState,
ReactNode, useCallback, useEffect, useRef, useState,
} from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -21,15 +21,16 @@ import {
} from '@openedx/paragon/icons';
import { useContentTagsCount } from '@src/generic/data/apiHooks';
import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
import TagCount from '@src/generic/tag-count';
import { useEscapeClick } from '@src/hooks';
import { XBlockActions } from '@src/data/types';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
import { ITEM_BADGE_STATUS } from '../constants';
import { scrollToElement } from '../utils';
import CardStatus from './CardStatus';
import messages from './messages';
import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
interface CardHeaderProps {
title: string;
@@ -112,7 +113,17 @@ const CardHeader = ({
const [searchParams] = useSearchParams();
const [titleValue, setTitleValue] = useState(title);
const cardHeaderRef = useRef(null);
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
const [isLegacyManageTagsDrawerOpen, openLegacyTagsDrawer, closeLegacyTagsDrawer] = useToggle(false);
const { setCurrentPageKey } = useOutlineSidebarContext();
const openManageTagsDrawer = useCallback(() => {
const showNewSidebar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true';
if (showNewSidebar) {
setCurrentPageKey('align', cardId);
} else {
openLegacyTagsDrawer();
}
}, [setCurrentPageKey, openLegacyTagsDrawer, cardId]);
// Use studio url as base if proctoringExamConfigurationLink is a relative link
const fullProctoringExamConfigurationLink = () => (
@@ -283,42 +294,42 @@ const CardHeader = ({
</Dropdown.Item>
)}
{actions.draggable && (
<>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
onClick={onClickMoveUp}
disabled={!actions.allowMoveUp}
>
{intl.formatMessage(messages.menuMoveUp)}
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
onClick={onClickMoveDown}
disabled={!actions.allowMoveDown}
>
{intl.formatMessage(messages.menuMoveDown)}
</Dropdown.Item>
</>
<>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
onClick={onClickMoveUp}
disabled={!actions.allowMoveUp}
>
{intl.formatMessage(messages.menuMoveUp)}
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
onClick={onClickMoveDown}
disabled={!actions.allowMoveDown}
>
{intl.formatMessage(messages.menuMoveDown)}
</Dropdown.Item>
</>
)}
{((actions.unlinkable ?? null) !== null || actions.deletable) && <Dropdown.Divider />}
{(actions.unlinkable ?? null) !== null && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-unlink-button`}
onClick={onClickUnlink}
disabled={!actions.unlinkable}
className="allow-hover-on-disabled"
title={!actions.unlinkable ? intl.formatMessage(messages.menuUnlinkDisabledTooltip) : undefined}
>
{intl.formatMessage(messages.menuUnlink)}
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-unlink-button`}
onClick={onClickUnlink}
disabled={!actions.unlinkable}
className="allow-hover-on-disabled"
title={!actions.unlinkable ? intl.formatMessage(messages.menuUnlinkDisabledTooltip) : undefined}
>
{intl.formatMessage(messages.menuUnlink)}
</Dropdown.Item>
)}
{actions.deletable && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-delete-button`}
onClick={onClickDelete}
>
{intl.formatMessage(messages.menuDelete)}
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-delete-button`}
onClick={onClickDelete}
>
{intl.formatMessage(messages.menuDelete)}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
@@ -326,8 +337,8 @@ const CardHeader = ({
</div>
<ContentTagsDrawerSheet
id={cardId}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
showSheet={isManageTagsDrawerOpen}
onClose={/* istanbul ignore next */ () => closeLegacyTagsDrawer()}
showSheet={isLegacyManageTagsDrawerOpen}
/>
</>
);

View File

@@ -5,7 +5,6 @@ import {
import {
Add as IconAdd, FindInPage, ViewSidebar,
} from '@openedx/paragon/icons';
import { OUTLINE_SIDEBAR_PAGES } from '@src/course-outline/outline-sidebar/constants';
import { OutlinePageErrors, XBlockActions } from '@src/data/types';
import type { SidebarPage } from '@src/generic/sidebar';
@@ -13,6 +12,7 @@ import type { SidebarPage } from '@src/generic/sidebar';
import { type OutlineSidebarPageKeys, useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
import messages from './messages';
import { getOutlineSidebarPages } from '../outline-sidebar/sidebarPages';
export interface HeaderActionsProps {
actions: {
@@ -29,6 +29,7 @@ const HeaderActions = ({
}: HeaderActionsProps) => {
const intl = useIntl();
const { lmsLink } = actions;
const sidebarPages = getOutlineSidebarPages();
const { setCurrentPageKey } = useOutlineSidebarContext();
@@ -80,7 +81,7 @@ const HeaderActions = ({
<Icon src={ViewSidebar} />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
{Object.entries(OUTLINE_SIDEBAR_PAGES).filter(([, page]) => !page.hideFromActionMenu)
{Object.entries(sidebarPages).filter(([, page]) => !page.hideFromActionMenu)
.map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => (
<Dropdown.Item
key={key}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import * as CourseAuthoringContext from '@src/CourseAuthoringContext';
import * as CourseDetailsApi from '@src/data/apiHooks';
import * as ContentDataApi from '@src/content-tags-drawer/data/apiHooks';
import * as OutlineSidebarContext from './OutlineSidebarContext';
import { OutlineAlignSidebar } from './OutlineAlignSidebar';
jest.mock('@src/content-tags-drawer', () => ({
ContentTagsDrawer: jest.fn(({ id, variant }) => (
<div data-testid="content-tags-drawer">
drawer-mock-{id}-{variant}
</div>
)),
}));
describe('OutlineAlignSidebar', () => {
beforeEach(() => {
jest
.spyOn(CourseAuthoringContext, 'useCourseAuthoringContext')
.mockReturnValue({
courseId: 'course-v1:test+course+run',
} as any);
jest
.spyOn(OutlineSidebarContext, 'useOutlineSidebarContext')
.mockReturnValue({
currentContainerId:
'block-v1:test+course+run+type@sequential+block@seq1',
} as any);
jest
.spyOn(CourseDetailsApi, 'useCourseDetails')
.mockReturnValue({
data: { name: 'Test Course' },
} as any);
jest
.spyOn(ContentDataApi, 'useContentData')
.mockReturnValue({
data: { displayName: 'Sequential 1' },
} as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders ContentTagsDrawer with the correct id and variant', () => {
render(<OutlineAlignSidebar />);
const drawer = screen.getByTestId('content-tags-drawer');
expect(drawer).toBeInTheDocument();
expect(drawer).toHaveTextContent(
'drawer-mock-block-v1:test+course+run+type@sequential+block@seq1-component',
);
});
});

View File

@@ -0,0 +1,39 @@
import { SchoolOutline } from '@openedx/paragon/icons';
import { ContentTagsDrawer } from '@src/content-tags-drawer';
import { useContentData } from '@src/content-tags-drawer/data/apiHooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useCourseDetails } from '@src/data/apiHooks';
import { SidebarTitle } from '@src/generic/sidebar';
import { useOutlineSidebarContext } from './OutlineSidebarContext';
export const OutlineAlignSidebar = () => {
const { courseId } = useCourseAuthoringContext();
const { currentContainerId } = useOutlineSidebarContext();
const sidebarContentId = currentContainerId || courseId;
const {
data: courseData,
} = useCourseDetails(courseId);
const {
data: contentData,
} = useContentData(currentContainerId);
return (
<div>
<SidebarTitle
title={
contentData && 'displayName' in contentData
? contentData.displayName
: courseData?.name || ''
}
icon={SchoolOutline}
/>
<ContentTagsDrawer
id={sidebarContentId}
variant="component"
/>
</div>
);
};

View File

@@ -3,13 +3,14 @@ import { useMediaQuery } from 'react-responsive';
import { Sidebar } from '@src/generic/sidebar';
import { OUTLINE_SIDEBAR_PAGES } from '@src/course-outline/outline-sidebar/constants';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import { useOutlineSidebarContext } from './OutlineSidebarContext';
import { isOutlineNewDesignEnabled } from '../utils';
import { getOutlineSidebarPages } from './sidebarPages';
const OutlineSideBar = () => {
const isMedium = useMediaQuery({ maxWidth: breakpoints.medium.maxWidth });
const sidebarPages = getOutlineSidebarPages();
const {
currentPageKey,
@@ -31,7 +32,7 @@ const OutlineSideBar = () => {
return (
<Sidebar
pages={OUTLINE_SIDEBAR_PAGES}
pages={sidebarPages}
currentPageKey={currentPageKey}
setCurrentPageKey={setCurrentPageKey}
isOpen={isOpen}

View File

@@ -11,7 +11,7 @@ import { useToggle } from '@openedx/paragon';
import { useStateWithUrlSearchParam } from '@src/hooks';
import { isOutlineNewDesignEnabled } from '../utils';
export type OutlineSidebarPageKeys = 'help' | 'info' | 'add';
export type OutlineSidebarPageKeys = 'help' | 'info' | 'add' | 'align';
export type OutlineFlowType = 'use-section' | 'use-subsection' | 'use-unit' | null;
export type OutlineFlow = {
flowType: 'use-section';
@@ -25,7 +25,7 @@ export type OutlineFlow = {
interface OutlineSidebarContextData {
currentPageKey: OutlineSidebarPageKeys;
setCurrentPageKey: (pageKey: OutlineSidebarPageKeys) => void;
setCurrentPageKey: (pageKey: OutlineSidebarPageKeys, containerId?: string) => void;
currentFlow: OutlineFlow | null;
startCurrentFlow: (flow: OutlineFlow) => void;
stopCurrentFlow: () => void;
@@ -33,12 +33,17 @@ interface OutlineSidebarContextData {
open: () => void;
toggle: () => void;
selectedContainerId?: string;
// The Id of the container used in the current sidebar page
// The container is not necessarily selected to open a selected sidebar.
// Example: Align sidebar
currentContainerId?: string;
openContainerInfoSidebar: (containerId: string) => void;
}
const OutlineSidebarContext = createContext<OutlineSidebarContextData | undefined>(undefined);
export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => {
const [currentContainerId, setCurrentContainerId] = useState<string>();
const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam<OutlineSidebarPageKeys>(
'info',
'sidebar',
@@ -64,9 +69,10 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
setCurrentFlow(null);
}, [setCurrentFlow]);
const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => {
const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys, containerId?: string) => {
setCurrentPageKeyState(pageKey);
setCurrentFlow(null);
setCurrentContainerId(containerId);
open();
}, [open, setCurrentFlow]);
@@ -104,6 +110,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
open,
toggle,
selectedContainerId,
currentContainerId,
openContainerInfoSidebar,
}),
[
@@ -116,6 +123,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
open,
toggle,
selectedContainerId,
currentContainerId,
openContainerInfoSidebar,
],
);

View File

@@ -1,28 +0,0 @@
import { HelpOutline, Info, Plus } from '@openedx/paragon/icons';
import type { SidebarPage } from '@src/generic/sidebar';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import { OutlineInfoSidebar } from './OutlineInfoSidebar';
import messages from './messages';
import { AddSidebar } from './AddSidebar';
import type { OutlineSidebarPageKeys } from './OutlineSidebarContext';
export type OutlineSidebarPages = Record<OutlineSidebarPageKeys, SidebarPage>;
export const OUTLINE_SIDEBAR_PAGES: OutlineSidebarPages = {
info: {
component: OutlineInfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
help: {
component: OutlineHelpSidebar,
icon: HelpOutline,
title: messages.sidebarButtonHelp,
},
add: {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
hideFromActionMenu: true,
},
};

View File

@@ -80,6 +80,11 @@ const messages = defineMessages({
defaultMessage: 'Info',
description: 'Button label for the info sidebar',
},
sidebarButtonAlign: {
id: 'course-authoring.course-outline.sidebar.sidebar-button-align',
defaultMessage: 'Align',
description: 'Alt text for the align button in the outline sidebar',
},
sidebarSectionSummary: {
id: 'course-authoring.course-outline.sidebar.sidebar-section-summary',
defaultMessage: 'Course Content Summary',

View File

@@ -0,0 +1,46 @@
import { getConfig } from '@edx/frontend-platform';
import {
HelpOutline, Info, Plus, Tag,
} from '@openedx/paragon/icons';
import type { SidebarPage } from '@src/generic/sidebar';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import { OutlineInfoSidebar } from './OutlineInfoSidebar';
import messages from './messages';
import { AddSidebar } from './AddSidebar';
import { OutlineAlignSidebar } from './OutlineAlignSidebar';
export type OutlineSidebarPages = {
info: SidebarPage;
help: SidebarPage;
add: SidebarPage;
align?: SidebarPage;
};
export const getOutlineSidebarPages = (): OutlineSidebarPages => {
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
return {
info: {
component: OutlineInfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
...(showAlignSidebar && {
align: {
component: OutlineAlignSidebar,
icon: Tag,
title: messages.sidebarButtonAlign,
},
}),
help: {
component: OutlineHelpSidebar,
icon: HelpOutline,
title: messages.sidebarButtonHelp,
},
add: {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
hideFromActionMenu: true,
},
} satisfies OutlineSidebarPages;
};

View File

@@ -3,8 +3,10 @@ import {
act, fireEvent, initializeMocks, render, screen, waitFor, within,
} from '@src/testUtils';
import { XBlock } from '@src/data/types';
import { Info } from '@openedx/paragon/icons';
import SectionCard from './SectionCard';
import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext';
import * as OutlineSidebarContext from '../outline-sidebar/OutlineSidebarContext';
import { OutlineInfoSidebar } from '../outline-sidebar/OutlineInfoSidebar';
const mockUseAcceptLibraryBlockChanges = jest.fn();
const mockUseIgnoreLibraryBlockChanges = jest.fn();
@@ -118,7 +120,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
routerProps: {
initialEntries: [entry],
},
extraWrapper: OutlineSidebarProvider,
extraWrapper: OutlineSidebarContext.OutlineSidebarProvider,
},
);
@@ -306,4 +308,71 @@ describe('<SectionCard />', () => {
await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled());
});
it('should open legacy manage tags', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'false',
});
renderComponent();
const element = await screen.findByTestId('section-card');
const menu = await within(element).findByTestId('section-card-header__menu-button');
await fireEvent.click(menu);
const manageTagsBtn = await within(element).findByTestId('section-card-header__menu-manage-tags-button');
expect(manageTagsBtn).toBeInTheDocument();
await fireEvent.click(manageTagsBtn);
const drawer = await screen.findByRole('alert');
expect(within(drawer).getByText(/manage tags/i));
});
it('should open align sidebar', async () => {
const mockSetCurrentPageKey = jest.fn();
const testSidebarPage = {
component: OutlineInfoSidebar,
icon: Info,
title: '',
};
jest
.spyOn(OutlineSidebarContext, 'useOutlineSidebarContext')
.mockImplementation(() => ({
setCurrentPageKey: mockSetCurrentPageKey,
currentPageKey: 'info',
sidebarPages: {
info: testSidebarPage,
help: testSidebarPage,
add: testSidebarPage,
},
isOpen: true,
open: jest.fn(),
toggle: jest.fn(),
currentFlow: null,
startCurrentFlow: jest.fn(),
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
}));
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true',
});
renderComponent();
const element = await screen.findByTestId('section-card');
const menu = await within(element).findByTestId('section-card-header__menu-button');
await fireEvent.click(menu);
const manageTagsBtn = await within(element).findByTestId('section-card-header__menu-manage-tags-button');
expect(manageTagsBtn).toBeInTheDocument();
await fireEvent.click(manageTagsBtn);
await waitFor(() => {
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', section.id);
});
});
});

View File

@@ -4,10 +4,12 @@ import {
act, fireEvent, initializeMocks, render, screen, waitFor, within,
} from '@src/testUtils';
import { XBlock } from '@src/data/types';
import { Info } from '@openedx/paragon/icons';
import userEvent from '@testing-library/user-event';
import cardHeaderMessages from '../card-header/messages';
import SubsectionCard from './SubsectionCard';
import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext';
import * as OutlineSidebarContext from '../outline-sidebar/OutlineSidebarContext';
import { OutlineInfoSidebar } from '../outline-sidebar/OutlineInfoSidebar';
let store;
const containerKey = 'lct:org:lib:unit:1';
@@ -145,7 +147,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
routerProps: {
initialEntries: [entry],
},
extraWrapper: OutlineSidebarProvider,
extraWrapper: OutlineSidebarContext.OutlineSidebarProvider,
},
);
@@ -416,4 +418,71 @@ describe('<SubsectionCard />', () => {
await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled());
});
it('should open legacy manage tags', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'false',
});
renderComponent();
const element = await screen.findByTestId('subsection-card');
const menu = await within(element).findByTestId('subsection-card-header__menu-button');
await fireEvent.click(menu);
const manageTagsBtn = await within(element).findByTestId('subsection-card-header__menu-manage-tags-button');
expect(manageTagsBtn).toBeInTheDocument();
await fireEvent.click(manageTagsBtn);
const drawer = await screen.findByRole('alert');
expect(within(drawer).getByText(/manage tags/i));
});
it('should open align sidebar', async () => {
const mockSetCurrentPageKey = jest.fn();
const testSidebarPage = {
component: OutlineInfoSidebar,
icon: Info,
title: '',
};
jest
.spyOn(OutlineSidebarContext, 'useOutlineSidebarContext')
.mockImplementation(() => ({
setCurrentPageKey: mockSetCurrentPageKey,
currentPageKey: 'info',
sidebarPages: {
info: testSidebarPage,
help: testSidebarPage,
add: testSidebarPage,
},
isOpen: true,
open: jest.fn(),
toggle: jest.fn(),
currentFlow: null,
startCurrentFlow: jest.fn(),
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
}));
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true',
});
renderComponent();
const element = await screen.findByTestId('subsection-card');
const menu = await within(element).findByTestId('subsection-card-header__menu-button');
await fireEvent.click(menu);
const manageTagsBtn = await within(element).findByTestId('subsection-card-header__menu-manage-tags-button');
expect(manageTagsBtn).toBeInTheDocument();
await fireEvent.click(manageTagsBtn);
await waitFor(() => {
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', subsection.id);
});
});
});

View File

@@ -4,9 +4,11 @@ import {
} from '@src/testUtils';
import { XBlock } from '@src/data/types';
import { Info } from '@openedx/paragon/icons';
import UnitCard from './UnitCard';
import cardMessages from '../card-header/messages';
import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext';
import * as OutlineSidebarContext from '../outline-sidebar/OutlineSidebarContext';
import { OutlineInfoSidebar } from '../outline-sidebar/OutlineInfoSidebar';
const mockUseAcceptLibraryBlockChanges = jest.fn();
const mockUseIgnoreLibraryBlockChanges = jest.fn();
@@ -107,7 +109,7 @@ const renderComponent = (props?: object) => render(
{
path: '/course/:courseId',
params: { courseId: '5' },
extraWrapper: OutlineSidebarProvider,
extraWrapper: OutlineSidebarContext.OutlineSidebarProvider,
},
);
@@ -274,4 +276,71 @@ describe('<UnitCard />', () => {
await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled());
});
it('should open legacy manage tags', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'false',
});
renderComponent();
const element = await screen.findByTestId('unit-card');
const menu = await within(element).findByTestId('unit-card-header__menu-button');
await fireEvent.click(menu);
const manageTagsBtn = await within(element).findByTestId('unit-card-header__menu-manage-tags-button');
expect(manageTagsBtn).toBeInTheDocument();
await fireEvent.click(manageTagsBtn);
const drawer = await screen.findByRole('alert');
expect(within(drawer).getByText(/manage tags/i));
});
it('should open align sidebar', async () => {
const mockSetCurrentPageKey = jest.fn();
const testSidebarPage = {
component: OutlineInfoSidebar,
icon: Info,
title: '',
};
jest
.spyOn(OutlineSidebarContext, 'useOutlineSidebarContext')
.mockImplementation(() => ({
setCurrentPageKey: mockSetCurrentPageKey,
currentPageKey: 'info',
sidebarPages: {
info: testSidebarPage,
help: testSidebarPage,
add: testSidebarPage,
},
isOpen: true,
open: jest.fn(),
toggle: jest.fn(),
currentFlow: null,
startCurrentFlow: jest.fn(),
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
}));
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true',
});
renderComponent();
const element = await screen.findByTestId('unit-card');
const menu = await within(element).findByTestId('unit-card-header__menu-button');
await fireEvent.click(menu);
const manageTagsBtn = await within(element).findByTestId('unit-card-header__menu-manage-tags-button');
expect(manageTagsBtn).toBeInTheDocument();
await fireEvent.click(manageTagsBtn);
await waitFor(() => {
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', unit.id);
});
});
});