diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index 776626bc03..9832ba1473 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -18,7 +18,7 @@ import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; import { RequestStatus, RequestStatusType } from './data/constants'; type ModalState = { - value: XBlock | UnitXBlock; + value?: XBlock | UnitXBlock; subsectionId?: string; sectionId?: string; }; diff --git a/src/content-tags-drawer/data/api.mocks.ts b/src/content-tags-drawer/data/api.mocks.ts index 9e7419a138..7b179a9f42 100644 --- a/src/content-tags-drawer/data/api.mocks.ts +++ b/src/content-tags-drawer/data/api.mocks.ts @@ -403,10 +403,19 @@ mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mo /** * Mock for `getContentData()` */ -export async function mockContentData(): Promise { - return mockContentData.data; +export async function mockContentData(contentId: string): Promise { + switch (contentId) { + case mockContentData.textXBlock: + return mockContentData.textXBlockData; + default: + return mockContentData.data; + } } mockContentData.data = { displayName: 'Unit 1', }; +mockContentData.textXBlock = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4'; +mockContentData.textXBlockData = { + displayName: 'Text XBlock 1', +}; mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData); diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 0d7027237f..f85a1ac3ee 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -48,6 +48,7 @@ interface CardHeaderProps { onClickMoveDown: () => void; onClickCopy?: () => void; onClickCard?: (e: React.MouseEvent) => void; + onClickManageTags?: () => void; titleComponent: ReactNode; namePrefix: string; proctoringExamConfigurationLink?: string, @@ -86,6 +87,7 @@ const CardHeader = ({ onClickMoveDown, onClickCopy, onClickCard, + onClickManageTags, titleComponent, namePrefix, actions, @@ -113,6 +115,7 @@ const CardHeader = ({ if (showNewSidebar && showAlignSidebar) { setCurrentPageKey('align'); onClickMenuButton(); + onClickManageTags?.(); } else { openLegacyTagsDrawer(); } diff --git a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx index 486b9963f6..5856736dce 100644 --- a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx +++ b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx @@ -9,12 +9,11 @@ import { useOutlineSidebarContext } from './OutlineSidebarContext'; export const OutlineAlignSidebar = () => { const { courseId, - currentSelection, setCurrentSelection, } = useCourseAuthoringContext(); const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); - const sidebarContentId = currentSelection?.currentId || selectedContainerState?.currentId || courseId; + const sidebarContentId = selectedContainerState?.currentId || courseId; const { data: contentData } = useContentData(sidebarContentId); diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 993e2e012c..b982e30ff8 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -36,6 +36,7 @@ interface OutlineSidebarContextData { open: () => void; toggle: () => void; selectedContainerState?: SelectionState; + setSelectedContainerState: (selectedContainerState?: SelectionState) => void; openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void; clearSelection: () => void; /** Stores last section that allows adding subsections inside it. */ @@ -188,6 +189,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod open, toggle, selectedContainerState, + setSelectedContainerState, openContainerInfoSidebar, clearSelection, lastEditableSection, @@ -205,6 +207,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod open, toggle, selectedContainerState, + setSelectedContainerState, openContainerInfoSidebar, clearSelection, lastEditableSection, diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSection.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSection.tsx index d35417bcbb..f4afe4663f 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/InfoSection.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSection.tsx @@ -2,12 +2,19 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useToggle } from '@openedx/paragon'; import { SchoolOutline, Tag } from '@openedx/paragon/icons'; import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; -import { LibraryReferenceCard } from '@src/course-outline/outline-sidebar/LibraryReferenceCard'; +import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils'; import { normalizeContainerType } from '@src/generic/key-utils'; import { SidebarContent, SidebarSection } from '@src/generic/sidebar'; import { useGetBlockTypes } from '@src/search-manager'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { LibraryReferenceCard } from '@src/generic/library-reference-card/LibraryReferenceCard'; import messages from '../messages'; interface Props { @@ -16,17 +23,39 @@ interface Props { export const InfoSection = ({ itemId }: Props) => { const intl = useIntl(); + const dispatch = useDispatch(); + const queryClient = useQueryClient(); const { data: itemData } = useCourseItemData(itemId); const { data: componentData } = useGetBlockTypes( [`breadcrumbs.usage_key = "${itemId}"`], ); const category = normalizeContainerType(itemData?.category || ''); + const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { courseId } = useCourseAuthoringContext(); const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + // istanbul ignore next + const handleOnPostChangeSync = useCallback(() => { + if (selectedContainerState?.sectionId) { + dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId])); + } + if (courseId) { + invalidateLinksQuery(queryClient, courseId); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.course(courseId), + }); + } + }, [dispatch, selectedContainerState, queryClient, courseId]); + return ( <> - + ', () => { it('should open align sidebar', async () => { const user = userEvent.setup(); const mockSetCurrentPageKey = jest.fn(); + const mockSetSelectedContainerState = jest.fn(); const testSidebarPage = { component: CourseInfoSidebar, @@ -346,6 +347,7 @@ describe('', () => { stopCurrentFlow: jest.fn(), openContainerInfoSidebar: jest.fn(), clearSelection: jest.fn(), + setSelectedContainerState: mockSetSelectedContainerState, })); setConfig({ ...getConfig(), @@ -369,5 +371,9 @@ describe('', () => { currentId: section.id, sectionId: section.id, }); + expect(mockSetSelectedContainerState).toHaveBeenCalledWith({ + currentId: section.id, + sectionId: section.id, + }); }); }); diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 9673ec4f98..e75afbb72b 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -62,7 +62,7 @@ const SectionCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); - const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const { @@ -199,6 +199,13 @@ const SectionCard = ({ }); }; + const handleClickManageTags = () => { + setSelectedContainerState({ + currentId: section.id, + sectionId: section.id, + }); + }; + const handleOpenHighlightsModal = () => { onOpenHighlightsModal(section); }; @@ -284,6 +291,7 @@ const SectionCard = ({ onClickSync={openSyncModal} onClickCard={(e) => onClickCard(e, true)} onClickDuplicate={onDuplicateSubmit} + onClickManageTags={handleClickManageTags} titleComponent={titleComponent} namePrefix={namePrefix} actions={actions} diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index aa624d94e0..1aaf9ea359 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -416,6 +416,7 @@ describe('', () => { it('should open align sidebar', async () => { const user = userEvent.setup(); const mockSetCurrentPageKey = jest.fn(); + const mockSetSelectedContainerState = jest.fn(); const testSidebarPage = { component: CourseInfoSidebar, @@ -441,6 +442,7 @@ describe('', () => { stopCurrentFlow: jest.fn(), openContainerInfoSidebar: jest.fn(), clearSelection: jest.fn(), + setSelectedContainerState: mockSetSelectedContainerState, })); setConfig({ ...getConfig(), @@ -465,5 +467,10 @@ describe('', () => { subsectionId: subsection.id, sectionId: section.id, }); + expect(mockSetSelectedContainerState).toHaveBeenCalledWith({ + currentId: subsection.id, + subsectionId: subsection.id, + sectionId: section.id, + }); }); }); diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 3df7f949e1..a6c727c4a5 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -67,7 +67,7 @@ const SubsectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); - const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); @@ -162,6 +162,14 @@ const SubsectionCard = ({ }); }; + const handleClickManageTags = () => { + setSelectedContainerState({ + currentId: subsection.id, + subsectionId: subsection.id, + sectionId: section.id, + }); + }; + const handleOnPostChangeSync = useCallback(() => { dispatch(fetchCourseSectionQuery([section.id])); if (courseId) { @@ -290,6 +298,7 @@ const SubsectionCard = ({ onClickSync={openSyncModal} onClickCard={(e) => onClickCard(e, true)} onClickDuplicate={onDuplicateSubmit} + onClickManageTags={handleClickManageTags} titleComponent={titleComponent} namePrefix={namePrefix} actions={actions} diff --git a/src/course-outline/unit-card/UnitCard.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index f820a8ba3b..70cb499430 100644 --- a/src/course-outline/unit-card/UnitCard.test.tsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -307,6 +307,7 @@ describe('', () => { it('should open align sidebar', async () => { const user = userEvent.setup(); const mockSetCurrentPageKey = jest.fn(); + const mockSetSelectedContainerState = jest.fn(); const testSidebarPage = { component: CourseInfoSidebar, @@ -332,6 +333,7 @@ describe('', () => { stopCurrentFlow: jest.fn(), openContainerInfoSidebar: jest.fn(), clearSelection: jest.fn(), + setSelectedContainerState: mockSetSelectedContainerState, })); setConfig({ ...getConfig(), @@ -356,5 +358,10 @@ describe('', () => { subsectionId: subsection.id, sectionId: section.id, }); + expect(mockSetSelectedContainerState).toHaveBeenCalledWith({ + currentId: unit.id, + subsectionId: subsection.id, + sectionId: section.id, + }); }); }); diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index f185ae6f7d..e8a8f5049d 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -63,7 +63,7 @@ const UnitCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const [searchParams] = useSearchParams(); - const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext(); const locatorId = searchParams.get('show'); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'unit'; @@ -140,6 +140,14 @@ const UnitCard = ({ }); }; + const handleClickManageTags = () => { + setSelectedContainerState({ + currentId: unit.id, + subsectionId: subsection.id, + sectionId: section.id, + }); + }; + const handleUnitMoveUp = () => { onOrderChange(section, moveUpDetails); }; @@ -269,6 +277,7 @@ const UnitCard = ({ onClickSync={openSyncModal} onClickCard={onClickCard} onClickDuplicate={onDuplicateSubmit} + onClickManageTags={handleClickManageTags} titleComponent={titleComponent} namePrefix={namePrefix} actions={actions} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index dda89e58a0..f1eea697aa 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -25,6 +25,7 @@ import { getClipboardUrl } from '@src/generic/data/api'; import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { mockContentData } from '@src/content-tags-drawer/data/api.mocks'; import { mockContentLibrary, mockGetContentLibraryV2List, @@ -89,6 +90,7 @@ mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockGetContentLibraryV2List.applyMock(); mockLibraryBlockMetadata.applyMock(); +mockContentData.applyMock(); const { block_id: id, @@ -2939,9 +2941,10 @@ describe('', () => { await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); - simulatePostMessageEvent(messageTypes.openManageTags, { contentId: blockId }); + simulatePostMessageEvent(messageTypes.openManageTags, { contentId: mockContentData.textXBlock }); await screen.findByText('Align'); + await screen.findByText(mockContentData.textXBlockData.displayName); }); describe('Add sidebar', () => { @@ -3244,4 +3247,21 @@ describe('', () => { expect(sidebarToggle).toBeInTheDocument(); expect(within(sidebarToggle).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); }); + + it('opens the component info sidebar on postMessage event', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + + render(); + + await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + + simulatePostMessageEvent(messageTypes.xblockSelected, { + contentId: mockContentData.textXBlock, + }); + + await screen.findByText(mockContentData.textXBlockData.displayName); + }); }); diff --git a/src/course-unit/constants.ts b/src/course-unit/constants.ts index 30fce6036d..6b9d48e2bb 100644 --- a/src/course-unit/constants.ts +++ b/src/course-unit/constants.ts @@ -79,4 +79,7 @@ export const messageTypes = { copyXBlockLegacy: 'copyXBlockLegacy', hideProcessingNotification: 'hideProcessingNotification', handleRedirectToXBlockEditPage: 'handleRedirectToXBlockEditPage', + xblockSelected: 'xblockSelected', + clearSelection: 'clearSelection', + selectXblock: 'selectXBlock', }; diff --git a/src/course-unit/header-navigations/HeaderNavigations.test.tsx b/src/course-unit/header-navigations/HeaderNavigations.test.tsx index 74acca0ce6..a650677232 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.test.tsx +++ b/src/course-unit/header-navigations/HeaderNavigations.test.tsx @@ -91,7 +91,7 @@ describe('', () => { expect(infoButton).toBeInTheDocument(); await user.click(infoButton); - expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info'); + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info', null); }); it('click Add button should open add sidebar', async () => { @@ -107,6 +107,6 @@ describe('', () => { expect(addButton).toBeInTheDocument(); await user.click(addButton); - expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add'); + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add', null); }); }); diff --git a/src/course-unit/header-navigations/HeaderNavigations.tsx b/src/course-unit/header-navigations/HeaderNavigations.tsx index a0985b2f9a..093dd0cb54 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.tsx +++ b/src/course-unit/header-navigations/HeaderNavigations.tsx @@ -52,7 +52,7 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat @@ -60,7 +60,7 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat diff --git a/src/course-unit/unit-sidebar/UnitAlignSidebar.test.tsx b/src/course-unit/unit-sidebar/UnitAlignSidebar.test.tsx index 95333ab2d1..36736284be 100644 --- a/src/course-unit/unit-sidebar/UnitAlignSidebar.test.tsx +++ b/src/course-unit/unit-sidebar/UnitAlignSidebar.test.tsx @@ -1,4 +1,5 @@ import { render, screen, initializeMocks } from '@src/testUtils'; +import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; import { UnitAlignSidebar } from './UnitAlignSidebar'; import { UnitSidebarProvider } from './UnitSidebarContext'; @@ -16,9 +17,11 @@ jest.mock('react-router-dom', () => ({ })); const renderComponent = () => render( - - - , + + + + + , ); describe('OutlineAlignSidebar', () => { diff --git a/src/course-unit/unit-sidebar/UnitAlignSidebar.tsx b/src/course-unit/unit-sidebar/UnitAlignSidebar.tsx index 8e095a87eb..4ea33d837c 100644 --- a/src/course-unit/unit-sidebar/UnitAlignSidebar.tsx +++ b/src/course-unit/unit-sidebar/UnitAlignSidebar.tsx @@ -1,7 +1,7 @@ +import { useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { useContentData } from '@src/content-tags-drawer/data/apiHooks'; import { AlignSidebar } from '@src/generic/sidebar/AlignSidebar'; -import { useCallback } from 'react'; import { useUnitSidebarContext } from './UnitSidebarContext'; /** @@ -9,18 +9,19 @@ import { useUnitSidebarContext } from './UnitSidebarContext'; */ export const UnitAlignSidebar = () => { const { blockId } = useParams(); - const { currentComponentId, setCurrentPageKey } = useUnitSidebarContext(); + const { selectedComponentId, setCurrentPageKey } = useUnitSidebarContext(); - const sidebarContentId = currentComponentId || blockId; + const sidebarContentId = selectedComponentId || blockId; const { data: contentData, } = useContentData(sidebarContentId); + // istanbul ignore next const handleBack = useCallback(() => { // Set the align sidebar without current component to back // to unit align sidebar. - setCurrentPageKey('align'); + setCurrentPageKey('align', null); }, [setCurrentPageKey]); return ( @@ -30,7 +31,7 @@ export const UnitAlignSidebar = () => { ? contentData.displayName : '' } contentId={sidebarContentId || ''} - onBackBtnClick={currentComponentId ? handleBack : undefined} + onBackBtnClick={selectedComponentId ? handleBack : undefined} /> ); }; diff --git a/src/course-unit/unit-sidebar/UnitSidebarContext.tsx b/src/course-unit/unit-sidebar/UnitSidebarContext.tsx index d76ff17f8a..4069a805e3 100644 --- a/src/course-unit/unit-sidebar/UnitSidebarContext.tsx +++ b/src/course-unit/unit-sidebar/UnitSidebarContext.tsx @@ -4,19 +4,19 @@ import { import { SidebarPage } from '@src/generic/sidebar'; import { useToggle } from '@openedx/paragon'; import { useStateWithUrlSearchParam } from '@src/hooks'; +import { useIframe } from '@src/generic/hooks/context/hooks'; +import { messageTypes } from '../constants'; export type UnitSidebarPageKeys = 'info' | 'add' | 'align'; export type UnitSidebarPages = Record; interface UnitSidebarContextData { currentPageKey: UnitSidebarPageKeys; - setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string) => void; + setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string | null) => void; currentTabKey?: string; setCurrentTabKey: (tabKey: string | undefined) => void; - // The Id of the component used in the current sidebar page - // The component is not necessarily selected to open a selected sidebar. - // Example: Align sidebar - currentComponentId?: string; + selectedComponentId?: string; + setSelectedComponentId: (componentId?: string) => void; isOpen: boolean; open: () => void; toggle: () => void; @@ -32,6 +32,7 @@ export const UnitSidebarProvider = ({ children?: React.ReactNode, readOnly: boolean, }) => { + const { sendMessageToIframe } = useIframe(); const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam( 'info', 'sidebar', @@ -39,16 +40,23 @@ export const UnitSidebarProvider = ({ (value: UnitSidebarPageKeys) => value, ); const [currentTabKey, setCurrentTabKey] = useState(); - const [currentComponentId, setCurrentComponentId] = useState(); + const [selectedComponentId, setSelectedComponentId] = useState(); const [isOpen, open,, toggle] = useToggle(true); const setCurrentPageKey = useCallback(/* istanbul ignore next */ ( pageKey: UnitSidebarPageKeys, - componentId?: string, + componentId?: string | null, ) => { + // Reset tab setCurrentTabKey(undefined); setCurrentPageKeyState(pageKey); - setCurrentComponentId(componentId); + if (componentId !== undefined) { + setSelectedComponentId(componentId === null ? undefined : componentId); + } + if (componentId === null) { + // Deselect the component + sendMessageToIframe(messageTypes.clearSelection, null); + } open(); }, [open]); @@ -58,7 +66,8 @@ export const UnitSidebarProvider = ({ setCurrentPageKey, currentTabKey, setCurrentTabKey, - currentComponentId, + selectedComponentId, + setSelectedComponentId, isOpen, open, toggle, @@ -69,7 +78,8 @@ export const UnitSidebarProvider = ({ setCurrentPageKey, currentTabKey, setCurrentTabKey, - currentComponentId, + selectedComponentId, + setSelectedComponentId, isOpen, open, toggle, diff --git a/src/course-unit/unit-sidebar/messages.ts b/src/course-unit/unit-sidebar/messages.ts index fe4f04d224..a68e3ad994 100644 --- a/src/course-unit/unit-sidebar/messages.ts +++ b/src/course-unit/unit-sidebar/messages.ts @@ -71,6 +71,11 @@ const messages = defineMessages({ defaultMessage: 'Advanced Blocks', description: 'Title for the add advanced blocks page in the unit sidebar', }, + sidebarDisabledAddTooltip: { + id: 'course-authoring.course-unit.sidebar.add.disabled.tooltip', + defaultMessage: 'Cannot add content to components', + description: 'Tooltip for the Add sidebar when is disabled.', + }, }); export default messages; diff --git a/src/course-unit/unit-sidebar/sidebarPages.ts b/src/course-unit/unit-sidebar/sidebarPages.ts index 47b6ef95aa..f54dd96e25 100644 --- a/src/course-unit/unit-sidebar/sidebarPages.ts +++ b/src/course-unit/unit-sidebar/sidebarPages.ts @@ -2,10 +2,10 @@ import { getConfig } from '@edx/frontend-platform'; import { Info, Tag, Plus } from '@openedx/paragon/icons'; import { SidebarPage } from '@src/generic/sidebar'; import messages from './messages'; -import { UnitInfoSidebar } from './unit-info/UnitInfoSidebar'; import { UnitAlignSidebar } from './UnitAlignSidebar'; import { AddSidebar } from './AddSidebar'; import { useUnitSidebarContext } from './UnitSidebarContext'; +import { InfoSidebar } from './unit-info/InfoSidebar'; export type UnitSidebarPages = { info: SidebarPage; @@ -21,10 +21,11 @@ export type UnitSidebarPages = { */ export const useUnitSidebarPages = (): UnitSidebarPages => { const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'; - const { readOnly } = useUnitSidebarContext(); + const { readOnly, selectedComponentId } = useUnitSidebarContext(); + const hasComponentSelected = selectedComponentId !== undefined; return { info: { - component: UnitInfoSidebar, + component: InfoSidebar, icon: Info, title: messages.sidebarButtonInfo, }, @@ -33,6 +34,8 @@ export const useUnitSidebarPages = (): UnitSidebarPages => { component: AddSidebar, icon: Plus, title: messages.sidebarButtonAdd, + disabled: hasComponentSelected, + tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined, }, }), ...(showAlignSidebar && { diff --git a/src/course-unit/unit-sidebar/unit-info/ComponentInfoSidebar.tsx b/src/course-unit/unit-sidebar/unit-info/ComponentInfoSidebar.tsx new file mode 100644 index 0000000000..42bc3fefd8 --- /dev/null +++ b/src/course-unit/unit-sidebar/unit-info/ComponentInfoSidebar.tsx @@ -0,0 +1,83 @@ +import { useSelector } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useNavigate } from 'react-router-dom'; +import { Tag } from '@openedx/paragon/icons'; +import { useQueryClient } from '@tanstack/react-query'; + +import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; +import { ContentTagsSnippet } from '@src/content-tags-drawer'; +import { useContentData } from '@src/content-tags-drawer/data/apiHooks'; +import type { XBlockData } from '@src/content-tags-drawer/data/types'; +import { getItemIcon } from '@src/generic/block-type-utils'; +import { useIframe } from '@src/generic/hooks/context/hooks'; +import { messageTypes } from '@src/course-unit/constants'; +import { LibraryReferenceCard } from '@src/generic/library-reference-card/LibraryReferenceCard'; +import { getCourseUnitData } from '@src/course-unit/data/selectors'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; + +import { useUnitSidebarContext } from '../UnitSidebarContext'; +import messages from './messages'; + +/** + * Sidebar info for components + */ +export const ComponentInfoSidebar = () => { + const intl = useIntl(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { sendMessageToIframe } = useIframe(); + const unitData = useSelector(getCourseUnitData); + const { courseId } = useCourseAuthoringContext(); + const sectionId = unitData?.ancestorInfo?.ancestors?.find( + (ancestor) => ancestor.category === 'chapter', + )?.id; + + const { + selectedComponentId, + setCurrentPageKey, + } = useUnitSidebarContext(); + + const { data: contentData } = useContentData(selectedComponentId) as { data: XBlockData | undefined }; + + // istanbul ignore next + const handleBack = () => { + setCurrentPageKey('info', null); + }; + + const handleGoToParent = (containerId: string) => { + navigate(`/course/${courseId}?show=${encodeURIComponent(containerId)}`); + }; + + // istanbul ignore next + const handlePostChange = () => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(selectedComponentId), + }); + }; + + return ( + <> + + + + + + + + + ); +}; diff --git a/src/course-unit/unit-sidebar/unit-info/InfoSidebar.tsx b/src/course-unit/unit-sidebar/unit-info/InfoSidebar.tsx new file mode 100644 index 0000000000..cb6116abfb --- /dev/null +++ b/src/course-unit/unit-sidebar/unit-info/InfoSidebar.tsx @@ -0,0 +1,19 @@ +import { useUnitSidebarContext } from '../UnitSidebarContext'; +import { ComponentInfoSidebar } from './ComponentInfoSidebar'; +import { UnitInfoSidebar } from './UnitInfoSidebar'; + +/** + * Main component to render the Info Sidebar in the unit page + * + * Depending of the selected component, this can render + * the unit infor sidebar or the component info sidebar + */ +export const InfoSidebar = () => { + const { selectedComponentId } = useUnitSidebarContext(); + + if (selectedComponentId) { + return ; + } + + return ; +}; diff --git a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx index 9ce62515da..48dc7c76a8 100644 --- a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx +++ b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx @@ -189,7 +189,7 @@ const UnitInfoSettings = () => { }; /** - * Main component that renders the tabs of the info sidebar. + * Component that renders the tabs of the info sidebar for units. */ export const UnitInfoSidebar = () => { const intl = useIntl(); diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts index 45d7fee5fa..458b1b0a10 100644 --- a/src/course-unit/xblock-container-iframe/hooks/types.ts +++ b/src/course-unit/xblock-container-iframe/hooks/types.ts @@ -16,6 +16,7 @@ export type UseMessageHandlersTypes = { handleShowProcessingNotification: (variant: string) => void; handleHideProcessingNotification: () => void; handleRefreshIframe: () => void; + handleXBlockSelected: (id: string) => void; }; export type MessageHandlersTypes = Record void>; diff --git a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx index fdba8ecdc5..e5ee14848c 100644 --- a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx @@ -1,11 +1,12 @@ import { useMemo } from 'react'; import { debounce } from 'lodash'; -import { useClipboard } from '../../../generic/clipboard'; -import { handleResponseErrors } from '../../../generic/saving-error-alert'; -import { NOTIFICATION_MESSAGES } from '../../../constants'; -import { updateSavingStatus } from '../../data/slice'; -import { messageTypes } from '../../constants'; +import { useClipboard } from '@src/generic/clipboard'; +import { messageTypes } from '@src/course-unit/constants'; +import { handleResponseErrors } from '@src/generic/saving-error-alert'; +import { updateSavingStatus } from '@src/course-unit/data/slice'; +import { NOTIFICATION_MESSAGES } from '@src/constants'; + import { MessageHandlersTypes, UseMessageHandlersTypes } from './types'; /** @@ -32,6 +33,7 @@ export const useMessageHandlers = ({ handleHideProcessingNotification, handleEditXBlock, handleRefreshIframe, + handleXBlockSelected, }: UseMessageHandlersTypes): MessageHandlersTypes => { const { copyToClipboard } = useClipboard(); @@ -45,7 +47,7 @@ export const useMessageHandlers = ({ [messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000), [messageTypes.toggleCourseXBlockDropdown]: ({ courseXBlockDropdownHeight, - }: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight), + }) => setIframeOffset(courseXBlockDropdownHeight), [messageTypes.editXBlock]: ({ id }) => handleShowLegacyEditXBlockModal(id), [messageTypes.closeXBlockEditorModal]: handleCloseLegacyEditorXBlockModal, [messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData, @@ -63,6 +65,7 @@ export const useMessageHandlers = ({ payload.type, payload.locator, ), + [messageTypes.xblockSelected]: ({ contentId }) => handleXBlockSelected(contentId), }), [ courseId, handleDeleteXBlock, @@ -71,5 +74,6 @@ export const useMessageHandlers = ({ handleManageXBlockAccess, handleScrollToXBlock, copyToClipboard, + handleXBlockSelected, ]); }; diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index bacf48cfe1..9cbe7b3009 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -24,6 +24,7 @@ import { UnlinkModal } from '@src/generic/unlink-modal'; import VideoSelectorPage from '@src/editors/VideoSelectorPage'; import EditorPage from '@src/editors/EditorPage'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { messageTypes } from '../constants'; import { fetchCourseSectionVerticalData, @@ -53,12 +54,15 @@ const XBlockContainerIframe: FC = ({ }) => { const intl = useIntl(); const dispatch = useDispatch(); - const { setCurrentPageKey } = useUnitSidebarContext(); + const { + setCurrentPageKey, + setSelectedComponentId, + } = useUnitSidebarContext(); // Useful to reload iframe const [iframeKey, setIframeKey] = useState(0); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); - const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false); + const { isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal } = useCourseAuthoringContext(); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle(); @@ -115,7 +119,7 @@ const XBlockContainerIframe: FC = ({ const handleUnlinkXBlock = (usageId: string) => { setUnlinkXBlockId(usageId); - openUnlinkModal(); + openUnlinkModal({}); }; const handleManageXBlockAccess = (usageId: string) => { @@ -129,6 +133,7 @@ const XBlockContainerIframe: FC = ({ const onDeleteSubmit = () => { if (deleteXBlockId) { + setSelectedComponentId(undefined); unitXBlockActions.handleDelete(deleteXBlockId); closeDeleteModal(); } @@ -180,6 +185,7 @@ const XBlockContainerIframe: FC = ({ const handleOpenManageTagsModal = (id: string) => { if (isUnitPageNewDesignEnabled()) { setCurrentPageKey('align', id); + sendMessageToIframe(messageTypes.selectXblock, { locator: id }); } else { // Legacy manage tags modal setConfigureXBlockId(id); @@ -204,6 +210,10 @@ const XBlockContainerIframe: FC = ({ setIframeKey((prev) => prev + 1); }; + const handleXBlockSelected = (id) => { + setCurrentPageKey('info', id); + }; + const messageHandlers = useMessageHandlers({ courseId, dispatch, @@ -222,6 +232,7 @@ const XBlockContainerIframe: FC = ({ handleHideProcessingNotification, handleEditXBlock, handleRefreshIframe, + handleXBlockSelected, }); useIframeMessages(readonly ? {} : messageHandlers); diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx b/src/generic/library-reference-card/LibraryReferenceCard.test.tsx similarity index 80% rename from src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx rename to src/generic/library-reference-card/LibraryReferenceCard.test.tsx index 01720903ca..f1f2414d4c 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx +++ b/src/generic/library-reference-card/LibraryReferenceCard.test.tsx @@ -37,21 +37,16 @@ const itemData = { }, }; -const mockUseOutlineSidebarContext = jest.fn().mockReturnValue({ - selectedContainerState: { currentId: itemData.id, sectionId: sectionData.id }, - openContainerInfoSidebar: jest.fn(), -}); const mockUseCourseAuthoringContext = jest.fn().mockReturnValue({ openUnlinkModal: jest.fn(), courseId: 'course1', }); -jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ - useOutlineSidebarContext: () => mockUseOutlineSidebarContext(), -})); jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => mockUseCourseAuthoringContext(), })); +const mockPostChange = jest.fn(); +const mockOpenContainerInfoSidebar = jest.fn(); const mockOpenSyncModal = jest.fn(); jest.mock('@src/hooks', () => ({ useToggleWithValue: () => [false, {}, mockOpenSyncModal, jest.fn()], @@ -70,7 +65,14 @@ describe('LibraryReferenceCard', () => { }); it('renders the LibraryReferenceCard normally', async () => { - render(); + render( + , + ); expect(await screen.findByText(/Library Reference/)).toBeInTheDocument(); }); @@ -86,7 +88,14 @@ describe('LibraryReferenceCard', () => { axiosMock .onGet(getXBlockApiUrl(itemData.id)) .reply(200, data); - render(); + render( + , + ); expect(await screen.findByText( `The link between ${itemData.displayName} and the library version has been broken. To edit or make changes, unlink component.`, )).toBeInTheDocument(); @@ -109,7 +118,14 @@ describe('LibraryReferenceCard', () => { readyToSync: true, }, }); - render(); + render( + , + ); expect(await screen.findByText( `${itemData.displayName} has available updates`, )).toBeInTheDocument(); @@ -128,7 +144,14 @@ describe('LibraryReferenceCard', () => { downstreamCustomized: ['displayName'], }, }); - render(); + render( + , + ); expect(await screen.findByText( `${itemData.displayName} has been modified in this course.`, )).toBeInTheDocument(); @@ -146,7 +169,14 @@ describe('LibraryReferenceCard', () => { errorMessage: 'some error', }, }); - render(); + render( + , + ); expect(await screen.findByText( `${itemData.displayName} was reused as part of a section which has a broken link. To recieve library updates to this component, unlink the broken link.`, )).toBeInTheDocument(); @@ -180,7 +210,14 @@ describe('LibraryReferenceCard', () => { axiosMock .onGet(getXBlockApiUrl(sectionData.id)) .reply(200, parentData); - render(); + render( + , + ); expect(await screen.findByText( `${itemData.displayName} was reused as part of a section which has updates available.`, )).toBeInTheDocument(); @@ -200,13 +237,20 @@ describe('LibraryReferenceCard', () => { topLevelParentKey: sectionData.upstreamInfo.downstreamKey, }, }); - render(); + render( + , + ); expect(await screen.findByText( `${itemData.displayName} was reused as part of a section.`, )).toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: 'View section' })); - expect(mockUseOutlineSidebarContext().openContainerInfoSidebar).toHaveBeenCalledWith( + expect(mockOpenContainerInfoSidebar).toHaveBeenCalledWith( sectionData.id, undefined, sectionData.id, diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/generic/library-reference-card/LibraryReferenceCard.tsx similarity index 76% rename from src/course-outline/outline-sidebar/LibraryReferenceCard.tsx rename to src/generic/library-reference-card/LibraryReferenceCard.tsx index 7eb1c21a22..806e21f95a 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/generic/library-reference-card/LibraryReferenceCard.tsx @@ -3,38 +3,43 @@ import { Button, Card, Icon, Stack, } from '@openedx/paragon'; import { Cached, LinkOff, Newsstand } from '@openedx/paragon/icons'; -import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; -import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; -import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; -import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { XBlock } from '@src/data/types'; import { ContainerType, getBlockType, normalizeContainerType } from '@src/generic/key-utils'; import { useToggleWithValue } from '@src/hooks'; -import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +import { useMemo } from 'react'; import messages from './messages'; interface SubProps { blockData: XBlock; displayName: string; openSyncModal: (val: XBlock) => void; + sectionId?: string; } -const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { +interface HasTopParentSubProps extends SubProps { + goToParent: (containerId: string, subsectionId?: string, sectionId?: string) => void; +} + +const HasTopParentTextAndButton = ({ + blockData, + displayName, + openSyncModal, + goToParent, + sectionId, +}: HasTopParentSubProps) => { const { upstreamInfo } = blockData; - const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); const { openUnlinkModal } = useCourseAuthoringContext(); const { data: parentData, isPending } = useCourseItemData(upstreamInfo?.topLevelParentKey); const handleUnlinkClick = () => { // istanbul ignore if - if (!selectedContainerState?.sectionId || !parentData) { + if (!sectionId || !parentData) { return; } - openUnlinkModal({ value: parentData, sectionId: selectedContainerState.sectionId }); + openUnlinkModal({ value: parentData, sectionId }); }; const handleSyncClick = () => { @@ -52,17 +57,17 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su } const category = getBlockType(upstreamInfo.topLevelParentKey) as ContainerType; if ([ContainerType.Chapter, ContainerType.Section].includes(category)) { - return openContainerInfoSidebar( + return goToParent( upstreamInfo.topLevelParentKey, undefined, upstreamInfo.topLevelParentKey, ); } // Only possible option is sequential or subsection - return openContainerInfoSidebar( + return goToParent( upstreamInfo.topLevelParentKey, upstreamInfo.topLevelParentKey, - selectedContainerState?.sectionId, + sectionId, ); }; @@ -119,9 +124,13 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su ); }; -const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { +const TopLevelTextAndButton = ({ + blockData, + displayName, + openSyncModal, + sectionId, +}: SubProps) => { const { upstreamInfo } = blockData; - const { selectedContainerState } = useOutlineSidebarContext(); const { openUnlinkModal } = useCourseAuthoringContext(); const messageValues = { name: displayName, @@ -129,10 +138,10 @@ const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubPro const handleUnlinkClick = () => { // istanbul ignore if - if (!selectedContainerState?.sectionId) { + if (!sectionId) { return; } - openUnlinkModal({ value: blockData, sectionId: selectedContainerState.sectionId }); + openUnlinkModal({ value: blockData, sectionId }); }; const handleSyncClick = () => { @@ -180,15 +189,23 @@ const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubPro interface Props { itemId?: string; + sectionId?: string; + postChange: (accept: boolean) => void, + goToParent: (containerId: string, subsectionId?: string, sectionId?: string) => void; } -export const LibraryReferenceCard = ({ itemId }: Props) => { +/** + * Libray reference card to show info and actions about + * upstream link of an item. + */ +export const LibraryReferenceCard = ({ + itemId, + sectionId, + postChange, + goToParent, +}: Props) => { const { data: itemData, isPending } = useCourseItemData(itemId); - const { selectedContainerState } = useOutlineSidebarContext(); - const { courseId } = useCourseAuthoringContext(); const [isSyncModalOpen, syncModalData, openSyncModal, closeSyncModal] = useToggleWithValue(); - const dispatch = useDispatch(); - const queryClient = useQueryClient(); const blockSyncData = useMemo(() => { if (!syncModalData?.upstreamInfo?.readyToSync) { @@ -205,19 +222,6 @@ export const LibraryReferenceCard = ({ itemId }: Props) => { }; }, [syncModalData]); - // istanbul ignore next - const handleOnPostChangeSync = useCallback(() => { - if (selectedContainerState?.sectionId) { - dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId])); - } - if (courseId) { - invalidateLinksQuery(queryClient, courseId); - queryClient.invalidateQueries({ - queryKey: courseOutlineQueryKeys.course(courseId), - }); - } - }, [dispatch, selectedContainerState, queryClient, courseId]); - if (!itemData?.upstreamInfo?.upstreamRef) { return null; } @@ -235,11 +239,14 @@ export const LibraryReferenceCard = ({ itemId }: Props) => { blockData={itemData} displayName={itemData.displayName} openSyncModal={openSyncModal} + sectionId={sectionId} /> @@ -249,7 +256,7 @@ export const LibraryReferenceCard = ({ itemId }: Props) => { blockData={blockSyncData} isModalOpen={isSyncModalOpen} closeModal={closeSyncModal} - postChange={handleOnPostChangeSync} + postChange={postChange} /> )} diff --git a/src/generic/library-reference-card/messages.ts b/src/generic/library-reference-card/messages.ts new file mode 100644 index 0000000000..d9978696b4 --- /dev/null +++ b/src/generic/library-reference-card/messages.ts @@ -0,0 +1,66 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + libraryReferenceCardText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.text', + defaultMessage: 'Library Reference', + description: 'Library reference card text in sidebar', + }, + hasTopParentText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-text', + defaultMessage: '{name} was reused as part of a {parentType}.', + description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block', + }, + hasTopParentBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-btn', + defaultMessage: 'View {parentType}', + description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block', + }, + hasTopParentReadyToSyncText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-text', + defaultMessage: '{name} was reused as part of a {parentType} which has updates available.', + description: 'Text displayed in sidebar library reference card when a block has updates available as it was reused as part of a parent block', + }, + hasTopParentReadyToSyncBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-btn', + defaultMessage: 'Review Updates', + description: 'Text displayed in sidebar library reference card button when a block has updates available as it was reused as part of a parent block', + }, + hasTopParentBrokenLinkText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-text', + defaultMessage: '{name} was reused as part of a {parentType} which has a broken link. To recieve library updates to this component, unlink the broken link.', + description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block which has a broken link.', + }, + hasTopParentBrokenLinkBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-btn', + defaultMessage: 'Unlink {parentType}', + description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block which has a broken link.', + }, + topParentBrokenLinkText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-text', + defaultMessage: 'The link between {name} and the library version has been broken. To edit or make changes, unlink component.', + description: 'Text displayed in sidebar library reference card when a block has a broken link.', + }, + topParentBrokenLinkBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-btn', + defaultMessage: 'Unlink from library', + description: 'Text displayed in sidebar library reference card button when a block has a broken link.', + }, + topParentModifiedText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-modified-text', + defaultMessage: '{name} has been modified in this course.', + description: 'Text displayed in sidebar library reference card when it is modified in course.', + }, + topParentReaadyToSyncText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-text', + defaultMessage: '{name} has available updates', + description: 'Text displayed in sidebar library reference card when it is has updates available.', + }, + topParentReaadyToSyncBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-btn', + defaultMessage: 'Review Updates', + description: 'Text displayed in sidebar library reference card button when it is has updates available.', + }, +}); + +export default messages; diff --git a/src/generic/sidebar/Sidebar.tsx b/src/generic/sidebar/Sidebar.tsx index a91e76584c..56f9aa699d 100644 --- a/src/generic/sidebar/Sidebar.tsx +++ b/src/generic/sidebar/Sidebar.tsx @@ -5,6 +5,7 @@ import { Icon, IconButton, IconButtonToggle, + IconButtonWithTooltip, Stack, } from '@openedx/paragon'; import { ResizableBox } from '@src/generic/resizable/Resizable'; @@ -18,6 +19,8 @@ export interface SidebarPage { component: React.ComponentType; icon: React.ComponentType; title: MessageDescriptor; + disabled?: boolean; + tooltip?: MessageDescriptor; } type SidebarPages = Record; @@ -85,7 +88,11 @@ export function Sidebar({ }: SidebarProps) { const intl = useIntl(); - const SidebarComponent = pages[currentPageKey].component; + const { + component: SidebarComponent, + icon: SidebarIcon, + title, + } = pages[currentPageKey]; const activeKey = isOpen ? currentPageKey : undefined; return ( @@ -100,14 +107,15 @@ export function Sidebar({ variant="tertiary" className="x-small text-primary font-weight-bold pl-0" > - {intl.formatMessage(pages[currentPageKey].title)} - + {intl.formatMessage(title)} + {Object.entries(pages).map(([key, page]) => ( setCurrentPageKey(key)} + disabled={page.disabled} > @@ -134,18 +142,30 @@ export function Sidebar({ activeValue={activeKey} onChange={setCurrentPageKey} > - {Object.entries(pages).map(([key, page]) => ( - - ))} + {Object.entries(pages).map(([key, page]) => { + const buttonData = { + key, + value: key, + src: page.icon, + alt: intl.formatMessage(page.title), + className: 'rounded-iconbutton my-2', + disabled: page.disabled, + }; + + if (page.tooltip) { + return ( + {intl.formatMessage(page.tooltip)}} + /> + ); + } + + return ( + + ); + })}