diff --git a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx index 97eebd89b9..fc4e3d427a 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx @@ -19,7 +19,7 @@ export type OutlineSidebarPages = { align?: SidebarPage; }; -export const getOutlineSidebarPages = () => ({ +const getOutlineSidebarPages = () => ({ info: { component: InfoSidebar, icon: Info, @@ -55,24 +55,24 @@ export const getOutlineSidebarPages = () => ({ * export function CourseOutlineSidebarWrapper( * { component, pluginProps }: { component: React.ReactNode, pluginProps: CourseOutlineAspectsPageProps }, * ) { + * const AnalyticsPage = React.useCallback(() => , [pluginProps]); + * const sidebarPages = useOutlineSidebarPagesContext(); * - * const AnalyticsPage = React.useCallback(() => , [pluginProps]); - * const sidebarPages = useOutlineSidebarPagesContext(); + * const overridedPages = useMemo(() => ({ + * ...sidebarPages, + * analytics: { + * component: AnalyticsPage, + * icon: AutoGraph, + * title: messages.analyticsLabel, + * }, + * }), [sidebarPages, AnalyticsPage]); * - * const overridedPages = useMemo(() => ({ - * ...sidebarPages, - * analytics: { - * component: AnalyticsPage, - * icon: AutoGraph, - * title: messages.analyticsLabel, - * }, - * }), [sidebarPages, AnalyticsPage]); - * - * return ( - * - * {component} - * - *} + * return ( + * + * {component} + * + * ); + * } */ export const OutlineSidebarPagesContext = createContext(undefined); @@ -94,6 +94,7 @@ export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesPro export const useOutlineSidebarPagesContext = (): OutlineSidebarPages => { const ctx = useContext(OutlineSidebarPagesContext); + // istanbul ignore if: this should never happen if (ctx === undefined) { throw new Error('useOutlineSidebarPages must be used within an OutlineSidebarPagesProvider'); } return ctx; }; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.tsx similarity index 97% rename from src/course-unit/CourseUnit.test.jsx rename to src/course-unit/CourseUnit.test.tsx index f1eea697aa..32ec0f4c05 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.tsx @@ -122,10 +122,10 @@ jest.mock('@src/studio-home/hooks', () => ({ * This can be used to mimic events like deletion or other actions * sent from Backbone or other sources via postMessage. * - * @param {string} type - The type of the message event (e.g., 'deleteXBlock'). - * @param {Object} payload - The payload data for the message event. + * @param type - The type of the message event (e.g., 'deleteXBlock'). + * @param payload - The payload data for the message event. */ -function simulatePostMessageEvent(type, payload) { +function simulatePostMessageEvent(type: string, payload?: object) { const messageEvent = new MessageEvent('message', { data: { type, payload }, }); @@ -331,7 +331,7 @@ describe('', () => { expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage - .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + .replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()), ); simulatePostMessageEvent(messageTypes.deleteXBlock, { @@ -422,7 +422,7 @@ describe('', () => { )).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage - .replace('{xblockCount}', updatedCourseVerticalChildren.length), + .replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()), ); // after removing the xblock, the sidebar status changes to Draft (unpublished changes) expect(await screen.findByText( @@ -485,10 +485,7 @@ describe('', () => { }); axiosMock - .onPost(postXBlockBaseApiUrl({ - parent_locator: blockId, - duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, - })) + .onPost(postXBlockBaseApiUrl()) .replyOnce(200, { locator: '1234567890' }); const updatedCourseVerticalChildren = [ @@ -520,7 +517,7 @@ describe('', () => { expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage - .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + .replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()), ); simulatePostMessageEvent(messageTypes.duplicateXBlock, { @@ -566,7 +563,7 @@ describe('', () => { expect(xblockIframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage - .replace('{xblockCount}', updatedCourseVerticalChildren.length), + .replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()), ); // after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) @@ -616,16 +613,14 @@ describe('', () => { it('checks courseUnit title changing when edit query is successfully', async () => { const user = userEvent.setup(); render(); - let editTitleButton = null; - let titleEditField = null; const newDisplayName = `${unitDisplayName} new`; axiosMock - .onPost(getXBlockBaseApiUrl(blockId, { + .onPost(getXBlockBaseApiUrl(blockId), { metadata: { display_name: newDisplayName, }, - })) + }) .reply(200, { dummy: 'value' }); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) @@ -634,7 +629,6 @@ describe('', () => { xblock_info: { ...courseSectionVerticalMock.xblock_info, metadata: { - ...courseSectionVerticalMock.xblock_info.metadata, display_name: newDisplayName, }, }, @@ -653,15 +647,14 @@ describe('', () => { }, }); - await waitFor(() => { - const unitHeaderTitle = screen.getByTestId('unit-header-title'); - editTitleButton = within(unitHeaderTitle) - .getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); - titleEditField = within(unitHeaderTitle) - .queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); - }); + const unitHeaderTitle = await screen.findByTestId('unit-header-title'); + const editTitleButton = within(unitHeaderTitle) + .getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); + let titleEditField = within(unitHeaderTitle) + .queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); expect(titleEditField).not.toBeInTheDocument(); await user.click(editTitleButton); + titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); await user.clear(titleEditField); @@ -680,7 +673,7 @@ describe('', () => { const user = userEvent.setup(); const { courseKey, locator } = courseCreateXblockMock; axiosMock - .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) + .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId }) .reply(500, {}); render(); @@ -695,7 +688,7 @@ describe('', () => { it('handle creating Problem xblock and showing editor modal', async () => { const user = userEvent.setup(); axiosMock - .onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId })) + .onPost(postXBlockBaseApiUrl(), { type: 'problem', category: 'problem', parent_locator: blockId }) .reply(200, courseCreateXblockMock); render(); @@ -759,17 +752,17 @@ describe('', () => { it('correct addition of a new course unit after click on the "Add new unit" button', async () => { const user = userEvent.setup(); render(); - let units = null; + let units: HTMLElement[] | null = null; const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ - ...updatedAncestorsChild.child_info.children, + ...updatedAncestorsChild.child_info!.children, courseUnitMock, ]); await waitFor(async () => { units = screen.getAllByTestId('course-unit-btn'); - const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; + const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info!.children; expect(units).toHaveLength(courseUnits.length); }); @@ -788,7 +781,7 @@ describe('', () => { const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage }); units = screen.getAllByTestId('course-unit-btn'); const updatedCourseUnits = updatedCourseSectionVerticalData - .xblock_info.ancestor_info.ancestors[0].child_info.children; + .xblock_info.ancestor_info.ancestors[0].child_info!.children; await user.click(addNewUnitBtn); expect(units.length).toEqual(updatedCourseUnits.length); @@ -826,18 +819,18 @@ describe('', () => { const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ - ...updatedAncestorsChild.child_info.children, + ...updatedAncestorsChild.child_info!.children, courseUnitMock, ]); const newDisplayName = `${unitDisplayName} new`; axiosMock - .onPost(getXBlockBaseApiUrl(blockId, { + .onPost(getXBlockBaseApiUrl(blockId), { metadata: { display_name: newDisplayName, }, - })) + }) .reply(200, { dummy: 'value' }) .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, { @@ -845,7 +838,6 @@ describe('', () => { xblock_info: { ...courseSectionVerticalMock.xblock_info, metadata: { - ...courseSectionVerticalMock.xblock_info.metadata, display_name: newDisplayName, }, }, @@ -879,7 +871,7 @@ describe('', () => { const waffleSpy = mockWaffleFlags({ useVideoGalleryFlow: true }); axiosMock - .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) + .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId }) .reply(200, courseCreateXblockMock); render(); @@ -950,12 +942,13 @@ describe('', () => { .replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from), )).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument(); + waffleSpy.mockRestore(); }); it('handles creating Video xblock and showing editor modal', async () => { axiosMock - .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) + .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId }) .reply(200, courseCreateXblockMock); const user = userEvent.setup(); render(); @@ -1160,11 +1153,12 @@ describe('', () => { const modalNotification = screen.getByRole('dialog'); const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityActionButtonText.defaultMessage }); const cancelBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityCancelButtonText.defaultMessage }); - const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' }); + const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalMakeVisibilityTitle.defaultMessage }); expect(makeVisibilityBtn).toBeInTheDocument(); expect(cancelBtn).toBeInTheDocument(); expect(headingElement).toBeInTheDocument(); + expect(headingElement).toHaveClass('pgn__modal-title'); expect(within(modalNotification) .getByText(unitInfoMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument(); @@ -1252,8 +1246,9 @@ describe('', () => { .getByText(unitInfoMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument(); expect(within(modalNotification) .getByText(unitInfoMessages.modalDiscardUnitChangesCancelButtonText.defaultMessage)).toBeInTheDocument(); - const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage, class: 'pgn__modal-title' }); + const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage }); expect(headingElement).toBeInTheDocument(); + expect(headingElement).toHaveClass('pgn__modal-title'); const actionBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalDiscardUnitChangesActionButtonText.defaultMessage }); expect(actionBtn).toBeInTheDocument(); @@ -1398,17 +1393,17 @@ describe('', () => { await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); - let units = null; + let units: HTMLElement[] | null = null; const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ - ...updatedAncestorsChild.child_info.children, + ...updatedAncestorsChild.child_info!.children, courseUnitMock, ]); await waitFor(() => { units = screen.getAllByTestId('course-unit-btn'); - const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; + const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info!.children; expect(units).toHaveLength(courseUnits.length); }); @@ -1425,7 +1420,7 @@ describe('', () => { units = screen.getAllByTestId('course-unit-btn'); const updatedCourseUnits = updatedCourseSectionVerticalData - .xblock_info.ancestor_info.ancestors[0].child_info.children; + .xblock_info.ancestor_info.ancestors[0].child_info!.children; expect(units.length).toEqual(updatedCourseUnits.length); expect(mockedUsedNavigate).toHaveBeenCalled(); @@ -1459,7 +1454,7 @@ describe('', () => { expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage - .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + .replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()), ); simulatePostMessageEvent(messageTypes.copyXBlock, { @@ -1495,7 +1490,7 @@ describe('', () => { expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage - .replace('{xblockCount}', updatedCourseVerticalChildren.length), + .replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()), ); }); }); @@ -1522,12 +1517,12 @@ describe('', () => { const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ - ...updatedAncestorsChild.child_info.children, + ...updatedAncestorsChild.child_info!.children, courseUnitMock, ]); axiosMock - .onPost(postXBlockBaseApiUrl(postXBlockBody)) + .onPost(postXBlockBaseApiUrl(), postXBlockBody) .reply(200, clipboardMockResponse); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) @@ -1575,12 +1570,12 @@ describe('', () => { const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ - ...updatedAncestorsChild.child_info.children, + ...updatedAncestorsChild.child_info!.children, courseUnitMock, ]); axiosMock - .onPost(postXBlockBaseApiUrl(postXBlockBody)) + .onPost(postXBlockBaseApiUrl(), postXBlockBody) .reply(200, clipboardMockResponse); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) @@ -1630,12 +1625,12 @@ describe('', () => { const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ - ...updatedAncestorsChild.child_info.children, + ...updatedAncestorsChild.child_info!.children, courseUnitMock, ]); axiosMock - .onPost(postXBlockBaseApiUrl(postXBlockBody)) + .onPost(postXBlockBaseApiUrl(), postXBlockBody) .reply(200, clipboardMockResponse); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) @@ -1808,7 +1803,7 @@ describe('', () => { }); await waitFor(async () => { - const currentUnit = currentSubsection.child_info.children[0]; + const currentUnit = currentSubsection.child_info!.children[0]; const currentUnitItemBtn = screen.getByRole('button', { name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); @@ -1848,13 +1843,13 @@ describe('', () => { simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator }); - const dismissButton = screen.queryByRole('button', { + const dismissButton = screen.getByRole('button', { name: /dismiss/i, hidden: true, }); - const undoButton = screen.queryByRole('button', { + const undoButton = screen.getByRole('button', { name: messages.undoMoveButton.defaultMessage, hidden: true, }); - const newLocationButton = screen.queryByRole('button', { + const newLocationButton = screen.getByRole('button', { name: messages.newLocationButton.defaultMessage, hidden: true, }); @@ -1894,7 +1889,7 @@ describe('', () => { callbackFn: requestData.callbackFn, }), store.dispatch); - const newLocationButton = screen.queryByRole('button', { + const newLocationButton = screen.getByRole('button', { name: messages.newLocationButton.defaultMessage, hidden: true, }); await user.click(newLocationButton); @@ -2248,6 +2243,7 @@ describe('', () => { ]; sidebarContent.forEach(({ query, type, name }) => { + // @ts-ignore expect(type ? query(type, { name }) : query(name)).toBeInTheDocument(); }); @@ -2273,10 +2269,10 @@ describe('', () => { targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original'; axiosMock - .onPost(postXBlockBaseApiUrl({ + .onPost(postXBlockBaseApiUrl(), { parent_locator: blockId, duplicate_source_locator: targetChild.block_id, - })) + }) .replyOnce(200, { locator: '1234567890' }); axiosMock @@ -2973,7 +2969,7 @@ describe('', () => { // The Meilisearch client-side API uses fetch, not Axios. fetchMock.mockReset(); fetchMock.post(searchEndpoint, (_url, req) => { - const requestData = JSON.parse((req.body ?? '')); + const requestData = JSON.parse((req.body ?? '') as string); const query = requestData?.queries[0]?.q ?? ''; // We have to replace the query (search keywords) in the mock results with the actual query, // because otherwise Instantsearch will update the UI and change the query, diff --git a/src/course-unit/CourseUnit.tsx b/src/course-unit/CourseUnit.tsx index f60338790c..192679ab3d 100644 --- a/src/course-unit/CourseUnit.tsx +++ b/src/course-unit/CourseUnit.tsx @@ -48,6 +48,7 @@ import MoveModal from './move-modal'; import IframePreviewLibraryXBlockChanges from './preview-changes'; import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot'; import { UnitSidebarProvider } from './unit-sidebar/UnitSidebarContext'; +import { UnitSidebarPagesProvider } from './unit-sidebar/UnitSidebarPagesContext'; import { UNIT_VISIBILITY_STATES } from './constants'; import { isUnitPageNewDesignEnabled } from './utils'; @@ -242,178 +243,180 @@ const CourseUnit = () => { return ( - -
- - {movedXBlockParams.isSuccess ? ( - - {intl.formatMessage(messages.undoMoveButton)} - , - , - ]} - onClose={handleCloseXBlockMovedAlert} - /> - ) : null} - - {courseUnit.upstreamInfo?.upstreamLink && ( - + +
+ + {movedXBlockParams.isSuccess ? ( + - {intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)} - - ), - }, - )} - variant="info" - /> - )} - - )} - breadcrumbs={( - - )} - headerActions={( - , + , + ]} + onClose={handleCloseXBlockMovedAlert} + /> + ) : null} + + {courseUnit.upstreamInfo?.upstreamLink && ( + + {intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)} + + ), + }, + )} + variant="info" /> )} - /> -
- {isUnitPageNewDesignEnabled() && isUnitVerticalType && ( - - )} -
- {isUnitVerticalType && ( - - )} -
-
- {currentlyVisibleToStudents && ( - )} - {staticFileNotices && ( - )} - {blockId && ( - )} - {!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData - && /* istanbul ignore next */ ( - handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }) - } - text={intl.formatMessage(messages.pasteButtonText)} + /> +
+ {isUnitPageNewDesignEnabled() && isUnitVerticalType && ( + + )} +
+ {isUnitVerticalType && ( + + )} +
+
+ {currentlyVisibleToStudents && ( + )} - {!readOnly && blockId && ( - + )} + {blockId && ( + + )} + {!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData + && /* istanbul ignore next */ ( + handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }) + } + text={intl.formatMessage(messages.pasteButtonText)} + /> + )} + {!readOnly && blockId && ( + + )} + + +
+ {!isUnitLegacyLibraryType && ( + )} - -
- {!isUnitLegacyLibraryType && ( - - )} -
-
-
-
- - -
+
+
+
+ + +
+
); }; diff --git a/src/course-unit/course-sequence/Sequence.jsx b/src/course-unit/course-sequence/Sequence.jsx index f8a7ea007f..e17db25a72 100644 --- a/src/course-unit/course-sequence/Sequence.jsx +++ b/src/course-unit/course-sequence/Sequence.jsx @@ -4,7 +4,8 @@ import classNames from 'classnames'; import { breakpoints, useWindowSize } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import Loading from '../../generic/Loading'; +import Loading from '@src/generic/Loading'; + import { RequestStatus } from '../../data/constants'; import SequenceNavigation from './sequence-navigation/SequenceNavigation'; import messages from './messages'; diff --git a/src/course-unit/unit-sidebar/UnitSidebar.tsx b/src/course-unit/unit-sidebar/UnitSidebar.tsx index f6d3c597a6..0c1593fdc6 100644 --- a/src/course-unit/unit-sidebar/UnitSidebar.tsx +++ b/src/course-unit/unit-sidebar/UnitSidebar.tsx @@ -1,8 +1,9 @@ import { Sidebar } from '@src/generic/sidebar'; + import LegacySidebar, { LegacySidebarProps } from '../legacy-sidebar'; -import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext'; import { isUnitPageNewDesignEnabled } from '../utils'; -import { useUnitSidebarPages } from './sidebarPages'; +import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext'; +import { useUnitSidebarPagesContext } from './UnitSidebarPagesContext'; export type UnitSidebarProps = { legacySidebarProps: LegacySidebarProps, @@ -22,7 +23,7 @@ export const UnitSidebar = ({ toggle, } = useUnitSidebarContext(); - const sidebarPages = useUnitSidebarPages(); + const sidebarPages = useUnitSidebarPagesContext(); if (!isUnitPageNewDesignEnabled()) { return ( diff --git a/src/course-unit/unit-sidebar/UnitSidebarPagesContext.tsx b/src/course-unit/unit-sidebar/UnitSidebarPagesContext.tsx new file mode 100644 index 0000000000..3054f622c0 --- /dev/null +++ b/src/course-unit/unit-sidebar/UnitSidebarPagesContext.tsx @@ -0,0 +1,104 @@ +import { createContext, useContext, useMemo } from 'react'; +import { getConfig } from '@edx/frontend-platform'; +import { Info, Plus, Tag } from '@openedx/paragon/icons'; + +import type { SidebarPage } from '@src/generic/sidebar'; + +import { InfoSidebar } from './unit-info/InfoSidebar'; +import { AddSidebar } from './AddSidebar'; +import { UnitAlignSidebar } from './UnitAlignSidebar'; +import { useUnitSidebarContext } from './UnitSidebarContext'; +import messages from './messages'; + +export type UnitSidebarPages = { + info: SidebarPage; + add?: SidebarPage; + align?: SidebarPage; +}; + +const getUnitSidebarPages = (readOnly: boolean, hasComponentSelected: boolean) => { + const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'; + + return { + info: { + component: InfoSidebar, + icon: Info, + title: messages.sidebarButtonInfo, + }, + ...(!readOnly && { + add: { + component: AddSidebar, + icon: Plus, + title: messages.sidebarButtonAdd, + disabled: hasComponentSelected, + tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined, + }, + }), + ...(showAlignSidebar && { + align: { + component: UnitAlignSidebar, + icon: Tag, + title: messages.sidebarButtonAlign, + }, + }), + }; +}; + +/** + * Context for the Unit Sidebar Pages. + * + * This could be used in plugins to add new pages to the sidebar. + * + * @example + * + * ```tsx + * export function UnitOutlineSidebarWrapper( + * { component, pluginProps }: { component: React.ReactNode, pluginProps: UnitOutlineAspectsPageProps}, + * ) { + * const sidebarPages = useUnitSidebarPagesContext(); + * const AnalyticsPage = useCallback(() => , [pluginProps]); + * + * const overridedPages = useMemo(() => ({ + * ...sidebarPages, + * analytics: { + * component: AnalyticsPage, + * icon: AutoGraph, + * title: messages.analyticsLabel, + * }, + * }), [sidebarPages, AnalyticsPage]); + * + * return ( + * + * {component} + * + * ); + * } + */ +export const UnitSidebarPagesContext = createContext(undefined); + +type UnitSidebarPagesProviderProps = { + children: React.ReactNode; +}; + +export const UnitSidebarPagesProvider = ({ children }: UnitSidebarPagesProviderProps) => { + const { readOnly, selectedComponentId } = useUnitSidebarContext(); + + const hasComponentSelected = selectedComponentId !== undefined; + + const sidebarPages = useMemo( + () => getUnitSidebarPages(readOnly, hasComponentSelected), + [readOnly, hasComponentSelected], + ); + + return ( + + {children} + + ); +}; + +export const useUnitSidebarPagesContext = (): UnitSidebarPages => { + const ctx = useContext(UnitSidebarPagesContext); + if (ctx === undefined) { throw new Error('useUnitSidebarPages must be used within an UnitSidebarPagesProvider'); } + return ctx; +}; diff --git a/src/course-unit/unit-sidebar/sidebarPages.ts b/src/course-unit/unit-sidebar/sidebarPages.ts deleted file mode 100644 index f54dd96e25..0000000000 --- a/src/course-unit/unit-sidebar/sidebarPages.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 { UnitAlignSidebar } from './UnitAlignSidebar'; -import { AddSidebar } from './AddSidebar'; -import { useUnitSidebarContext } from './UnitSidebarContext'; -import { InfoSidebar } from './unit-info/InfoSidebar'; - -export type UnitSidebarPages = { - info: SidebarPage; - align?: SidebarPage; - add?: SidebarPage; -}; - -/** - * Sidebar pages for the unit sidebar - * - * This has been separated from the context to avoid a cyclical import - * if you want to use the context in the sidebar pages. - */ -export const useUnitSidebarPages = (): UnitSidebarPages => { - const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'; - const { readOnly, selectedComponentId } = useUnitSidebarContext(); - const hasComponentSelected = selectedComponentId !== undefined; - return { - info: { - component: InfoSidebar, - icon: Info, - title: messages.sidebarButtonInfo, - }, - ...(!readOnly && { - add: { - component: AddSidebar, - icon: Plus, - title: messages.sidebarButtonAdd, - disabled: hasComponentSelected, - tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined, - }, - }), - ...(showAlignSidebar && { - align: { - component: UnitAlignSidebar, - icon: Tag, - title: messages.sidebarButtonAlign, - }, - }), - }; -}; diff --git a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx index 48dc7c76a8..4bcdc29157 100644 --- a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx +++ b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx @@ -205,7 +205,7 @@ export const UnitInfoSidebar = () => { }, []); return ( -
+ <> {
- + ); }; diff --git a/src/generic/sidebar/Sidebar.tsx b/src/generic/sidebar/Sidebar.tsx index 56f9aa699d..ebfd06aee4 100644 --- a/src/generic/sidebar/Sidebar.tsx +++ b/src/generic/sidebar/Sidebar.tsx @@ -144,7 +144,6 @@ export function Sidebar({ > {Object.entries(pages).map(([key, page]) => { const buttonData = { - key, value: key, src: page.icon, alt: intl.formatMessage(page.title), @@ -155,6 +154,7 @@ export function Sidebar({ if (page.tooltip) { return ( {intl.formatMessage(page.tooltip)}} diff --git a/src/generic/sidebar/SidebarContent.tsx b/src/generic/sidebar/SidebarContent.tsx index 155c43e0c8..312752f3c0 100644 --- a/src/generic/sidebar/SidebarContent.tsx +++ b/src/generic/sidebar/SidebarContent.tsx @@ -27,14 +27,22 @@ interface SidebarContentProps { * * ``` */ -export const SidebarContent = ({ children } : SidebarContentProps) => ( - - {Array.isArray(children) ? children.map((child, index) => ( - // eslint-disable-next-line react/no-array-index-key - - {child} - {index !== children.length - 1 &&
} -
- )) : children} -
-); +export const SidebarContent = ({ children } : SidebarContentProps) => { + // Flatten the array and filter out empty children to correctly render + // the hr element between each child. + const nonEmptyChildren = Array.isArray(children) + ? children.flat(Infinity).filter(child => !!child) + : [children]; + + return ( + + {nonEmptyChildren.map((child, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {child} + {index !== nonEmptyChildren.length - 1 &&
} +
+ ))} +
+ ); +};