diff --git a/src/content-tags-drawer/ContentTagsCollapsible.d.ts b/src/content-tags-drawer/ContentTagsCollapsible.d.ts index 32a1ff41f..9dfe16c74 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.d.ts +++ b/src/content-tags-drawer/ContentTagsCollapsible.d.ts @@ -7,6 +7,7 @@ import type {} from 'react-select/base'; export interface TagTreeEntry { explicit: boolean; children: Record; + 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; diff --git a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx index 4c41463c2..abf725cf8 100644 --- a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx @@ -191,6 +191,7 @@ const useContentTagsCollapsibleHelper = ( children: {}, canChangeObjecttag: item.canChangeObjecttag, canDeleteObjecttag: item.canDeleteObjecttag, + isCopied: item.isCopied, }; // Populating the SelectableBox with "selected" (explicit) tags diff --git a/src/content-tags-drawer/TagsTree.test.jsx b/src/content-tags-drawer/TagsTree.test.jsx index ed53111fd..f09bc84bc 100644 --- a/src/content-tags-drawer/TagsTree.test.jsx +++ b/src/content-tags-drawer/TagsTree.test.jsx @@ -57,4 +57,17 @@ describe('', () => { fireEvent.click(xButton); expect(mockRemoveTagHandler).toHaveBeenCalled(); }); + + it('should render library lock icon', async () => { + render( + , + ); + + const view = screen.getByText(/hierarchical taxonomy tag 3\.4\.50/i); + expect(within(view).getByTestId('lock-icon')).toBeInTheDocument(); + }); }); diff --git a/src/content-tags-drawer/TagsTree.jsx b/src/content-tags-drawer/TagsTree.tsx similarity index 60% rename from src/content-tags-drawer/TagsTree.jsx rename to src/content-tags-drawer/TagsTree.tsx index 50ce614de..d17541069 100644 --- a/src/content-tags-drawer/TagsTree.jsx +++ b/src/content-tags-drawer/TagsTree.tsx @@ -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 }) => ( + + + + )} + > + + +); + +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} ); }; -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. + + )} /> { tags[key].children @@ -142,6 +172,7 @@ const TagsTree = ({ parentKey={key} lineage={[...lineage, encodeURIComponent(key)]} removeTagHandler={removeTagHandler} + afterTagsComponent={afterTagsComponent} /> )} @@ -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; diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js index 5e6a1e350..6d7fc48bb 100644 --- a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js @@ -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: {}, }, }, diff --git a/src/content-tags-drawer/data/apiHooks.ts b/src/content-tags-drawer/data/apiHooks.ts index 72940b15d..e00393da1 100644 --- a/src/content-tags-drawer/data/apiHooks.ts +++ b/src/content-tags-drawer/data/apiHooks.ts @@ -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, }) ); diff --git a/src/content-tags-drawer/data/types.ts b/src/content-tags-drawer/data/types.ts index 44e671120..52c2f68a2 100644 --- a/src/content-tags-drawer/data/types.ts +++ b/src/content-tags-drawer/data/types.ts @@ -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. */ diff --git a/src/content-tags-drawer/messages.ts b/src/content-tags-drawer/messages.ts index d461d3223..aa3c9bf2f 100644 --- a/src/content-tags-drawer/messages.ts +++ b/src/content-tags-drawer/messages.ts @@ -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; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx index 4089efb2d..79f364abe 100644 --- a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx @@ -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, }; } diff --git a/src/course-outline/card-header/CardHeader.test.tsx b/src/course-outline/card-header/CardHeader.test.tsx index e542f94bb..869bda8b2 100644 --- a/src/course-outline/card-header/CardHeader.test.tsx +++ b/src/course-outline/card-header/CardHeader.test.tsx @@ -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, }, ); }; diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 572c91a3e..1fcf35a05 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -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 = ({ )} {actions.draggable && ( - <> - - {intl.formatMessage(messages.menuMoveUp)} - - - {intl.formatMessage(messages.menuMoveDown)} - - + <> + + {intl.formatMessage(messages.menuMoveUp)} + + + {intl.formatMessage(messages.menuMoveDown)} + + )} {((actions.unlinkable ?? null) !== null || actions.deletable) && } {(actions.unlinkable ?? null) !== null && ( - - {intl.formatMessage(messages.menuUnlink)} - + + {intl.formatMessage(messages.menuUnlink)} + )} {actions.deletable && ( - - {intl.formatMessage(messages.menuDelete)} - + + {intl.formatMessage(messages.menuDelete)} + )} @@ -326,8 +337,8 @@ const CardHeader = ({ closeManageTagsDrawer()} - showSheet={isManageTagsDrawerOpen} + onClose={/* istanbul ignore next */ () => closeLegacyTagsDrawer()} + showSheet={isLegacyManageTagsDrawerOpen} /> ); diff --git a/src/course-outline/header-navigations/HeaderActions.tsx b/src/course-outline/header-navigations/HeaderActions.tsx index b1f758bf6..7bfc2a4f5 100644 --- a/src/course-outline/header-navigations/HeaderActions.tsx +++ b/src/course-outline/header-navigations/HeaderActions.tsx @@ -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 = ({ - {Object.entries(OUTLINE_SIDEBAR_PAGES).filter(([, page]) => !page.hideFromActionMenu) + {Object.entries(sidebarPages).filter(([, page]) => !page.hideFromActionMenu) .map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => ( ({ + ContentTagsDrawer: jest.fn(({ id, variant }) => ( +
+ drawer-mock-{id}-{variant} +
+ )), +})); + +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(); + + 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', + ); + }); +}); diff --git a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx new file mode 100644 index 000000000..73593d057 --- /dev/null +++ b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx @@ -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 ( +
+ + +
+ ); +}; diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.tsx b/src/course-outline/outline-sidebar/OutlineSidebar.tsx index bc1c23bfb..a3afc2a15 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebar.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebar.tsx @@ -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 ( 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(undefined); export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => { + const [currentContainerId, setCurrentContainerId] = useState(); const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam( '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, ], ); diff --git a/src/course-outline/outline-sidebar/constants.ts b/src/course-outline/outline-sidebar/constants.ts deleted file mode 100644 index a91410799..000000000 --- a/src/course-outline/outline-sidebar/constants.ts +++ /dev/null @@ -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; - -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, - }, -}; diff --git a/src/course-outline/outline-sidebar/messages.ts b/src/course-outline/outline-sidebar/messages.ts index 210469432..dd98edbe9 100644 --- a/src/course-outline/outline-sidebar/messages.ts +++ b/src/course-outline/outline-sidebar/messages.ts @@ -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', diff --git a/src/course-outline/outline-sidebar/sidebarPages.ts b/src/course-outline/outline-sidebar/sidebarPages.ts new file mode 100644 index 000000000..e70d8d640 --- /dev/null +++ b/src/course-outline/outline-sidebar/sidebarPages.ts @@ -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; +}; diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index 84681dc08..ae1b3c764 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -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('', () => { 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); + }); + }); }); diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index df23cbc19..d3e425a86 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -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('', () => { 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); + }); + }); }); diff --git a/src/course-outline/unit-card/UnitCard.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index 0b37aad0b..964633641 100644 --- a/src/course-outline/unit-card/UnitCard.test.tsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -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('', () => { 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); + }); + }); });