From a06651ce2ef090834bd921f7763dde5745006d55 Mon Sep 17 00:00:00 2001 From: ihor-romaniuk <ihor.romaniuk@raccoongang.com> Date: Wed, 16 Oct 2024 13:54:47 +0200 Subject: [PATCH] feat: [FC-0070] implement move xblock modal --- src/CourseAuthoringRoutes.jsx | 4 +- src/constants.js | 2 + src/course-unit/CourseUnit.jsx | 57 +- src/course-unit/CourseUnit.scss | 1 + src/course-unit/CourseUnit.test.jsx | 284 ++- .../__mocks__/courseOutlineInfo.js | 1683 +++++++++++++++++ src/course-unit/__mocks__/index.js | 1 + src/course-unit/constants.js | 2 + src/course-unit/context/hooks.test.tsx | 24 + src/course-unit/context/hooks.tsx | 12 + src/course-unit/context/iFrameContext.tsx | 42 + src/course-unit/data/api.js | 49 + src/course-unit/data/selectors.js | 3 + src/course-unit/data/slice.js | 21 + src/course-unit/data/thunk.js | 58 + src/course-unit/header-title/HeaderTitle.jsx | 1 + src/course-unit/hooks.jsx | 60 +- src/course-unit/index.js | 1 + src/course-unit/messages.js | 30 + .../components/CategoryIndicator.tsx | 26 + .../move-modal/components/EmptyMessage.tsx | 17 + .../move-modal/components/ModalLoader.tsx | 9 + .../move-modal/components/index.ts | 3 + src/course-unit/move-modal/constants.ts | 41 + src/course-unit/move-modal/hooks.tsx | 234 +++ src/course-unit/move-modal/index.scss | 79 + src/course-unit/move-modal/index.tsx | 164 ++ src/course-unit/move-modal/interfaces.ts | 82 + src/course-unit/move-modal/messages.ts | 81 + src/course-unit/move-modal/moveModal.test.tsx | 182 ++ src/course-unit/move-modal/utils.test.ts | 175 ++ src/course-unit/move-modal/utils.ts | 116 ++ .../xblock-container-iframe/index.tsx | 8 +- .../tests/XblockContainerIframe.test.tsx | 5 +- 34 files changed, 3535 insertions(+), 22 deletions(-) create mode 100644 src/course-unit/__mocks__/courseOutlineInfo.js create mode 100644 src/course-unit/context/hooks.test.tsx create mode 100644 src/course-unit/context/hooks.tsx create mode 100644 src/course-unit/context/iFrameContext.tsx create mode 100644 src/course-unit/move-modal/components/CategoryIndicator.tsx create mode 100644 src/course-unit/move-modal/components/EmptyMessage.tsx create mode 100644 src/course-unit/move-modal/components/ModalLoader.tsx create mode 100644 src/course-unit/move-modal/components/index.ts create mode 100644 src/course-unit/move-modal/constants.ts create mode 100644 src/course-unit/move-modal/hooks.tsx create mode 100644 src/course-unit/move-modal/index.scss create mode 100644 src/course-unit/move-modal/index.tsx create mode 100644 src/course-unit/move-modal/interfaces.ts create mode 100644 src/course-unit/move-modal/messages.ts create mode 100644 src/course-unit/move-modal/moveModal.test.tsx create mode 100644 src/course-unit/move-modal/utils.test.ts create mode 100644 src/course-unit/move-modal/utils.ts diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 0c9d2a1680..ded2f07eae 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -17,7 +17,7 @@ import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; import { CourseUpdates } from './course-updates'; -import { CourseUnit } from './course-unit'; +import { CourseUnit, IframeProvider } from './course-unit'; import { Certificates } from './certificates'; import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; @@ -79,7 +79,7 @@ const CourseAuthoringRoutes = () => { <Route key={path} path={path} - element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>} + element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>} /> ))} <Route diff --git a/src/constants.js b/src/constants.js index f9e84c19de..163a16ef84 100644 --- a/src/constants.js +++ b/src/constants.js @@ -27,6 +27,8 @@ export const NOTIFICATION_MESSAGES = { copying: 'Copying', pasting: 'Pasting', discardChanges: 'Discarding changes', + moving: 'Moving', + undoMoving: 'Undo moving', publishing: 'Publishing', hidingFromStudents: 'Hiding from students', makingVisibleToStudents: 'Making visible to students', diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index a2c585522d..b235e832b9 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -2,10 +2,15 @@ import { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { Container, Layout, Stack } from '@openedx/paragon'; +import { + Container, Layout, Stack, Button, TransitionReplace, +} from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; import { useIntl, injectIntl } from '@edx/frontend-platform/i18n'; -import { Warning as WarningIcon } from '@openedx/paragon/icons'; +import { + Warning as WarningIcon, + CheckCircle as CheckCircleIcon, +} from '@openedx/paragon/icons'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import SubHeader from '../generic/sub-header/SubHeader'; @@ -30,6 +35,7 @@ import LocationInfo from './sidebar/LocationInfo'; import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls'; import { PasteNotificationAlert } from './clipboard'; import XBlockContainerIframe from './xblock-container-iframe'; +import MoveModal from './move-modal'; const CourseUnit = ({ courseId }) => { const { blockId } = useParams(); @@ -55,6 +61,13 @@ const CourseUnit = ({ courseId }) => { handleConfigureSubmit, courseVerticalChildren, canPasteComponent, + isMoveModalOpen, + openMoveModal, + closeMoveModal, + movedXBlockParams, + handleRollbackMovedXBlock, + handleCloseXBlockMovedAlert, + handleNavigateToTargetUnit, } = useCourseUnit({ courseId, blockId }); useEffect(() => { @@ -82,6 +95,40 @@ const CourseUnit = ({ courseId }) => { <> <Container size="xl" className="course-unit px-4"> <section className="course-unit-container mb-4 mt-5"> + <TransitionReplace> + {movedXBlockParams.isSuccess ? ( + <AlertMessage + key="xblock-moved-alert" + data-testid="xblock-moved-alert" + show={movedXBlockParams.isSuccess} + variant="success" + icon={CheckCircleIcon} + title={movedXBlockParams.isUndo + ? intl.formatMessage(messages.alertMoveCancelTitle) + : intl.formatMessage(messages.alertMoveSuccessTitle)} + description={movedXBlockParams.isUndo + ? intl.formatMessage(messages.alertMoveCancelDescription, { title: movedXBlockParams.title }) + : intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })} + aria-hidden={movedXBlockParams.isSuccess} + dismissible + actions={movedXBlockParams.isUndo ? null : [ + <Button + onClick={handleRollbackMovedXBlock} + key="xblock-moved-alert-undo-move-button" + > + {intl.formatMessage(messages.undoMoveButton)} + </Button>, + <Button + onClick={handleNavigateToTargetUnit} + key="xblock-moved-alert-new-location-button" + > + {intl.formatMessage(messages.newLocationButton)} + </Button>, + ]} + onClose={handleCloseXBlockMovedAlert} + /> + ) : null} + </TransitionReplace> <SubHeader hideBorder title={( @@ -147,6 +194,12 @@ const CourseUnit = ({ courseId }) => { text={intl.formatMessage(messages.pasteButtonText)} /> )} + <MoveModal + isOpenModal={isMoveModalOpen} + openModal={openMoveModal} + closeModal={closeMoveModal} + courseId={courseId} + /> </Layout.Element> <Layout.Element> <Stack gap={3}> diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 44a2c93d13..3ada01ca2f 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -3,6 +3,7 @@ @import "./add-component/AddComponent"; @import "./sidebar/Sidebar"; @import "./header-title/HeaderTitle"; +@import "./move-modal"; .course-unit__alert { margin-bottom: 1.75rem; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 3dec79de59..25e0274708 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { - act, render, waitFor, fireEvent, within, screen, + act, render, waitFor, within, screen, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; @@ -18,6 +18,7 @@ import { getCourseSectionVerticalApiUrl, getCourseUnitApiUrl, getCourseVerticalChildrenApiUrl, + getCourseOutlineInfoUrl, getXBlockBaseApiUrl, postXBlockBaseApiUrl, } from './data/api'; @@ -27,6 +28,8 @@ import { fetchCourseSectionVerticalData, fetchCourseUnitQuery, fetchCourseVerticalChildrenData, + getCourseOutlineInfoQuery, + patchUnitItemQuery, } from './data/thunk'; import initializeStore from '../store'; import { @@ -36,6 +39,7 @@ import { courseUnitMock, courseVerticalChildrenMock, clipboardMockResponse, + courseOutlineInfoMock, } from './__mocks__'; import { clipboardUnit } from '../__mocks__'; import { executeThunk } from '../utils'; @@ -49,10 +53,12 @@ import { extractCourseUnitId } from './sidebar/utils'; import CourseUnit from './CourseUnit'; import configureModalMessages from '../generic/configure-modal/messages'; +import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; import addComponentMessages from './add-component/messages'; -import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; +import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; +import { IframeProvider } from './context/iFrameContext'; +import moveModalMessages from './move-modal/messages'; import messages from './messages'; -import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; let axiosMock; let store; @@ -108,7 +114,9 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); const RootWrapper = () => ( <AppProvider store={store}> <IntlProvider locale="en"> - <CourseUnit courseId={courseId} /> + <IframeProvider> + <CourseUnit courseId={courseId} /> + </IframeProvider> </IntlProvider> </AppProvider> ); @@ -123,6 +131,7 @@ describe('<CourseUnit />', () => { roles: [], }, }); + window.scrollTo = jest.fn(); global.localStorage.clear(); store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); @@ -223,12 +232,13 @@ describe('<CourseUnit />', () => { .queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); }); expect(titleEditField).not.toBeInTheDocument(); - fireEvent.click(editTitleButton); + userEvent.click(editTitleButton); titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); - fireEvent.change(titleEditField, { target: { value: newDisplayName } }); - await act(async () => { - fireEvent.blur(titleEditField); - }); + + await userEvent.clear(titleEditField); + await userEvent.type(titleEditField, newDisplayName); + await userEvent.tab(); + expect(titleEditField).toHaveValue(newDisplayName); titleEditField = queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); @@ -393,12 +403,13 @@ describe('<CourseUnit />', () => { const unitHeaderTitle = getByTestId('unit-header-title'); const editTitleButton = within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); - fireEvent.click(editTitleButton); + userEvent.click(editTitleButton); const titleEditField = within(unitHeaderTitle).getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); - fireEvent.change(titleEditField, { target: { value: newDisplayName } }); - await act(async () => fireEvent.blur(titleEditField)); + await userEvent.clear(titleEditField); + await userEvent.type(titleEditField, newDisplayName); + await userEvent.tab(); await waitFor(async () => { const units = getAllByTestId('course-unit-btn'); @@ -1061,4 +1072,253 @@ describe('<CourseUnit />', () => { )).not.toBeInTheDocument(); }); }); + + describe('Move functionality', () => { + const requestData = { + sourceLocator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', + targetParentLocator: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course', + title: 'Getting Started', + currentParentLocator: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5', + isMoving: true, + callbackFn: jest.fn(), + }; + const messageEvent = new MessageEvent('message', { + data: { + type: messageTypes.showMoveXBlockModal, + payload: { + sourceXBlockInfo: { + id: requestData.sourceLocator, + displayName: requestData.title, + }, + sourceParentXBlockInfo: { + id: requestData.currentParentLocator, + category: 'vertical', + hasChildren: true, + }, + }, + }, + origin: '*', + }); + + it('should display "Move Modal" on receive trigger message', async () => { + const { + getByText, + getByRole, + } = render(<RootWrapper />); + + await act(async () => { + await waitFor(() => { + expect(getByText(unitDisplayName)) + .toBeInTheDocument(); + }); + + axiosMock + .onGet(getCourseOutlineInfoUrl(courseId)) + .reply(200, courseOutlineInfoMock); + await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch); + + window.dispatchEvent(messageEvent); + }); + + expect(getByText( + moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title), + )).toBeInTheDocument(); + expect(getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument(); + }); + + it('should navigates to xBlock current unit', async () => { + const { + getByText, + getByRole, + } = render(<RootWrapper />); + + await act(async () => { + await waitFor(() => { + expect(getByText(unitDisplayName)) + .toBeInTheDocument(); + }); + + axiosMock + .onGet(getCourseOutlineInfoUrl(courseId)) + .reply(200, courseOutlineInfoMock); + await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch); + + window.dispatchEvent(messageEvent); + }); + + expect(getByText( + moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title), + )).toBeInTheDocument(); + + const currentSection = courseOutlineInfoMock.child_info.children[1]; + const currentSectionItemBtn = getByRole('button', { + name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, + }); + expect(currentSectionItemBtn).toBeInTheDocument(); + userEvent.click(currentSectionItemBtn); + + await waitFor(() => { + const currentSubsection = currentSection.child_info.children[0]; + const currentSubsectionItemBtn = getByRole('button', { + name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, + }); + expect(currentSubsectionItemBtn).toBeInTheDocument(); + userEvent.click(currentSubsectionItemBtn); + }); + + await waitFor(() => { + const currentComponentLocationText = getByText( + moveModalMessages.moveModalOutlineItemCurrentComponentLocationText.defaultMessage, + ); + expect(currentComponentLocationText).toBeInTheDocument(); + }); + }); + + it('should allow move operation and handles it successfully', async () => { + const { + getByText, + getByRole, + } = render(<RootWrapper />); + + axiosMock + .onPatch(postXBlockBaseApiUrl()) + .reply(200, {}); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, {}); + + await act(async () => { + await waitFor(() => { + expect(getByText(unitDisplayName)) + .toBeInTheDocument(); + }); + + axiosMock + .onGet(getCourseOutlineInfoUrl(courseId)) + .reply(200, courseOutlineInfoMock); + await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch); + + window.dispatchEvent(messageEvent); + }); + + expect(getByText( + moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title), + )).toBeInTheDocument(); + + const currentSection = courseOutlineInfoMock.child_info.children[1]; + const currentSectionItemBtn = getByRole('button', { + name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, + }); + expect(currentSectionItemBtn).toBeInTheDocument(); + userEvent.click(currentSectionItemBtn); + + const currentSubsection = currentSection.child_info.children[1]; + await waitFor(() => { + const currentSubsectionItemBtn = getByRole('button', { + name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, + }); + expect(currentSubsectionItemBtn).toBeInTheDocument(); + userEvent.click(currentSubsectionItemBtn); + }); + + await waitFor(() => { + const currentUnit = currentSubsection.child_info.children[0]; + const currentUnitItemBtn = getByRole('button', { + name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, + }); + expect(currentUnitItemBtn).toBeInTheDocument(); + userEvent.click(currentUnitItemBtn); + }); + + const moveModalBtn = getByRole('button', { + name: moveModalMessages.moveModalSubmitButton.defaultMessage, + }); + expect(moveModalBtn).toBeInTheDocument(); + expect(moveModalBtn).not.toBeDisabled(); + userEvent.click(moveModalBtn); + + await waitFor(() => { + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + expect(window.scrollTo).toHaveBeenCalledTimes(1); + }); + }); + + it('should display "Move Confirmation" alert after moving and undo operations', async () => { + const { + queryByRole, + getByText, + } = render(<RootWrapper />); + + axiosMock + .onPatch(postXBlockBaseApiUrl()) + .reply(200, {}); + + await executeThunk(patchUnitItemQuery({ + sourceLocator: requestData.sourceLocator, + targetParentLocator: requestData.targetParentLocator, + title: requestData.title, + currentParentLocator: requestData.currentParentLocator, + isMoving: requestData.isMoving, + callbackFn: requestData.callbackFn, + }), store.dispatch); + + const dismissButton = queryByRole('button', { + name: /dismiss/i, hidden: true, + }); + const undoButton = queryByRole('button', { + name: messages.undoMoveButton.defaultMessage, hidden: true, + }); + const newLocationButton = queryByRole('button', { + name: messages.newLocationButton.defaultMessage, hidden: true, + }); + + expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(`${requestData.title} has been moved`)).toBeInTheDocument(); + expect(dismissButton).toBeInTheDocument(); + expect(undoButton).toBeInTheDocument(); + expect(newLocationButton).toBeInTheDocument(); + + userEvent.click(undoButton); + + await waitFor(() => { + expect(getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument(); + }); + expect(getByText( + messages.alertMoveCancelDescription.defaultMessage.replace('{title}', requestData.title), + )).toBeInTheDocument(); + expect(dismissButton).toBeInTheDocument(); + expect(undoButton).not.toBeInTheDocument(); + expect(newLocationButton).not.toBeInTheDocument(); + }); + + it('should navigate to new location by button click', async () => { + const { + queryByRole, + } = render(<RootWrapper />); + + axiosMock + .onPatch(postXBlockBaseApiUrl()) + .reply(200, {}); + + await executeThunk(patchUnitItemQuery({ + sourceLocator: requestData.sourceLocator, + targetParentLocator: requestData.targetParentLocator, + title: requestData.title, + currentParentLocator: requestData.currentParentLocator, + isMoving: requestData.isMoving, + callbackFn: requestData.callbackFn, + }), store.dispatch); + + const newLocationButton = queryByRole('button', { + name: messages.newLocationButton.defaultMessage, hidden: true, + }); + userEvent.click(newLocationButton); + expect(mockedUsedNavigate).toHaveBeenCalledWith( + `/course/${courseId}/container/${blockId}/${requestData.currentParentLocator}`, + { replace: true }, + ); + }); + }); }); diff --git a/src/course-unit/__mocks__/courseOutlineInfo.js b/src/course-unit/__mocks__/courseOutlineInfo.js new file mode 100644 index 0000000000..a5646c6fee --- /dev/null +++ b/src/course-unit/__mocks__/courseOutlineInfo.js @@ -0,0 +1,1683 @@ +module.exports = { + id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course', + display_name: 'Demonstration Course', + category: 'course', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + unit_level_discussions: false, + child_info: { + category: 'chapter', + display_name: 'Section', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b', + display_name: 'Introduction', + category: 'chapter', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'sequential', + display_name: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction', + display_name: 'Demo Course Overview', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', + display_name: 'Introduction: Video and Sequences', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4', + display_name: 'Blank HTML Page', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@f7cc083ff66d442eafafd48152881276', + display_name: '“Blank HTML Page”的副本', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd', + display_name: 'Welcome!', + category: 'video', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@6e72ebc448694e42ac56553af74304e7', + display_name: 'Video', + category: 'video', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@8c964a36521a42e3a221e7b8cf6c94fc', + display_name: 'Subsection', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations', + display_name: 'Example Week 1: Getting Started', + category: 'chapter', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'sequential', + display_name: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5', + display_name: 'Lesson 1 - Getting Started', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', + display_name: 'Getting Started', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@82d599b014b246c7a9b5dfc750dc08a9', + display_name: 'Getting Started', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9', + display_name: 'Working with Videos', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@6bcccc2d7343416e9e03fd7325b2f232', + display_name: '', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@7e9b434e6de3435ab99bd3fb25bde807', + display_name: 'A Shared Culture', + category: 'video', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@412dc8dbb6674014862237b23c1f643f', + display_name: 'Working with Videos', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0', + display_name: 'Videos on edX', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@0a3b4139f51a4917a3aff9d519b1eeb6', + display_name: 'Videos on edX', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@5c90cffecd9b48b188cbfea176bf7fe9', + display_name: 'Video', + category: 'video', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@722085be27c84ac693cfebc8ac5da700', + display_name: 'Videos on edX', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76', + display_name: 'Video Demonstrations', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@ed5dccf14ae94353961f46fa07217491', + display_name: '', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@9f9e1373cc8243b985c8750cc8acec7d', + display_name: 'Video Demonstrations', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0', + display_name: 'Video Presentation Styles', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@c2f7008c9ccf4bd09d5d800c98fb0722', + display_name: '', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6', + display_name: 'Connecting a Circuit and a Circuit Diagram', + category: 'video', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@e2cb0e0994f84b0abfa5f4ae42ed9d44', + display_name: 'Video Presentation Styles', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1', + display_name: 'Interactive Questions', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618', + display_name: 'Interactive Questions', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@3169f89efde2452993f2f2d9bc74f5b2', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606', + display_name: 'Exciting Labs and Tools', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@ffcd6351126d4ca984409180e41d1b51', + display_name: 'Exciting Labs and Tools', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@1c8d47c425724346a7968fa1bc745dcd', + display_name: 'Labs and Tools', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e', + display_name: 'Reading Assignments', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@e0254b911fa246218bd98bbdadffef06', + display_name: 'Reading Assignments', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@2574c523e97b477a9d72fbb37bfb995f', + display_name: 'Text', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@932e6f2ce8274072a355a94560216d1a', + display_name: 'Perchance to Dream', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@303034da25524878a2e66fb57c91cf85', + display_name: 'Attributing Blame', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@ffa5817d49e14fec83ad6187cbe16358', + display_name: 'Reading Sample', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193', + display_name: 'When Are Your Exams? ', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf', + display_name: 'When Are Your Exams? ', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions', + display_name: 'Homework - Question Styles', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7', + display_name: 'Pointing on a Picture', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c', + display_name: 'Pointing on a Picture Component', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@e5eac7e1a5a24f5fa7ed77bb6d136591', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c', + display_name: 'Drag and Drop', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@d2e35c1d294b4ba0b3b1048615605d2a', + display_name: 'Drag and Drop', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@5ab88e67d46049b9aa694cb240c39cef', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@54bb9b142c6c4c22afc62bcb628f0e68', + display_name: 'Multiple Choice Questions', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4', + display_name: 'Multiple Choice Questions', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@67c26b1e826e47aaa29757f62bcd1ad0', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0c92347a5c00', + display_name: 'Mathematical Expressions', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@Sample_Algebraic_Problem', + display_name: 'Mathematical Expressions', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@870371212ba04dcf9536d7c7b8f3109e', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_1fef54c2b23b', + display_name: 'Chemical Equations', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@Sample_ChemFormula_Problem', + display_name: 'Chemical Equations', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@4d672c5893cb4f1dad0de67d2008522e', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2889db1677a549abb15eb4d886f95d1c', + display_name: 'Numerical Input', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974', + display_name: 'Numerical Input', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@501aed9d902349eeb2191fa505548de2', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e8a5cc2aed424838853defab7be45e42', + display_name: 'Text input', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02', + display_name: 'Text Input', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@6244918637ed4ff4b5f94a840a7e4b43', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb6b62dbec4348528629cf2232b86aea', + display_name: 'Instructor Programmed Responses', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [], + }, + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions', + display_name: 'Example Week 2: Get Interactive', + category: 'chapter', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'sequential', + display_name: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations', + display_name: "Lesson 2 - Let's Get Interactive!", + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0', + display_name: "Lesson 2 - Let's Get Interactive! ", + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@78d7d3642f3a4dbabbd1b017861aa5f2', + display_name: "Lesson 2: Let's Get Interactive!", + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e', + display_name: 'An Interactive Reference Table', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html_07d547513285', + display_name: 'An Interactive Reference Table', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@6f7a6670f87147149caeff6afa07a526', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471', + display_name: 'Zooming Diagrams', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@700x_pathways', + display_name: 'Zooming Diagrams', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@e0d7423118ab432582d03e8e8dad8e36', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c', + display_name: 'Electronic Sound Experiment', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@Lab_5B_Mosfet_Amplifier_Experiment', + display_name: 'Electronic Sound Experiment', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@03f051f9a8814881a3783d2511613aa6', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d', + display_name: 'New Unit', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@af7fe1335eb841cd81ce31c7ee8eb069', + display_name: 'Video', + category: 'video', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations', + display_name: 'Homework - Labs and Demos', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf', + display_name: 'Labs and Demos', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@2bee8c4248e842a19ba1e73ed8d426c2', + display_name: 'Labs and Demos', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55', + display_name: 'Code Grader', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@891211e17f9a472290a5f12c7a6626d7', + display_name: 'Code Grader', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader', + display_name: 'problem', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@c6cd4bea43454aaea60ad01beb0cf213', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1', + display_name: 'Electric Circuit Simulator', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@d5a5caaf35e84ebc9a747038465dcfb4', + display_name: 'Electronic Circuit Simulator', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@free_form_simulation', + display_name: 'problem', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@logic_gate_problem', + display_name: 'problem', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@4f06b358a96f4d1dae57d6d81acd06f2', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae', + display_name: 'Protein Creator', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@78e3719e864e45f3bee938461f3c3de6', + display_name: 'Protein Builder', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@700x_proteinmake', + display_name: 'Designing Proteins in Two Dimensions', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@ed01bcd164e64038a78964a16eac3edc', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7', + display_name: 'Molecule Structures', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@9b9687073e904ae197799dc415df899f', + display_name: 'Molecule Structures', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e', + display_name: 'Homework - Essays', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050', + display_name: 'Peer Assessed Essays', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@openassessment+block@b24c33ea35954c7889e1d2944d3fe397', + display_name: 'Open Response Assessment', + category: 'openassessment', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@12ad4f3ff4c14114a6e629b00e000976', + display_name: 'Peer Grading', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@social_integration', + display_name: 'Example Week 3: Be Social', + category: 'chapter', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'sequential', + display_name: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e', + display_name: 'Lesson 3 - Be Social', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3c4b575924bf4b75a2f3542df5c354fc', + display_name: 'Be Social', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0', + display_name: 'Be Social', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_3888db0bc286', + display_name: 'Discussion Forums', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7', + display_name: 'Discussion Forums', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@discussion_5deb6081620d', + display_name: 'Discussion Forums', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@312cb4faed17420e82ab3178fc3e251a', + display_name: 'Getting Help', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@8bb218cccf8d40519a971ff0e4901ccf', + display_name: 'Getting Help', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@7efc7bf4a47b4a6cb6595c32cde7712a', + display_name: 'Homework - Find Your Study Buddy', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339', + display_name: 'Blank HTML Page', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc027bcb4fe9afb744d2e8415855', + display_name: 'Homework - Find Your Study Buddy', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@26d89b08f75d48829a63520ed8b0037d', + display_name: 'Homework - Find Your Study Buddy', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5', + display_name: 'Find Your Study Buddy', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@6ab9c442501d472c8ed200e367b4edfa', + display_name: 'More Ways to Connect', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3f2c11aba9434e459676a7d7acc4d960', + display_name: 'Google Hangout', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@d45779ad3d024a40a09ad8cc317c0970', + display_name: 'Text', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@55cbc99f262443d886a25cf84594eafb', + display_name: 'Text', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@ade92343df3d4953a40ab3adc8805390', + display_name: 'Google Hangout', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7', + display_name: 'About Exams and Certificates', + category: 'chapter', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'sequential', + display_name: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow', + display_name: 'edX Exams', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3', + display_name: 'EdX Exams', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530', + display_name: 'EdX Exams', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131', + display_name: 'Immediate Feedback', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@ex_practice_2', + display_name: 'Immediate Feedback', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@4aba537a78774bd5a862485a8563c345', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93', + display_name: 'Getting Answers', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4', + display_name: 'Getting Answers', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@f480df4ce91347c5ae4301ddf6146238', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa', + display_name: 'Answering More Than Once', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@651e0945b77f42e0a4c89b8c3e6f5b3b', + display_name: 'Answering More Than Once', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@b8cec2a19ebf463f90cd3544c7927b0e', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91', + display_name: 'Limited Checks', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@ex_practice_limited_checks', + display_name: 'Limited Checks', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@d1b84dcd39b0423d9e288f27f0f7f242', + display_name: 'Few Checks', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@cd177caa62444fbca48aa8f843f09eac', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a', + display_name: 'Randomized Questions', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@ex_practice_3', + display_name: 'Randomized Questions', + category: 'problem', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@ddede76df71045ffa16de9d1481d2119', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c', + display_name: 'Overall Grade Performance', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c', + display_name: 'Overall Grade', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@1a810b1a3b2447b998f0917d0e5a802b', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff', + display_name: 'Passing a Course', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9', + display_name: 'Passing a Course', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@23e6eda482c04335af2bb265beacaf59', + display_name: '', + category: 'discussion', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45', + display_name: 'Getting Your edX Certificate', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@148ae8fa73ea460eb6f05505da0ba6e6', + display_name: 'Getting Your edX Certificate', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@6b6bee43c7c641509da71c9299cc9f5a', + display_name: 'Blank HTML Page', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@59666313a79946079f5ef4fff36e45f0', + display_name: 'IFrame', + category: 'chapter', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'sequential', + display_name: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@f9fd819dfb224d118e4df4d46c648179', + display_name: 'Subsection', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@c8165538b5f04283879efc8e8deb2d92', + display_name: 'Iframe', + category: 'vertical', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + child_info: { + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@fd3d0a72d0d344af9a53de144d83af1f', + display_name: 'IFrame Tool', + category: 'html', + has_children: false, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@a7deaeb85ee24470871c912536534a59', + display_name: 'Subsection', + category: 'sequential', + has_children: true, + video_sharing_enabled: true, + video_sharing_options: 'per-video', + video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html', + }, + ], + }, + }, + ], + }, +}; diff --git a/src/course-unit/__mocks__/index.js b/src/course-unit/__mocks__/index.js index 8810e61e07..bfbbb9a4bb 100644 --- a/src/course-unit/__mocks__/index.js +++ b/src/course-unit/__mocks__/index.js @@ -4,3 +4,4 @@ export { default as courseUnitMock } from './courseUnit'; export { default as courseCreateXblockMock } from './courseCreateXblock'; export { default as courseVerticalChildrenMock } from './courseVerticalChildren'; export { default as clipboardMockResponse } from './clipboardResponse'; +export { default as courseOutlineInfoMock } from './courseOutlineInfo'; diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index 60b199c21c..ebadb310b4 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -50,6 +50,8 @@ export const messageTypes = { modal: 'plugin.modal', resize: 'plugin.resize', videoFullScreen: 'plugin.videoFullScreen', + refreshXBlock: 'refreshXBlock', + showMoveXBlockModal: 'showMoveXBlockModal', }; export const IFRAME_FEATURE_POLICY = ( diff --git a/src/course-unit/context/hooks.test.tsx b/src/course-unit/context/hooks.test.tsx new file mode 100644 index 0000000000..fb94d57d11 --- /dev/null +++ b/src/course-unit/context/hooks.test.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useIframe } from './hooks'; +import { IframeProvider } from './iFrameContext'; + +describe('useIframe hook', () => { + it('throws an error when used outside of IframeProvider', () => { + const { result } = renderHook(() => useIframe()); + expect(result.error).toEqual(new Error('useIframe must be used within an IframeProvider')); + }); + + it('returns context value when used inside IframeProvider', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + <IframeProvider> + {children} + </IframeProvider> + ); + + const { result } = renderHook(() => useIframe(), { wrapper }); + expect(result.current).toHaveProperty('setIframeRef'); + expect(result.current).toHaveProperty('sendMessageToIframe'); + }); +}); diff --git a/src/course-unit/context/hooks.tsx b/src/course-unit/context/hooks.tsx new file mode 100644 index 0000000000..9760c07afc --- /dev/null +++ b/src/course-unit/context/hooks.tsx @@ -0,0 +1,12 @@ +import { useContext } from 'react'; + +import { IframeContext, IframeContextType } from './iFrameContext'; + +// eslint-disable-next-line import/prefer-default-export +export const useIframe = (): IframeContextType => { + const context = useContext(IframeContext); + if (!context) { + throw new Error('useIframe must be used within an IframeProvider'); + } + return context; +}; diff --git a/src/course-unit/context/iFrameContext.tsx b/src/course-unit/context/iFrameContext.tsx new file mode 100644 index 0000000000..75418f0d39 --- /dev/null +++ b/src/course-unit/context/iFrameContext.tsx @@ -0,0 +1,42 @@ +import { + createContext, MutableRefObject, useRef, useCallback, useMemo, ReactNode, +} from 'react'; +import { logError } from '@edx/frontend-platform/logging'; + +export interface IframeContextType { + setIframeRef: (ref: MutableRefObject<HTMLIFrameElement | null>) => void; + sendMessageToIframe: (messageType: string, payload: unknown) => void; +} + +export const IframeContext = createContext<IframeContextType | undefined>(undefined); + +export const IframeProvider: React.FC = ({ children }: { children: ReactNode }) => { + const iframeRef = useRef<HTMLIFrameElement | null>(null); + const setIframeRef = useCallback((ref: MutableRefObject<HTMLIFrameElement | null>) => { + iframeRef.current = ref.current; + }, []); + + const sendMessageToIframe = useCallback((messageType: string, payload: any) => { + const iframeWindow = iframeRef?.current?.contentWindow; + if (iframeWindow) { + try { + iframeWindow.postMessage({ type: messageType, payload }, '*'); + } catch (error) { + logError('Failed to send message to iframe:', error); + } + } else { + logError('Iframe is not accessible or loaded yet.'); + } + }, [iframeRef]); + + const value = useMemo(() => ({ + setIframeRef, + sendMessageToIframe, + }), [setIframeRef, sendMessageToIframe]); + + return ( + <IframeContext.Provider value={value}> + {children} + </IframeContext.Provider> + ); +}; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index e699e8ad07..7ede6e0236 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -11,6 +11,7 @@ export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/con export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`; export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`; export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`; +export const getCourseOutlineInfoUrl = (courseId) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`; export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; /** @@ -157,3 +158,51 @@ export async function duplicateUnitItem(itemId, XBlockId) { return data; } + +/** + * @typedef {Object} courseOutline + * @property {string} id - The unique identifier of the course. + * @property {string} displayName - The display name of the course. + * @property {string} category - The category of the course (e.g., "course"). + * @property {boolean} hasChildren - Whether the course has child items. + * @property {boolean} unitLevelDiscussions - Indicates if unit-level discussions are available. + * @property {Object} childInfo - Information about the child elements of the course. + * @property {string} childInfo.category - The category of the child (e.g., "chapter"). + * @property {string} childInfo.display_name - The display name of the child element. + * @property {Array<Object>} childInfo.children - List of children within the child_info (could be empty). + */ + +/** + * Get an object containing course outline data. + * @param {string} courseId - The identifier of the course. + * @returns {Promise<courseOutline>} - The course outline data. + */ +export async function getCourseOutlineInfo(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseOutlineInfoUrl(courseId)); + + return camelCaseObject(data); +} + +/** + * @typedef {Object} moveInfo + * @property {string} moveSourceLocator - The locator of the source block being moved. + * @property {string} parentLocator - The locator of the parent block where the source is being moved to. + * @property {number} sourceIndex - The index position of the source block. + */ + +/** + * Move a unit item to new unit. + * @param {string} sourceLocator - The ID of the item to be moved. + * @param {string} targetParentLocator - The ID of the XBlock associated with the item. + * @returns {Promise<moveInfo>} - The move information. + */ +export async function patchUnitItem(sourceLocator, targetParentLocator) { + const { data } = await getAuthenticatedHttpClient() + .patch(postXBlockBaseApiUrl(), { + parent_locator: targetParentLocator, + move_source_locator: sourceLocator, + }); + + return camelCaseObject(data); +} diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index e445ddaf19..824b4545d4 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -13,6 +13,9 @@ export const getCourseSectionVertical = (state) => state.courseUnit.courseSectio export const getCourseId = (state) => state.courseDetail.courseId; export const getSequenceId = (state) => state.courseUnit.sequenceId; export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren; +export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo; +export const getCourseOutlineInfoLoadingStatus = (state) => state.courseUnit.courseOutlineInfoLoadingStatus; +export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams; const getLoadingStatuses = (state) => state.courseUnit.loadingStatus; export const getIsLoading = createSelector( [getLoadingStatuses], diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 39dbeb5a18..aab66ea260 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -20,6 +20,15 @@ const slice = createSlice({ courseSectionVertical: {}, courseVerticalChildren: { children: [], isPublished: true }, staticFileNotices: {}, + courseOutlineInfo: {}, + courseOutlineInfoLoadingStatus: RequestStatus.IN_PROGRESS, + movedXBlockParams: { + isSuccess: false, + isUndo: false, + title: '', + sourceLocator: '', + targetParentLocator: '', + }, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { @@ -103,6 +112,15 @@ const slice = createSlice({ fetchStaticFileNoticesSuccess: (state, { payload }) => { state.staticFileNotices = payload; }, + updateCourseOutlineInfo: (state, { payload }) => { + state.courseOutlineInfo = payload; + }, + updateCourseOutlineInfoLoadingStatus: (state, { payload }) => { + state.courseOutlineInfoLoadingStatus = payload.status; + }, + updateMovedXBlockParams: (state, { payload }) => { + state.movedXBlockParams = { ...state.movedXBlockParams, ...payload }; + }, }, }); @@ -124,6 +142,9 @@ export const { deleteXBlock, duplicateXBlock, fetchStaticFileNoticesSuccess, + updateCourseOutlineInfo, + updateCourseOutlineInfoLoadingStatus, + updateMovedXBlockParams, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 404903302e..a0d421eea3 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -18,6 +18,8 @@ import { handleCourseUnitVisibilityAndData, deleteUnitItem, duplicateUnitItem, + getCourseOutlineInfo, + patchUnitItem, } from './api'; import { updateLoadingCourseUnitStatus, @@ -35,6 +37,9 @@ import { deleteXBlock, duplicateXBlock, fetchStaticFileNoticesSuccess, + updateCourseOutlineInfo, + updateCourseOutlineInfoLoadingStatus, + updateMovedXBlockParams, } from './slice'; import { getNotificationMessage } from './utils'; @@ -260,3 +265,56 @@ export function duplicateUnitItemQuery(itemId, xblockId) { } }; } + +export function getCourseOutlineInfoQuery(courseId) { + return async (dispatch) => { + dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const result = await getCourseOutlineInfo(courseId); + if (result) { + dispatch(updateCourseOutlineInfo(result)); + dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + } + } catch (error) { + handleResponseErrors(error, dispatch, updateSavingStatus); + dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function patchUnitItemQuery({ + sourceLocator = '', + targetParentLocator = '', + title, + currentParentLocator = '', + isMoving, + callbackFn, +}) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES[isMoving ? 'moving' : 'undoMoving'])); + + try { + await patchUnitItem(sourceLocator, isMoving ? targetParentLocator : currentParentLocator); + const xBlockParams = { + title, + isSuccess: true, + isUndo: !isMoving, + sourceLocator, + targetParentLocator, + currentParentLocator, + }; + dispatch(updateMovedXBlockParams(xBlockParams)); + dispatch(updateCourseOutlineInfo({})); + dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + const courseUnit = await getCourseUnitData(currentParentLocator); + dispatch(fetchCourseItemSuccess(courseUnit)); + callbackFn(); + } catch (error) { + handleResponseErrors(error, dispatch, updateSavingStatus); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx index 0d29404ba6..336d986fab 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.jsx @@ -85,6 +85,7 @@ const HeaderTitle = ({ onClose={closeConfigureModal} onConfigureSubmit={onConfigureSubmit} currentItemData={currentItemData} + isSelfPaced={false} /> </div> {getVisibilityMessage()} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index de24082a3d..2e9358a5ee 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,8 +1,10 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useToggle } from '@openedx/paragon'; import { RequestStatus } from '../data/constants'; +import { useCopyToClipboard } from '../generic/clipboard'; import { createNewCourseXBlock, fetchCourseUnitQuery, @@ -12,6 +14,8 @@ import { deleteUnitItemQuery, duplicateUnitItemQuery, editCourseUnitVisibilityAndData, + getCourseOutlineInfoQuery, + patchUnitItemQuery, } from './data/thunk'; import { getCourseSectionVertical, @@ -23,16 +27,23 @@ import { getSequenceStatus, getStaticFileNotices, getCanEdit, + getCourseOutlineInfo, + getMovedXBlockParams, } from './data/selectors'; -import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; -import { PUBLISH_TYPES } from './constants'; - -import { useCopyToClipboard } from '../generic/clipboard'; +import { + changeEditTitleFormOpen, + updateQueryPendingStatus, + updateMovedXBlockParams, +} from './data/slice'; +import { useIframe } from './context/hooks'; +import { messageTypes, PUBLISH_TYPES } from './constants'; // eslint-disable-next-line import/prefer-default-export export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); const [searchParams] = useSearchParams(); + const { sendMessageToIframe } = useIframe(); + const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false); const courseUnit = useSelector(getCourseUnitData); const savingStatus = useSelector(getSavingStatus); @@ -45,6 +56,8 @@ export const useCourseUnit = ({ courseId, blockId }) => { const navigate = useNavigate(); const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen); const canEdit = useSelector(getCanEdit); + const courseOutlineInfo = useSelector(getCourseOutlineInfo); + const movedXBlockParams = useSelector(getMovedXBlockParams); const { currentlyVisibleToStudents } = courseUnit; const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); const { canPasteComponent } = courseVerticalChildren; @@ -113,6 +126,31 @@ export const useCourseUnit = ({ courseId, blockId }) => { }, }; + const handleRollbackMovedXBlock = () => { + const { + sourceLocator, targetParentLocator, title, currentParentLocator, + } = movedXBlockParams; + dispatch(patchUnitItemQuery({ + sourceLocator, + targetParentLocator, + title, + currentParentLocator, + isMoving: false, + callbackFn: () => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + })); + }; + + const handleCloseXBlockMovedAlert = () => { + dispatch(updateMovedXBlockParams({ isSuccess: false })); + }; + + const handleNavigateToTargetUnit = () => { + navigate(`/course/${courseId}/container/${movedXBlockParams.targetParentLocator}`); + }; + useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateQueryPendingStatus(true)); @@ -125,8 +163,15 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(fetchCourseVerticalChildrenData(blockId)); handleNavigate(sequenceId); + dispatch(updateMovedXBlockParams({ isSuccess: false })); }, [courseId, blockId, sequenceId]); + useEffect(() => { + if (isMoveModalOpen && !Object.keys(courseOutlineInfo).length) { + dispatch(getCourseOutlineInfoQuery(courseId)); + } + }, [isMoveModalOpen]); + return { sequenceId, courseUnit, @@ -149,5 +194,12 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleConfigureSubmit, courseVerticalChildren, canPasteComponent, + isMoveModalOpen, + openMoveModal, + closeMoveModal, + handleRollbackMovedXBlock, + handleCloseXBlockMovedAlert, + movedXBlockParams, + handleNavigateToTargetUnit, }; }; diff --git a/src/course-unit/index.js b/src/course-unit/index.js index e6c38e561a..5c5928653b 100644 --- a/src/course-unit/index.js +++ b/src/course-unit/index.js @@ -1,2 +1,3 @@ /* eslint-disable import/prefer-default-export */ export { default as CourseUnit } from './CourseUnit'; +export { IframeProvider } from './context/iFrameContext'; diff --git a/src/course-unit/messages.js b/src/course-unit/messages.js index 4f0418efe5..83779747a0 100644 --- a/src/course-unit/messages.js +++ b/src/course-unit/messages.js @@ -13,6 +13,36 @@ const messages = defineMessages({ id: 'course-authoring.course-unit.paste-component.btn.text', defaultMessage: 'Paste component', }, + alertMoveSuccessTitle: { + id: 'course-authoring.course-unit.alert.xblock.move.success.title', + defaultMessage: 'Success!', + description: 'Title for the success alert when an XBlock is moved successfully', + }, + alertMoveSuccessDescription: { + id: 'course-authoring.course-unit.alert.xblock.move.success.description', + defaultMessage: '{title} has been moved', + description: 'Description for the success alert when an XBlock is moved successfully', + }, + alertMoveCancelTitle: { + id: 'course-authoring.course-unit.alert.xblock.move.cancel.title', + defaultMessage: 'Move cancelled', + description: 'Title for the alert when moving an XBlock is cancelled', + }, + alertMoveCancelDescription: { + id: 'course-authoring.course-unit.alert.xblock.move.cancel.description', + defaultMessage: '{title} has been moved back to its original location', + description: 'Description for the alert when moving an XBlock is cancelled and the XBlock is moved back to its original location', + }, + undoMoveButton: { + id: 'course-authoring.course-unit.alert.xblock.move.undo.btn.text', + defaultMessage: 'Undo move', + description: 'Text for the button allowing users to undo a move action of an XBlock', + }, + newLocationButton: { + id: 'course-authoring.course-unit.alert.xblock.new.location.btn.text', + defaultMessage: 'Take me to the new location', + description: 'Text for the button allowing users to navigate to the new location after an XBlock has been moved', + }, }); export default messages; diff --git a/src/course-unit/move-modal/components/CategoryIndicator.tsx b/src/course-unit/move-modal/components/CategoryIndicator.tsx new file mode 100644 index 0000000000..58160b51b4 --- /dev/null +++ b/src/course-unit/move-modal/components/CategoryIndicator.tsx @@ -0,0 +1,26 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; + +const CategoryIndicator = ({ categoryText, displayName }: { categoryText: string, displayName: string }) => { + const intl = useIntl(); + return ( + <div className="xblock-items-category small text-gray-500"> + <span className="sr-only"> + {intl.formatMessage(messages.moveModalCategoryIndicatorAccessibilityText, { + categoryText, + displayName, + })} + </span> + <span + className="category-text" + aria-hidden="true" + data-testid="move-xblock-modal-category" + > + {categoryText} + </span> + </div> + ); +}; + +export default CategoryIndicator; diff --git a/src/course-unit/move-modal/components/EmptyMessage.tsx b/src/course-unit/move-modal/components/EmptyMessage.tsx new file mode 100644 index 0000000000..99737b0aa3 --- /dev/null +++ b/src/course-unit/move-modal/components/EmptyMessage.tsx @@ -0,0 +1,17 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; + +const EmptyMessage = ({ category, categoryText }: { category: string, categoryText: string }) => { + const intl = useIntl(); + return ( + <li className="xblock-no-child-message"> + {intl.formatMessage(messages.moveModalEmptyCategoryText, { + category, + categoryText: categoryText.toLowerCase(), + })} + </li> + ); +}; + +export default EmptyMessage; diff --git a/src/course-unit/move-modal/components/ModalLoader.tsx b/src/course-unit/move-modal/components/ModalLoader.tsx new file mode 100644 index 0000000000..0685d2b93f --- /dev/null +++ b/src/course-unit/move-modal/components/ModalLoader.tsx @@ -0,0 +1,9 @@ +import { LoadingSpinner } from '../../../generic/Loading'; + +const ModalLoader = () => ( + <div className="move-xblock-modal-loading"> + <LoadingSpinner /> + </div> +); + +export default ModalLoader; diff --git a/src/course-unit/move-modal/components/index.ts b/src/course-unit/move-modal/components/index.ts new file mode 100644 index 0000000000..4d07788092 --- /dev/null +++ b/src/course-unit/move-modal/components/index.ts @@ -0,0 +1,3 @@ +export { default as EmptyMessage } from './EmptyMessage'; +export { default as ModalLoader } from './ModalLoader'; +export { default as CategoryIndicator } from './CategoryIndicator'; diff --git a/src/course-unit/move-modal/constants.ts b/src/course-unit/move-modal/constants.ts new file mode 100644 index 0000000000..d013ec4df7 --- /dev/null +++ b/src/course-unit/move-modal/constants.ts @@ -0,0 +1,41 @@ +import messages from './messages'; + +export const CATEGORIES = { + TEXT: { + section: messages.moveModalBreadcrumbsSections, + subsection: messages.moveModalBreadcrumbsSubsections, + unit: messages.moveModalBreadcrumbsUnits, + component: messages.moveModalBreadcrumbsComponents, + group: messages.moveModalBreadcrumbsGroups, + }, + KEYS: { + course: 'course', + chapter: 'chapter', + section: 'section', + sequential: 'sequential', + subsection: 'subsection', + vertical: 'vertical', + unit: 'unit', + component: 'component', + split_test: 'split_test', + group: 'group', + }, + RELATION_MAP: { + course: 'section', + section: 'subsection', + subsection: 'unit', + unit: 'component', + }, +} as const; + +export const MOVE_DIRECTIONS = { + forward: 'forward', + backward: 'backward', +} as const; + +export const BASIC_BLOCK_TYPES = [ + 'course', + 'chapter', + 'sequential', + 'vertical', +] as const; diff --git a/src/course-unit/move-modal/hooks.tsx b/src/course-unit/move-modal/hooks.tsx new file mode 100644 index 0000000000..69ad13470c --- /dev/null +++ b/src/course-unit/move-modal/hooks.tsx @@ -0,0 +1,234 @@ +import { + useCallback, useEffect, useState, useMemo, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { useMediaQuery } from 'react-responsive'; +import { breakpoints } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { RequestStatus } from '../../data/constants'; +import { useEventListener } from '../../generic/hooks'; +import { getCourseOutlineInfo, getCourseOutlineInfoLoadingStatus } from '../data/selectors'; +import { getCourseOutlineInfoQuery, patchUnitItemQuery } from '../data/thunk'; +import { useIframe } from '../context/hooks'; +import { messageTypes } from '../constants'; +import { CATEGORIES, MOVE_DIRECTIONS } from './constants'; +import { + findParentIds, getBreadcrumbs, getXBlockType, isValidCategory, +} from './utils'; +import { + IState, IUseMoveModalParams, IUseMoveModalReturn, IXBlockInfo, +} from './interfaces'; + +// eslint-disable-next-line import/prefer-default-export +export const useMoveModal = ({ + isOpenModal, closeModal, openModal, courseId, +}: IUseMoveModalParams): IUseMoveModalReturn => { + const { blockId } = useParams<{ blockId: string }>(); + const intl = useIntl(); + const dispatch = useDispatch(); + const { sendMessageToIframe } = useIframe(); + const courseOutlineInfo = useSelector(getCourseOutlineInfo); + const courseOutlineInfoLoadingStatus = useSelector(getCourseOutlineInfoLoadingStatus); + + const initialValues = useMemo<IState>(() => ({ + childrenInfo: { children: courseOutlineInfo.childInfo?.children ?? [], category: CATEGORIES.KEYS.section }, + parentInfo: { parent: courseOutlineInfo, category: CATEGORIES.KEYS.course }, + isValidMove: false, + sourceXBlockInfo: { current: {} as IXBlockInfo, parent: {} as IXBlockInfo }, + visitedAncestors: [courseOutlineInfo], + }), [courseOutlineInfo]); + + const [state, setState] = useState<IState>(initialValues); + + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); + + const currentXBlockParentIds = useMemo( + () => findParentIds(courseOutlineInfo, state.sourceXBlockInfo.current.id as string), + [courseOutlineInfo, state.sourceXBlockInfo.current.id], + ); + + const receiveMessage = useCallback(({ data }: { data: any }) => { + const { payload, type } = data; + + if (type === messageTypes.showMoveXBlockModal) { + setState((prevState) => ({ + ...prevState, + sourceXBlockInfo: { + current: payload.sourceXBlockInfo, + parent: payload.sourceParentXBlockInfo, + }, + })); + openModal(); + } + }, [openModal]); + + useEventListener('message', receiveMessage); + + const updateParentItemsData = useCallback((direction?: string, newParentIndex?: string) => { + setState((prevState: IState) => { + if (!direction) { + return { + ...prevState, + parentInfo: { + parent: initialValues.parentInfo.parent, + category: initialValues.parentInfo.category, + }, + visitedAncestors: [initialValues.parentInfo.parent], + }; + } + + if ( + direction === MOVE_DIRECTIONS.forward && newParentIndex !== undefined + && prevState.childrenInfo.children[newParentIndex] + ) { + const newParent = prevState.childrenInfo.children[newParentIndex]; + return { + ...prevState, + parentInfo: { + parent: newParent, + category: prevState.parentInfo.category, + }, + visitedAncestors: [...prevState.visitedAncestors, newParent], + }; + } + + if ( + direction === MOVE_DIRECTIONS.backward && newParentIndex !== undefined + && prevState.visitedAncestors[newParentIndex] + ) { + return { + ...prevState, + parentInfo: { + parent: prevState.visitedAncestors[newParentIndex], + category: prevState.parentInfo.category, + }, + visitedAncestors: prevState.visitedAncestors.slice(0, parseInt(newParentIndex, 10) + 1), + }; + } + + return prevState; + }); + }, [initialValues]); + + const handleXBlockClick = useCallback((newParentIndex: string) => { + updateParentItemsData(MOVE_DIRECTIONS.forward, newParentIndex); + }, [updateParentItemsData]); + + const handleBreadcrumbsClick = useCallback((newParentIndex: string) => { + updateParentItemsData(MOVE_DIRECTIONS.backward, newParentIndex); + }, [updateParentItemsData]); + + const updateChildrenItemsData = useCallback(() => { + setState((prevState: IState) => ({ + ...prevState, + childrenInfo: { + ...prevState.childrenInfo, + children: prevState.parentInfo.parent?.childInfo?.children || [], + }, + })); + }, []); + + const getCategoryText = useCallback(() => ( + intl.formatMessage(CATEGORIES.TEXT[state.childrenInfo.category]) || '' + ), [intl, state.childrenInfo.category]); + + const breadcrumbs = useMemo(() => ( + getBreadcrumbs(state.visitedAncestors, intl.formatMessage) + ), [state.visitedAncestors]); + + const setDisplayedXBlocksCategories = useCallback(() => { + setState((prevState) => { + const childCategory = CATEGORIES.KEYS.component; + const newParentCategory = getXBlockType(prevState.parentInfo.parent?.category || ''); + + if (prevState.parentInfo.category !== newParentCategory) { + return { + ...prevState, + parentInfo: { + ...prevState.parentInfo, + category: newParentCategory, + }, + childrenInfo: { + ...prevState.childrenInfo, + category: CATEGORIES.RELATION_MAP[newParentCategory] || childCategory, + }, + }; + } + return prevState; + }); + }, []); + + const handleCLoseModal = useCallback(() => { + setState(initialValues); + closeModal(); + }, [initialValues, closeModal]); + + const enableMoveOperation = useCallback((targetParentXBlockInfo: IXBlockInfo) => { + const isValid = isValidCategory(state.sourceXBlockInfo.parent, targetParentXBlockInfo) + && state.sourceXBlockInfo.parent.id !== targetParentXBlockInfo.id // different parent + && state.sourceXBlockInfo.current.id !== targetParentXBlockInfo.id; // different source item + + setState((prevState) => ({ + ...prevState, + isValidMove: isValid, + })); + }, [isValidCategory, state.sourceXBlockInfo]); + + const handleMoveXBlock = useCallback(() => { + const lastAncestor = state.visitedAncestors[state.visitedAncestors.length - 1]; + dispatch(patchUnitItemQuery({ + sourceLocator: state.sourceXBlockInfo.current.id, + targetParentLocator: lastAncestor.id, + title: state.sourceXBlockInfo.current.displayName, + currentParentLocator: blockId, + isMoving: true, + callbackFn: () => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + closeModal(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + })); + }, [state, dispatch, blockId, closeModal]); + + useEffect(() => { + if (isOpenModal && !Object.keys(courseOutlineInfo).length) { + dispatch(getCourseOutlineInfoQuery(courseId)); + } + }, [isOpenModal, courseOutlineInfo, courseId, dispatch]); + + useEffect(() => { + if (isOpenModal && courseOutlineInfoLoadingStatus === RequestStatus.SUCCESSFUL) { + updateParentItemsData(); + } + }, [courseOutlineInfoLoadingStatus, isOpenModal, updateParentItemsData]); + + useEffect(() => { + if (isOpenModal && courseOutlineInfoLoadingStatus === RequestStatus.SUCCESSFUL) { + updateChildrenItemsData(); + setDisplayedXBlocksCategories(); + enableMoveOperation(state.parentInfo.parent); + } + }, [ + state.parentInfo, isOpenModal, courseOutlineInfoLoadingStatus, updateChildrenItemsData, + setDisplayedXBlocksCategories, enableMoveOperation, + ]); + + return { + isLoading: courseOutlineInfoLoadingStatus === RequestStatus.IN_PROGRESS, + isValidMove: state.isValidMove, + isExtraSmall, + parentInfo: state.parentInfo, + childrenInfo: state.childrenInfo, + displayName: state.sourceXBlockInfo.current.displayName, + sourceXBlockId: state.sourceXBlockInfo.current.id, + categoryText: getCategoryText(), + breadcrumbs, + currentXBlockParentIds, + handleXBlockClick, + handleBreadcrumbsClick, + handleCLoseModal, + handleMoveXBlock, + }; +}; diff --git a/src/course-unit/move-modal/index.scss b/src/course-unit/move-modal/index.scss new file mode 100644 index 0000000000..b644898e2d --- /dev/null +++ b/src/course-unit/move-modal/index.scss @@ -0,0 +1,79 @@ +.move-xblock-modal { + max-width: 57.5rem; + + .move-xblock-modal-loading { + min-height: 10rem; + display: flex; + align-items: center; + justify-content: center; + } + + .pgn__modal-header, + .pgn__modal-footer { + z-index: 2; + } + + .pgn__modal-header { + @include pgn-box-shadow(2, "centered"); + } + + .pgn__modal-footer { + @include pgn-box-shadow(2, "down"); + } + + .pgn__modal-body { + background: $white; + padding-left: 0; + padding-right: 0; + } + + .pgn__breadcrumb { + border-bottom: 1px solid $light-300; + padding: map-get($spacers, 1) map-get($spacers, 4) $spacer; + + .list-inline { + flex-wrap: wrap; + } + + .list-inline-item { + &.active, + a.link-muted { + color: $dark-500; + } + + a.link-muted { + cursor: pointer; + } + } + } + + .xblock-items-category { + padding: $spacer map-get($spacers, 4) map-get($spacers, 2\.5); + } + + .xblock-items-container { + list-style: none; + } + + .xblock-item { + .btn, + .component { + display: flex; + border-radius: 0; + width: 100%; + gap: map-get($spacers, 2); + padding: .5625rem $spacer .5625rem map-get($spacers, 4); + } + + .btn { + &:hover { + background: $light-300; + text-decoration: none; + } + } + } + + .xblock-no-child-message { + text-align: center; + } +} diff --git a/src/course-unit/move-modal/index.tsx b/src/course-unit/move-modal/index.tsx new file mode 100644 index 0000000000..7844d7c310 --- /dev/null +++ b/src/course-unit/move-modal/index.tsx @@ -0,0 +1,164 @@ +import React, { FC, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Breadcrumb, + Button, + ModalDialog, +} from '@openedx/paragon'; +import { + ArrowForwardIos as ArrowForwardIosIcon, +} from '@openedx/paragon/icons'; + +import { CATEGORIES } from './constants'; +import { IUseMoveModalParams, IXBlock, IXBlockInfo } from './interfaces'; +import { useMoveModal } from './hooks'; +import { EmptyMessage, ModalLoader, CategoryIndicator } from './components'; +import messages from './messages'; + +const MoveModal: FC<IUseMoveModalParams> = ({ + isOpenModal, closeModal, openModal, courseId, +}) => { + const intl = useIntl(); + + const { + isLoading, + isValidMove, + isExtraSmall, + parentInfo, + childrenInfo, + displayName, + categoryText, + breadcrumbs, + sourceXBlockId, + currentXBlockParentIds, + handleXBlockClick, + handleBreadcrumbsClick, + handleCLoseModal, + handleMoveXBlock, + } = useMoveModal({ + isOpenModal, closeModal, openModal, courseId, + }); + + const renderBreadcrumbs = useCallback(() => ( + <Breadcrumb + ariaLabel={intl.formatMessage(messages.moveModalBreadcrumbsLabel)} + data-testid="move-xblock-modal-breadcrumbs" + isMobile={isExtraSmall} + links={breadcrumbs.slice(0, -1).map((breadcrumb, index) => ( + { label: breadcrumb, 'data-parent-index': index } + ))} + activeLabel={breadcrumbs[breadcrumbs.length - 1]} + clickHandler={({ target }) => handleBreadcrumbsClick(target.dataset.parentIndex)} + /> + ), [isExtraSmall, breadcrumbs, handleBreadcrumbsClick]); + + const getCourseStructureItemButton = useCallback((xBlock: IXBlock, index: number) => ( + <Button + variant="link" + className="button-forward text-left justify-content-start text-gray-700" + onClick={() => handleXBlockClick(index)} + > + <span className="xblock-display-name text-truncate"> + {xBlock?.displayName} + </span> + {currentXBlockParentIds.includes(xBlock.id) && ( + <span className="current-location text-nowrap mr-3"> + {intl.formatMessage(messages.moveModalOutlineItemCurrentLocationText)} + </span> + )} + <ArrowForwardIosIcon className="ml-auto flex-shrink-0" /> + <span className="sr-only"> + {intl.formatMessage(messages.moveModalOutlineItemViewText)} + </span> + </Button> + ), [currentXBlockParentIds, handleXBlockClick]); + + const renderCourseStructureItemSpan = useCallback((xBlock: IXBlock) => ( + <span className="component text-left justify-content-start text-gray-700"> + <span className="xblock-display-name text-truncate"> + {xBlock?.displayName} + </span> + {currentXBlockParentIds.includes(xBlock.id) && ( + <span className="current-location text-nowrap mr-3"> + {intl.formatMessage(messages.moveModalOutlineItemCurrentComponentLocationText)} + </span> + )} + </span> + ), [currentXBlockParentIds]); + + const renderCourseStructureListItem = useCallback((xBlock: IXBlock, index: number) => ( + <li key={xBlock.id} className="xblock-item"> + {sourceXBlockId !== xBlock.id && (xBlock?.childInfo || childrenInfo.category !== CATEGORIES.KEYS.component) + ? getCourseStructureItemButton(xBlock, index) + : renderCourseStructureItemSpan(xBlock)} + </li> + ), [sourceXBlockId, childrenInfo.category, getCourseStructureItemButton, renderCourseStructureItemSpan]); + + return ( + <ModalDialog + isOpen={isOpenModal} + onClose={handleCLoseModal} + size="xl" + className="move-xblock-modal" + hasCloseButton + isFullscreenOnMobile + > + <ModalDialog.Header> + <ModalDialog.Title> + {intl.formatMessage(messages.moveModalTitle, { displayName })} + </ModalDialog.Title> + </ModalDialog.Header> + <ModalDialog.Body> + {isLoading ? <ModalLoader /> : ( + <> + {renderBreadcrumbs()} + <div className="xblock-list-container"> + <CategoryIndicator + categoryText={categoryText} + displayName={displayName} + /> + <ul className="xblock-items-container p-0 m-0"> + {!childrenInfo.children?.length + ? ( + <EmptyMessage + category={parentInfo.category} + categoryText={categoryText.toLowerCase()} + /> + ) + : childrenInfo.children.map( + (xBlock: IXBlock | IXBlockInfo, index: number) => ( + renderCourseStructureListItem(xBlock as IXBlock, index) + ), + )} + </ul> + </div> + </> + )} + </ModalDialog.Body> + <ModalDialog.Footer> + <ActionRow> + <ModalDialog.CloseButton variant="tertiary"> + {intl.formatMessage(messages.moveModalCancelButton)} + </ModalDialog.CloseButton> + <Button + disabled={!isValidMove} + onClick={handleMoveXBlock} + > + {intl.formatMessage(messages.moveModalSubmitButton)} + </Button> + </ActionRow> + </ModalDialog.Footer> + </ModalDialog> + ); +}; + +MoveModal.propTypes = { + isOpenModal: PropTypes.bool.isRequired, + closeModal: PropTypes.func.isRequired, + openModal: PropTypes.func.isRequired, + courseId: PropTypes.string.isRequired, +}; + +export default MoveModal; diff --git a/src/course-unit/move-modal/interfaces.ts b/src/course-unit/move-modal/interfaces.ts new file mode 100644 index 0000000000..3806acd707 --- /dev/null +++ b/src/course-unit/move-modal/interfaces.ts @@ -0,0 +1,82 @@ +export interface IXBlockInfo { + id: string; + displayName: string; + childInfo?: { + children?: IXBlockInfo[]; + }; + category?: string; + hasChildren?: boolean; +} + +export interface IUseMoveModalParams { + isOpenModal: boolean; + closeModal: () => void; + openModal: () => void; + courseId: string; +} + +export interface IUseMoveModalReturn { + isLoading: boolean; + isValidMove: boolean; + isExtraSmall: boolean; + parentInfo: { + parent: IXBlockInfo; + category: string; + }; + childrenInfo: { + children: IXBlockInfo[]; + category: string; + }; + displayName: string; + sourceXBlockId: string; + categoryText: string; + breadcrumbs: string[]; + currentXBlockParentIds: string[]; + handleXBlockClick: (newParentIndex: string | number) => void; + handleBreadcrumbsClick: (newParentIndex: string | number) => void; + handleCLoseModal: () => void; + handleMoveXBlock: () => void; +} + +export interface IState { + sourceXBlockInfo: { + current: IXBlockInfo; + parent: IXBlockInfo; + }; + childrenInfo: { + children: IXBlockInfo[]; + category: string; + }; + parentInfo: { + parent: IXBlockInfo; + category: string; + }; + visitedAncestors: IXBlockInfo[]; + isValidMove: boolean; +} + +export interface ITreeNode { + id: string; + childInfo?: { + children?: ITreeNode[]; + }; +} + +export interface IAncestor { + category?: string; + displayName?: string; +} + +export interface IXBlockChildInfo { + category?: string; + displayName?: string; + children?: IXBlock[]; +} + +export interface IXBlock { + id: string; + displayName: string; + category: string; + hasChildren: boolean; + childInfo?: IXBlockChildInfo; +} diff --git a/src/course-unit/move-modal/messages.ts b/src/course-unit/move-modal/messages.ts new file mode 100644 index 0000000000..b1d71f2a66 --- /dev/null +++ b/src/course-unit/move-modal/messages.ts @@ -0,0 +1,81 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + moveModalTitle: { + id: 'course-authoring.course-unit.xblock.move.modal.title', + defaultMessage: 'Move: {displayName}', + description: 'Text for the move modal heading', + }, + moveModalCancelButton: { + id: 'course-authoring.course-unit.xblock.move.modal.cancel.btn.text', + defaultMessage: 'Cancel', + description: 'Text for the button closing move modal of an XBlock', + }, + moveModalSubmitButton: { + id: 'course-authoring.course-unit.xblock.move.modal.submit.btn.text', + defaultMessage: 'Move', + description: 'Text for the button submitting move modal of an XBlock', + }, + moveModalBreadcrumbsBaseCategory: { + id: 'course-authoring.course-unit.xblock.move.modal.breadcrumbs.core.category.text', + defaultMessage: 'Course Outline', + description: 'Text for the core breadcrumbs item in move modal of an XBlock', + }, + moveModalBreadcrumbsSections: { + id: 'course-authoring.course-unit.xblock.move.modal.breadcrumbs.sections.text', + defaultMessage: 'Sections', + description: 'Text for the sections breadcrumbs item in move modal of an XBlock', + }, + moveModalBreadcrumbsSubsections: { + id: 'course-authoring.course-unit.xblock.move.modal.breadcrumbs.subsections.text', + defaultMessage: 'Subsections', + description: 'Text for the subsections breadcrumbs item in move modal of an XBlock', + }, + moveModalBreadcrumbsUnits: { + id: 'course-authoring.course-unit.xblock.move.modal.breadcrumbs.units.text', + defaultMessage: 'Units', + description: 'Text for the units breadcrumbs item in move modal of an XBlock', + }, + moveModalBreadcrumbsComponents: { + id: 'course-authoring.course-unit.xblock.move.modal.breadcrumbs.components.text', + defaultMessage: 'Components', + description: 'Text for the components breadcrumbs item in move modal of an XBlock', + }, + moveModalBreadcrumbsGroups: { + id: 'course-authoring.course-unit.xblock.move.modal.breadcrumbs.groups.text', + defaultMessage: 'Groups', + description: 'Text for the groups breadcrumbs item in move modal of an XBlock', + }, + moveModalBreadcrumbsLabel: { + id: 'course-authoring.course-unit.xblock.move.modal.breadcrumbs.label.text', + defaultMessage: 'Course Outline breadcrumb', + description: 'Text for the breadcrumbs label in move modal of an XBlock', + }, + moveModalEmptyCategoryText: { + id: 'course-authoring.course-unit.xblock.move.modal.category.empty.text', + defaultMessage: 'This {category} has no {categoryText}', + description: 'Text for the category with empty children in move modal of an XBlock', + }, + moveModalCategoryIndicatorAccessibilityText: { + id: 'course-authoring.course-unit.xblock.move.modal.category.accessibility.text', + defaultMessage: '{categoryText} in {displayName}', + description: 'Text for the category indicator accessibility in move modal of an XBlock', + }, + moveModalOutlineItemCurrentLocationText: { + id: 'course-authoring.course-unit.xblock.move.modal.outline.item.location.text', + defaultMessage: '(Current location)', + description: 'Text for the outline item that indicates the current location in move modal of an XBlock', + }, + moveModalOutlineItemCurrentComponentLocationText: { + id: 'course-authoring.course-unit.xblock.move.modal.outline.item.component.location.text', + defaultMessage: '(Currently selected)', + description: 'Text for the outline item that indicates the current component location in move modal of an XBlock', + }, + moveModalOutlineItemViewText: { + id: 'course-authoring.course-unit.xblock.move.modal.outline.item.view.text', + defaultMessage: 'View child items', + description: 'Text for the outline item action description in move modal of an XBlock', + }, +}); + +export default messages; diff --git a/src/course-unit/move-modal/moveModal.test.tsx b/src/course-unit/move-modal/moveModal.test.tsx new file mode 100644 index 0000000000..ba94e018a9 --- /dev/null +++ b/src/course-unit/move-modal/moveModal.test.tsx @@ -0,0 +1,182 @@ +import MockAdapter from 'axios-mock-adapter'; +import { render, waitFor, within } from '@testing-library/react'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import userEvent from '@testing-library/user-event'; + +import initializeStore from '../../store'; +import { getCourseOutlineInfoUrl } from '../data/api'; +import { courseOutlineInfoMock } from '../__mocks__'; +import { executeThunk } from '../../utils'; +import { getCourseOutlineInfoQuery } from '../data/thunk'; +import { IframeProvider } from '../context/iFrameContext'; +import { IXBlock } from './interfaces'; +import MoveModal from './index'; +import messages from './messages'; + +let store; +let axiosMock: MockAdapter; +const courseId = '1234567890'; +const closeModalMockFn = jest.fn() as jest.MockedFunction<() => void>; +const openModalMockFn = jest.fn() as jest.MockedFunction<() => void>; +const scrollToMockFn = jest.fn() as jest.MockedFunction<() => void>; +const sections: IXBlock[] | any = camelCaseObject(courseOutlineInfoMock)?.childInfo.children || []; +const subsections: IXBlock[] = sections[1]?.childInfo?.children || []; +const units: IXBlock[] = subsections[1]?.childInfo?.children || []; +const components: IXBlock[] = units[0]?.childInfo?.children || []; + +const renderComponent = (props?: any) => render( + <AppProvider store={store}> + <IntlProvider locale="en"> + <IframeProvider> + <MoveModal + isOpenModal + closeModal={closeModalMockFn} + openModal={openModalMockFn} + courseId={courseId} + {...props} + /> + </IframeProvider> + </IntlProvider> + </AppProvider>, +); + +describe('<MoveModal />', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + window.scrollTo = scrollToMockFn; + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseOutlineInfoUrl(courseId)) + .reply(200, courseOutlineInfoMock); + await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch); + }); + + it('renders loading indicator correctly', async () => { + axiosMock + .onGet(getCourseOutlineInfoUrl(courseId)) + .reply(200, null); + await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch); + + const { getByText } = renderComponent(); + expect(getByText('Loading...')).toBeInTheDocument(); + }); + + it('renders component properly', () => { + const { getByText, getByRole, getByTestId } = renderComponent(); + const breadcrumbs: HTMLElement = getByTestId('move-xblock-modal-breadcrumbs'); + const categoryIndicator: HTMLElement = getByTestId('move-xblock-modal-category'); + + expect(getByText(messages.moveModalTitle.defaultMessage.replace(' {displayName}', ''))).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage)).toBeInTheDocument(); + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSections.defaultMessage), + ).toBeInTheDocument(); + expect(getByRole('button', { name: messages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.moveModalCancelButton.defaultMessage })).toBeInTheDocument(); + + userEvent.click(getByRole('button', { name: messages.moveModalCancelButton.defaultMessage })); + expect(closeModalMockFn).toHaveBeenCalledTimes(1); + }); + + it('correctly navigates through the structure list', async () => { + const { getByText, getByRole, getByTestId } = renderComponent(); + const breadcrumbs: HTMLElement = getByTestId('move-xblock-modal-breadcrumbs'); + const categoryIndicator: HTMLElement = getByTestId('move-xblock-modal-category'); + + expect(within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage)).toBeInTheDocument(); + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSections.defaultMessage), + ).toBeInTheDocument(); + sections.forEach((section) => { + expect(getByText(section.displayName)).toBeInTheDocument(); + }); + userEvent.click(getByRole('button', { name: new RegExp(sections[1].displayName, 'i') })); + await waitFor(() => { + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSubsections.defaultMessage), + ).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(sections[1].displayName)).toBeInTheDocument(); + subsections.forEach((subsection) => { + expect(getByRole('button', { name: new RegExp(subsection.displayName, 'i') })).toBeInTheDocument(); + }); + }); + userEvent.click(getByRole('button', { name: new RegExp(subsections[1].displayName, 'i') })); + await waitFor(() => { + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsUnits.defaultMessage), + ).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(subsections[1].displayName)).toBeInTheDocument(); + units.forEach((unit) => { + expect(getByRole('button', { name: new RegExp(unit.displayName, 'i') })).toBeInTheDocument(); + }); + }); + userEvent.click(getByRole('button', { name: new RegExp(units[0].displayName, 'i') })); + await waitFor(() => { + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsComponents.defaultMessage), + ).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(units[0].displayName)).toBeInTheDocument(); + components.forEach((component) => { + if (component.displayName) { + expect(getByText(component.displayName)).toBeInTheDocument(); + } + }); + }); + }); + + it('correctly navigates using breadcrumbs', async () => { + const { getByRole, getByTestId } = renderComponent(); + const breadcrumbs: HTMLElement = getByTestId('move-xblock-modal-breadcrumbs'); + const categoryIndicator: HTMLElement = getByTestId('move-xblock-modal-category'); + + await waitFor(() => { + userEvent.click(getByRole('button', { name: new RegExp(sections[1].displayName, 'i') })); + userEvent.click(getByRole('button', { name: new RegExp(subsections[1].displayName, 'i') })); + userEvent.click(within(breadcrumbs).getByText(sections[1].displayName)); + }); + + await waitFor(() => { + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSubsections.defaultMessage), + ).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(sections[1].displayName)).toBeInTheDocument(); + subsections.forEach((subsection) => ( + expect(getByRole('button', { name: new RegExp(subsection.displayName, 'i') })).toBeInTheDocument() + )); + }); + }); + + it('renders empty message when no components are provided', async () => { + const { getByText, getByRole } = renderComponent(); + + await waitFor(() => { + userEvent.click(getByRole('button', { name: new RegExp(sections[1].displayName, 'i') })); + userEvent.click(getByRole('button', { name: new RegExp(subsections[1].displayName, 'i') })); + }); + + await waitFor(() => { + const unitBtn = getByRole('button', { name: new RegExp(units[7].displayName, 'i') }); + userEvent.click(unitBtn); + }); + + await waitFor(() => { + expect(getByText( + messages.moveModalEmptyCategoryText.defaultMessage + .replace('{category}', 'unit') + .replace('{categoryText}', 'components'), + )).toBeInTheDocument(); + }); + }); +}); diff --git a/src/course-unit/move-modal/utils.test.ts b/src/course-unit/move-modal/utils.test.ts new file mode 100644 index 0000000000..8721787de2 --- /dev/null +++ b/src/course-unit/move-modal/utils.test.ts @@ -0,0 +1,175 @@ +import { CATEGORIES } from './constants'; +import { ITreeNode, IXBlockInfo, IAncestor } from './interfaces'; +import { + getXBlockType, findParentIds, isValidCategory, getBreadcrumbs, +} from './utils'; +import messages from './messages'; + +const mockFormatMessage = jest.fn((message) => message.defaultMessage); + +const tree: ITreeNode = { + id: 'root', + childInfo: { + children: [ + { + id: 'child-1', + childInfo: { + children: [ + { + id: 'grandchild-1', + childInfo: { + children: [], + }, + }, + { + id: 'grandchild-2', + childInfo: { + children: [], + }, + }, + ], + }, + }, + { + id: 'child-2', + childInfo: { + children: [], + }, + }, + ], + }, +}; + +describe('getXBlockType utility', () => { + it('returns section for chapter category', () => { + const result = getXBlockType(CATEGORIES.KEYS.chapter); + expect(result).toBe(CATEGORIES.KEYS.section); + }); + + it('returns subsection for sequential category', () => { + const result = getXBlockType(CATEGORIES.KEYS.sequential); + expect(result).toBe(CATEGORIES.KEYS.subsection); + }); + + it('returns unit for vertical category', () => { + const result = getXBlockType(CATEGORIES.KEYS.vertical); + expect(result).toBe(CATEGORIES.KEYS.unit); + }); + + it('returns the same category if no match is found', () => { + const customCategory = 'custom-category'; + const result = getXBlockType(customCategory); + expect(result).toBe(customCategory); + }); +}); + +describe('findParentIds utility', () => { + it('returns path to target ID in the tree', () => { + const result = findParentIds(tree, 'grandchild-2'); + expect(result).toEqual(['root', 'child-1', 'grandchild-2']); + }); + + it('returns empty array if target ID is not found', () => { + const result = findParentIds(tree, 'non-existent-id'); + expect(result).toEqual([]); + }); + + it('returns path with only root when target ID is the root', () => { + const result = findParentIds(tree, 'root'); + expect(result).toEqual(['root']); + }); + + it('returns empty array if tree is undefined', () => { + const result = findParentIds(undefined, 'some-id'); + expect(result).toEqual([]); + }); +}); + +describe('isValidCategory utility', () => { + const sourceParentInfo: IXBlockInfo = { + displayName: 'test-source-parent-name', + id: '12345', + category: 'chapter', + hasChildren: true, + }; + const targetParentInfo: IXBlockInfo = { + displayName: 'test-target-parent-name', + id: '67890', + category: 'chapter', + hasChildren: true, + }; + + it('returns true when target and source categories are the same', () => { + const result = isValidCategory(sourceParentInfo, targetParentInfo); + expect(result).toBe(true); + }); + + it('returns false when categories are different', () => { + const result = isValidCategory(sourceParentInfo, { ...targetParentInfo, category: 'unit' }); + expect(result).toBe(false); + }); + + it('converts source category to vertical if it has children and is not basic block type', () => { + const result = isValidCategory( + { ...sourceParentInfo, category: 'section' }, + { ...targetParentInfo, category: 'vertical' }, + ); + expect(result).toBe(true); + }); + + it('converts target category to vertical if it has children and is not basic block type or split_test', () => { + const result = isValidCategory( + { ...sourceParentInfo, category: 'vertical' }, + { ...targetParentInfo, category: 'section' }, + ); + expect(result).toBe(true); + }); + + it('returns false when categories are different after conversion', () => { + const result = isValidCategory( + { ...sourceParentInfo, category: 'chapter' }, + { ...targetParentInfo, category: 'section' }, + ); + expect(result).toBe(false); + }); +}); + +describe('getBreadcrumbs utility', () => { + it('returns correct breadcrumb labels for visited ancestors', () => { + const visitedAncestors: IAncestor[] = [ + { category: 'chapter', displayName: 'Chapter 1' }, + { category: 'section', displayName: 'Section 1' }, + ]; + + const result = getBreadcrumbs(visitedAncestors, mockFormatMessage); + + expect(result).toEqual(['Chapter 1', 'Section 1']); + }); + + it('returns base category label when category is course', () => { + const visitedAncestors: IAncestor[] = [ + { category: CATEGORIES.KEYS.course, displayName: 'Course Name' }, + ]; + + const result = getBreadcrumbs(visitedAncestors, mockFormatMessage); + + expect(result).toEqual(['Course Outline']); + expect(mockFormatMessage).toHaveBeenCalledWith(messages.moveModalBreadcrumbsBaseCategory); + }); + + it('returns empty string if displayName is missing', () => { + const visitedAncestors: IAncestor[] = [ + { category: 'chapter', displayName: '' }, + ]; + + const result = getBreadcrumbs(visitedAncestors, mockFormatMessage); + + expect(result).toEqual(['']); + }); + + it('returns empty array if visitedAncestors is not an array', () => { + const result = getBreadcrumbs(undefined as any, mockFormatMessage); + + expect(result).toEqual([]); + }); +}); diff --git a/src/course-unit/move-modal/utils.ts b/src/course-unit/move-modal/utils.ts new file mode 100644 index 0000000000..e875d672c6 --- /dev/null +++ b/src/course-unit/move-modal/utils.ts @@ -0,0 +1,116 @@ +import { BASIC_BLOCK_TYPES, CATEGORIES } from './constants'; +import { ITreeNode, IXBlockInfo, IAncestor } from './interfaces'; +import messages from './messages'; + +/** + * Determines the XBlock type based on the provided category and parent information. + * + * @param {string} category - The category of the XBlock (e.g., 'chapter', 'sequential', 'vertical'). + * @returns {string} - The determined XBlock type (e.g., 'section', 'subsection', 'unit'). + */ +export const getXBlockType = (category: string): string => { + const categoryMap: { [key: string]: string } = { + [CATEGORIES.KEYS.chapter]: CATEGORIES.KEYS.section, + [CATEGORIES.KEYS.sequential]: CATEGORIES.KEYS.subsection, + [CATEGORIES.KEYS.vertical]: CATEGORIES.KEYS.unit, + }; + return categoryMap[category] || category; +}; + +/** + * Recursively finds the parent IDs of the target ID in a hierarchical object structure. + * It returns an array of IDs leading to the target, including the target's own ID. + * + * @param {Object} tree - The hierarchical object to search through. + * @param {string} targetId - The ID of the target element for which to find the parent IDs. + * @returns {string[]} - An array of IDs representing the path from the root to the target element. + */ +export const findParentIds = ( + tree: ITreeNode | undefined, + targetId: string, +): string[] => { + let path: string[] = []; + + function traverse(node: ITreeNode | undefined, id: string, currentPath: string[]): boolean { + if (!node) { + return false; + } + + currentPath.push(node.id); + + if (node.id === id) { + path = currentPath.slice(); + return true; + } + + for (const child of node.childInfo?.children ?? []) { + if (traverse(child, id, currentPath)) { + return true; + } + } + + currentPath.pop(); + return false; + } + + traverse(tree, targetId, []); + return path; +}; + +/** + * Checks if the target category is valid for moving. + * @param {Object} sourceParentInfo - Current parent information. + * @param {Object} targetParentInfo - Target parent information. + * @returns {boolean} - Returns true if moving is valid. + */ +export const isValidCategory = ( + sourceParentInfo: IXBlockInfo, + targetParentInfo: IXBlockInfo, +): boolean => { + let { category: sourceParentCategory } = sourceParentInfo; + let { category: targetParentCategory } = targetParentInfo; + const { hasChildren: sourceParentHasChildren } = sourceParentInfo; + const { hasChildren: targetParentHasChildren } = targetParentInfo; + + if ( + sourceParentHasChildren + && sourceParentCategory + && !(BASIC_BLOCK_TYPES as readonly string[]).includes(sourceParentCategory) + ) { + sourceParentCategory = CATEGORIES.KEYS.vertical; + } + + if ( + targetParentHasChildren + && targetParentCategory + && !(BASIC_BLOCK_TYPES as readonly string[]).includes(targetParentCategory) + && targetParentCategory !== CATEGORIES.KEYS.split_test + ) { + targetParentCategory = CATEGORIES.KEYS.vertical; + } + + return targetParentCategory === sourceParentCategory; +}; + +/** + * Builds breadcrumbs based on visited ancestors. + * @param {Array} visitedAncestors - Array of ancestors. + * @param {Function} formatMessage - Intl formatting function. + * @returns {Array} - Array of breadcrumb elements. + */ +export const getBreadcrumbs = ( + visitedAncestors: IAncestor[], + formatMessage: any, +): string[] => { + if (!Array.isArray(visitedAncestors)) { + return []; + } + + return visitedAncestors.map((ancestor) => { + if (ancestor?.category === CATEGORIES.KEYS.course) { + return formatMessage(messages.moveModalBreadcrumbsBaseCategory); + } + + return ancestor?.displayName || ''; + }); +}; diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index df3e6528bf..761d637750 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,9 +1,10 @@ -import { useRef, FC } from 'react'; +import { useRef, useEffect, FC } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; import { IFRAME_FEATURE_POLICY } from '../constants'; +import { useIframe } from '../context/hooks'; import { useIFrameBehavior } from './hooks'; import messages from './messages'; @@ -20,6 +21,7 @@ interface XBlockContainerIframeProps { const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({ blockId }) => { const intl = useIntl(); const iframeRef = useRef<HTMLIFrameElement>(null); + const { setIframeRef } = useIframe(); const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`; @@ -28,6 +30,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({ blockId }) => { iframeUrl, }); + useEffect(() => { + setIframeRef(iframeRef); + }, [setIframeRef]); + return ( <iframe ref={iframeRef} diff --git a/src/course-unit/xblock-container-iframe/tests/XblockContainerIframe.test.tsx b/src/course-unit/xblock-container-iframe/tests/XblockContainerIframe.test.tsx index 3b940fda43..b3bee233b8 100644 --- a/src/course-unit/xblock-container-iframe/tests/XblockContainerIframe.test.tsx +++ b/src/course-unit/xblock-container-iframe/tests/XblockContainerIframe.test.tsx @@ -5,6 +5,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IFRAME_FEATURE_POLICY } from '../../constants'; import { useIFrameBehavior } from '../hooks'; import XBlockContainerIframe from '..'; +import { IframeProvider } from '../../context/iFrameContext'; jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn(), @@ -27,7 +28,9 @@ describe('<XBlockContainerIframe />', () => { it('renders correctly with the given blockId', () => { const { getByTitle } = render( <IntlProvider locale="en"> - <XBlockContainerIframe blockId={blockId} /> + <IframeProvider> + <XBlockContainerIframe blockId={blockId} /> + </IframeProvider> </IntlProvider>, ); const iframe = getByTitle('Course unit iframe');