diff --git a/package-lock.json b/package-lock.json index cb7b9e098f..180f8fab64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20974,6 +20974,7 @@ "@openedx/paragon": "*", "prop-types": "*", "react": "*", + "react-redux": "*", "yup": "*" }, "peerDependenciesMeta": { 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 = () => { } + element={} /> ))} { const { blockId } = useParams(); @@ -40,13 +45,13 @@ const CourseUnit = ({ courseId }) => { isLoading, sequenceId, unitTitle, + unitCategory, errorMessage, sequenceStatus, savingStatus, isTitleEditFormOpen, staticFileNotices, currentlyVisibleToStudents, - unitXBlockActions, sharedClipboardData, showPasteXBlock, showPasteUnit, @@ -55,22 +60,33 @@ const CourseUnit = ({ courseId }) => { handleTitleEdit, handleCreateNewCourseXBlock, handleConfigureSubmit, - courseVerticalChildren, - handleXBlockDragAndDrop, canPasteComponent, + isMoveModalOpen, + openMoveModal, + closeMoveModal, + movedXBlockParams, + handleRollbackMovedXBlock, + handleCloseXBlockMovedAlert, + handleNavigateToTargetUnit, } = useCourseUnit({ courseId, blockId }); - const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]); - const [unitXBlocks, setUnitXBlocks] = useState(initialXBlocksData); + const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id; + const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id; + + const unitLayout = [{ span: 12 }, { span: 0 }]; + const defaultLayout = { + lg: [{ span: 8 }, { span: 4 }], + md: [{ span: 8 }, { span: 4 }], + sm: [{ span: 8 }, { span: 3 }], + xs: [{ span: 9 }, { span: 3 }], + xl: [{ span: 9 }, { span: 3 }], + }; + const layoutGrid = isUnitLibraryType ? { lg: unitLayout } : defaultLayout; useEffect(() => { document.title = getPageHeadTitle('', unitTitle); }, [unitTitle]); - useEffect(() => { - setUnitXBlocks(courseVerticalChildren.children); - }, [courseVerticalChildren.children]); - const { isShow: isShowProcessingNotification, title: processingNotificationTitle, @@ -88,16 +104,44 @@ const CourseUnit = ({ courseId }) => { ); } - const finalizeXBlockOrder = () => (newXBlocks) => { - handleXBlockDragAndDrop(newXBlocks.map(xBlock => xBlock.id), () => { - setUnitXBlocks(initialXBlocksData); - }); - }; - return ( <>
+ + {movedXBlockParams.isSuccess ? ( + + {intl.formatMessage(messages.undoMoveButton)} + , + , + ]} + onClose={handleCloseXBlockMovedAlert} + /> + ) : null} + { /> )} breadcrumbs={( - + )} headerActions={( )} /> - - + {isUnitVerticalType && ( + + )} + - {currentlyVisibleToStudents && ( + {!currentlyVisibleToStudents && ( { courseId={courseId} /> )} - - - - {unitXBlocks.map(({ - name, id, blockType: type, shouldScroll, userPartitionInfo, validationMessages, - }) => ( - - ))} - - - - - {showPasteXBlock && canPasteComponent && ( + + {isUnitVerticalType && ( + + )} + {showPasteXBlock && canPasteComponent && isUnitVerticalType && ( )} + - - - - {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' - && ( - - - + {isUnitVerticalType && ( + <> + + + + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + + + + )} + + + + )} - - - diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 5a3c203c7a..6a7c0c72bd 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -1,9 +1,13 @@ @import "./breadcrumbs/Breadcrumbs"; @import "./course-sequence/CourseSequence"; @import "./add-component/AddComponent"; -@import "./course-xblock/CourseXBlock"; @import "./sidebar/Sidebar"; @import "./header-title/HeaderTitle"; +@import "./move-modal"; + +.course-unit { + min-width: 900px; +} .course-unit__alert { margin-bottom: 1.75rem; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 91e8a2f51a..bde7c99315 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -17,17 +17,17 @@ import { cloneDeep, set } from 'lodash'; import { getCourseSectionVerticalApiUrl, getCourseUnitApiUrl, - getCourseVerticalChildrenApiUrl, + getCourseVerticalChildrenApiUrl, getOutlineInfo, getXBlockBaseApiUrl, postXBlockBaseApiUrl, } from './data/api'; import { createNewCourseXBlock, - deleteUnitItemQuery, editCourseUnitVisibilityAndData, fetchCourseSectionVerticalData, fetchCourseUnitQuery, - fetchCourseVerticalChildrenData, + fetchCourseVerticalChildrenData, getCourseOutlineInfoQuery, + patchUnitItemQuery, } from './data/thunk'; import initializeStore from '../store'; import { @@ -36,14 +36,10 @@ import { courseUnitIndexMock, courseUnitMock, courseVerticalChildrenMock, - clipboardMockResponse, + clipboardMockResponse, courseOutlineInfoMock, } from './__mocks__'; -import { - clipboardUnit, - clipboardXBlock, -} from '../__mocks__'; +import { clipboardUnit } from '../__mocks__'; import { executeThunk } from '../utils'; -import deleteModalMessages from '../generic/delete-modal/messages'; import pasteComponentMessages from '../generic/clipboard/paste-component/messages'; import pasteNotificationsMessages from './clipboard/paste-notification/messages'; import headerNavigationsMessages from './header-navigations/messages'; @@ -54,12 +50,12 @@ import { extractCourseUnitId } from './sidebar/utils'; import CourseUnit from './CourseUnit'; import configureModalMessages from '../generic/configure-modal/messages'; -import courseXBlockMessages from './course-xblock/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'; -import { RequestStatus } from '../data/constants'; let axiosMock; let store; @@ -115,7 +111,9 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); const RootWrapper = () => ( - + + + ); @@ -130,6 +128,7 @@ describe('', () => { roles: [], }, }); + window.scrollTo = jest.fn(); global.localStorage.clear(); store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); @@ -552,75 +551,46 @@ describe('', () => { }); }); - it('checks whether xblock is deleted when corresponding delete button is clicked', async () => { - axiosMock - .onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id)) - .replyOnce(200, { dummy: 'value' }); - - const { - getByText, - getAllByLabelText, - getByRole, - getAllByTestId, - } = render(); - - await waitFor(() => { - expect(getByText(unitDisplayName)).toBeInTheDocument(); - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - - const deleteBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonDelete.defaultMessage }); - userEvent.click(deleteBtn); - expect(getByText(/Delete this component?/)).toBeInTheDocument(); - - const deleteConfirmBtn = getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage }); - userEvent.click(deleteConfirmBtn); - - expect(getAllByTestId('course-xblock')).toHaveLength(1); - }); - }); - - it('checks whether xblock is duplicate when corresponding delete button is clicked', async () => { - axiosMock - .onPost(postXBlockBaseApiUrl({ - parent_locator: blockId, - duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, - })) - .replyOnce(200, { locator: '1234567890' }); - - axiosMock - .onGet(getCourseVerticalChildrenApiUrl(blockId)) - .reply(200, { - ...courseVerticalChildrenMock, - children: [ - ...courseVerticalChildrenMock.children, - { - name: 'New Cloned XBlock', - block_id: '1234567890', - block_type: 'drag-and-drop-v2', - user_partition_info: {}, - }, - ], - }); - - const { - getByText, - getAllByLabelText, - getAllByTestId, - } = render(); - - await waitFor(() => { - expect(getByText(unitDisplayName)).toBeInTheDocument(); - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - - const duplicateBtn = getByText(courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage); - userEvent.click(duplicateBtn); - - expect(getAllByTestId('course-xblock')).toHaveLength(3); - expect(getByText('New Cloned XBlock')).toBeInTheDocument(); - }); - }); + // axiosMock + // .onPost(postXBlockBaseApiUrl({ + // parent_locator: blockId, + // duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, + // })) + // .replyOnce(200, { locator: '1234567890' }); + + // axiosMock + // .onGet(getCourseVerticalChildrenApiUrl(blockId)) + // .reply(200, { + // ...courseVerticalChildrenMock, + // children: [ + // ...courseVerticalChildrenMock.children, + // { + // name: 'New Cloned XBlock', + // block_id: '1234567890', + // block_type: 'drag-and-drop-v2', + // user_partition_info: {}, + // }, + // ], + // }); + + // const { + // getByText, + // getAllByLabelText, + // getAllByTestId, + // } = render(); + + // await waitFor(() => { + // expect(getByText(unitDisplayName)).toBeInTheDocument(); + // const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); + // userEvent.click(xblockActionBtn); + + // const duplicateBtn = getByText(courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage); + // userEvent.click(duplicateBtn); + + // expect(getAllByTestId('course-xblock')).toHaveLength(3); + // expect(getByText('New Cloned XBlock')).toBeInTheDocument(); + // }); + // }); it('should toggle visibility from sidebar and update course unit state accordingly', async () => { const { getByRole, getByTestId } = render(); @@ -788,189 +758,6 @@ describe('', () => { expect(discardChangesBtn).not.toBeInTheDocument(); }); - it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { - const { - getByText, - getAllByLabelText, - getByRole, - getAllByTestId, - queryByRole, - } = render(); - - await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); - }); - - axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { - publish: PUBLISH_TYPES.makePublic, - }) - .reply(200, { dummy: 'value' }); - axiosMock - .onGet(getCourseUnitApiUrl(blockId)) - .reply(200, { - ...courseUnitIndexMock, - visibility_state: UNIT_VISIBILITY_STATES.live, - has_changes: false, - published_by: userName, - }); - - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - - axiosMock - .onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id)) - .replyOnce(200, { dummy: 'value' }); - - await executeThunk(deleteUnitItemQuery(courseId, blockId), store.dispatch); - - await waitFor(() => { - // check if the sidebar status is Published and Live - expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(getByText( - sidebarMessages.publishLastPublished.defaultMessage - .replace('{publishedOn}', courseUnitIndexMock.published_on) - .replace('{publishedBy}', userName), - )).toBeInTheDocument(); - expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - - expect(getByText(unitDisplayName)).toBeInTheDocument(); - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - - const deleteBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonDelete.defaultMessage }); - userEvent.click(deleteBtn); - expect(getByText(/Delete this component?/)).toBeInTheDocument(); - - const deleteConfirmBtn = getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage }); - userEvent.click(deleteConfirmBtn); - - expect(getAllByTestId('course-xblock')).toHaveLength(1); - }); - - axiosMock - .onGet(getCourseUnitApiUrl(blockId)) - .reply(200, courseUnitIndexMock); - - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - - // after removing the xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( - sidebarMessages.publishInfoDraftSaved.defaultMessage - .replace('{editedOn}', courseUnitIndexMock.edited_on) - .replace('{editedBy}', courseUnitIndexMock.edited_by), - )).toBeInTheDocument(); - expect(getByText( - sidebarMessages.releaseInfoWithSection.defaultMessage - .replace('{sectionName}', courseUnitIndexMock.release_date_from), - )).toBeInTheDocument(); - }); - - it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => { - axiosMock - .onPost(postXBlockBaseApiUrl({ - parent_locator: blockId, - duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, - })) - .replyOnce(200, { locator: '1234567890' }); - - axiosMock - .onGet(getCourseVerticalChildrenApiUrl(blockId)) - .reply(200, { - ...courseVerticalChildrenMock, - children: [ - ...courseVerticalChildrenMock.children, - { - ...courseVerticalChildrenMock.children[0], - name: 'New Cloned XBlock', - }, - ], - }); - - const { - getByText, - getAllByLabelText, - getAllByTestId, - queryByRole, - getByRole, - } = render(); - - await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); - }); - - axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { - publish: PUBLISH_TYPES.makePublic, - }) - .reply(200, { dummy: 'value' }); - axiosMock - .onGet(getCourseUnitApiUrl(blockId)) - .reply(200, { - ...courseUnitIndexMock, - visibility_state: UNIT_VISIBILITY_STATES.live, - has_changes: false, - published_by: userName, - }); - - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - - await waitFor(() => { - // check if the sidebar status is Published and Live - expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(getByText( - sidebarMessages.publishLastPublished.defaultMessage - .replace('{publishedOn}', courseUnitIndexMock.published_on) - .replace('{publishedBy}', userName), - )).toBeInTheDocument(); - expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - - expect(getByText(unitDisplayName)).toBeInTheDocument(); - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - - const duplicateBtn = getByText(courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage); - userEvent.click(duplicateBtn); - - expect(getAllByTestId('course-xblock')).toHaveLength(3); - expect(getByText('New Cloned XBlock')).toBeInTheDocument(); - }); - - axiosMock - .onGet(getCourseUnitApiUrl(blockId)) - .reply(200, courseUnitIndexMock); - - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - - // after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( - sidebarMessages.publishInfoDraftSaved.defaultMessage - .replace('{editedOn}', courseUnitIndexMock.edited_on) - .replace('{editedBy}', courseUnitIndexMock.edited_by), - )).toBeInTheDocument(); - expect(getByText( - sidebarMessages.releaseInfoWithSection.defaultMessage - .replace('{sectionName}', courseUnitIndexMock.release_date_from), - )).toBeInTheDocument(); - }); - it('should toggle visibility from header configure modal and update course unit state accordingly', async () => { const { getByRole, getByTestId } = render(); let courseUnitSidebar; @@ -1016,7 +803,7 @@ describe('', () => { axiosMock .onPost(getXBlockBaseApiUrl(courseUnitIndexMock.id), { publish: null, - metadata: { visible_to_staff_only: true, group_access: { 50: [2] } }, + metadata: { visible_to_staff_only: true, group_access: { 50: [2] }, discussion_enabled: true }, }) .reply(200, { dummy: 'value' }); axiosMock @@ -1078,140 +865,6 @@ describe('', () => { expect(queryByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })).toBeInTheDocument(); }); - it('should display clipboard information in popover when hovering over What\'s in clipboard text', async () => { - const { - queryByTestId, getByRole, getAllByLabelText, getByText, - } = render(); - - await waitFor(() => { - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage })); - }); - - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardXBlock, - }); - - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); - - const whatsInClipboardText = getByText( - pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, - ); - - userEvent.hover(whatsInClipboardText); - - const popoverContent = queryByTestId('popover-content'); - expect(popoverContent.tagName).toBe('A'); - expect(popoverContent).toHaveAttribute('href', clipboardXBlock.sourceEditUrl); - expect(within(popoverContent).getByText(clipboardXBlock.content.displayName)).toBeInTheDocument(); - expect(within(popoverContent).getByText(clipboardXBlock.sourceContextTitle)).toBeInTheDocument(); - expect(within(popoverContent).getByText(clipboardXBlock.content.blockTypeDisplay)).toBeInTheDocument(); - - fireEvent.blur(whatsInClipboardText); - await waitFor(() => expect(queryByTestId('popover-content')).toBeNull()); - - fireEvent.focus(whatsInClipboardText); - await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument()); - - fireEvent.mouseLeave(whatsInClipboardText); - await waitFor(() => expect(queryByTestId('popover-content')).toBeNull()); - - fireEvent.mouseEnter(whatsInClipboardText); - await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument()); - }); - - it('should increase the number of course XBlocks after copying and pasting a block', async () => { - const { - getAllByTestId, getByRole, getAllByLabelText, - } = render(); - - await waitFor(() => { - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage })); - }); - - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardXBlock, - }); - - axiosMock - .onGet(getCourseUnitApiUrl(courseId)) - .reply(200, { - ...courseUnitIndexMock, - enable_copy_paste_units: true, - }); - - await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage })); - - await waitFor(() => { - expect(getAllByTestId('course-xblock')).toHaveLength(2); - }); - - axiosMock - .onGet(getCourseVerticalChildrenApiUrl(blockId)) - .reply(200, { - ...courseVerticalChildrenMock, - children: [ - ...courseVerticalChildrenMock.children, - { - name: 'Copy XBlock', - block_id: '1234567890', - block_type: 'drag-and-drop-v2', - user_partition_info: { - selectable_partitions: [], - selected_partition_index: -1, - selected_groups_label: '', - }, - }, - ], - }); - - await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); - expect(getAllByTestId('course-xblock')).toHaveLength(3); - }); - - it('should display the "Paste component" button after copying a xblock to clipboard', async () => { - const { getByRole, getAllByLabelText } = render(); - - await waitFor(() => { - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage })); - }); - - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardXBlock, - }); - - axiosMock - .onGet(getCourseUnitApiUrl(courseId)) - .reply(200, { - ...courseUnitIndexMock, - enable_copy_paste_units: true, - }); - - await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - - expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); - }); - it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { const { getAllByTestId, getByRole, @@ -1471,55 +1124,225 @@ describe('', () => { }); }); - describe('Drag and drop', () => { - it('checks xblock list is restored to original order when API call fails', async () => { - const { findAllByRole } = render(); + 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(); - const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - const draggableButton = xBlocksDraggers[1]; + await waitFor(() => { + expect(getByText(unitDisplayName)).toBeInTheDocument(); + }); axiosMock - .onPut(getXBlockBaseApiUrl(blockId)) - .reply(500, { dummy: 'value' }); + .onGet(getOutlineInfo(courseId)) + .reply(200, courseOutlineInfoMock); + await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch); - const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id; + window.dispatchEvent(messageEvent); - fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + 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(); + }); - await waitFor(async () => { - fireEvent.keyDown(draggableButton, { code: 'Space' }); + it('should navigates to xBlock current unit', async () => { + const { + getByText, + getByRole, + } = render(); - const saveStatus = store.getState().courseUnit.savingStatus; - expect(saveStatus).toEqual(RequestStatus.FAILED); + await waitFor(() => { + expect(getByText(unitDisplayName)).toBeInTheDocument(); }); - const xBlock1New = store.getState().courseUnit.courseVerticalChildren.children[0].id; - expect(xBlock1).toBe(xBlock1New); - }); + axiosMock + .onGet(getOutlineInfo(courseId)) + .reply(200, courseOutlineInfoMock); + await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch); - it('check that new xblock list is saved when dragged', async () => { - const { findAllByRole } = render(); + window.dispatchEvent(messageEvent); - const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - const draggableButton = xBlocksDraggers[1]; + expect(getByText( + moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title), + )).toBeInTheDocument(); - axiosMock - .onPut(getXBlockBaseApiUrl(blockId)) - .reply(200, { dummy: 'value' }); + const currentSection = courseOutlineInfoMock.child_info.children[1]; + const currentSectionItemText = getByRole('button', { + name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, + }); + expect(currentSectionItemText).toBeInTheDocument(); + fireEvent.click(currentSectionItemText); + + const currentSubsection = currentSection.child_info.children[0]; + const currentSubsectionText = getByRole('button', { + name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, + }); + expect(currentSubsectionText).toBeInTheDocument(); + fireEvent.click(currentSubsectionText); + + const currentComponentLocationText = getByText( + moveModalMessages.moveModalOutlineItemCurrentComponentLocationText.defaultMessage, + ); + expect(currentComponentLocationText).toBeInTheDocument(); + }); + + it('should display "Move Confirmation" alert after moving and undo operations', async () => { + const { + queryByRole, + getByText, + } = render(); - const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id; + 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, + }); - fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(`${requestData.title} has been moved`)).toBeInTheDocument(); + expect(dismissButton).toBeInTheDocument(); + expect(undoButton).toBeInTheDocument(); + expect(newLocationButton).toBeInTheDocument(); + expect(requestData.callbackFn).toHaveBeenCalled(); - await waitFor(async () => { - fireEvent.keyDown(draggableButton, { code: 'Space' }); + fireEvent.click(undoButton); - const saveStatus = store.getState().courseUnit.savingStatus; - expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); + 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(); + expect(requestData.callbackFn).toHaveBeenCalled(); + }); + + it('should navigate to new location by button click', async () => { + const { + queryByRole, + } = render(); - const xBlock2 = store.getState().courseUnit.courseVerticalChildren.children[1].id; - expect(xBlock1).toBe(xBlock2); + 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, + }); + fireEvent.click(newLocationButton); + expect(mockedUsedNavigate).toHaveBeenCalledWith( + `/course/${courseId}/container/${blockId}/${requestData.currentParentLocator}`, + { replace: true }, + ); }); }); + + // it('checks xblock list is restored to original order when API call fails', async () => { + // const { findAllByRole } = render(); + + // const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); + // const draggableButton = xBlocksDraggers[1]; + + // axiosMock + // .onPut(getXBlockBaseApiUrl(blockId)) + // .reply(500, { dummy: 'value' }); + + // const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id; + + // fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + + // await waitFor(async () => { + // fireEvent.keyDown(draggableButton, { code: 'Space' }); + + // const saveStatus = store.getState().courseUnit.savingStatus; + // expect(saveStatus).toEqual(RequestStatus.FAILED); + // }); + + // const xBlock1New = store.getState().courseUnit.courseVerticalChildren.children[0].id; + // expect(xBlock1).toBe(xBlock1New); + // }); + + // it('check that new xblock list is saved when dragged', async () => { + // const { findAllByRole } = render(); + + // const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); + // const draggableButton = xBlocksDraggers[1]; + + // axiosMock + // .onPut(getXBlockBaseApiUrl(blockId)) + // .reply(200, { dummy: 'value' }); + + // const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id; + + // fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + + // await waitFor(async () => { + // fireEvent.keyDown(draggableButton, { code: 'Space' }); + + // const saveStatus = store.getState().courseUnit.savingStatus; + // expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); + // }); + + // const xBlock2 = store.getState().courseUnit.courseVerticalChildren.children[1].id; + // expect(xBlock1).toBe(xBlock2); + // }); + // }); }); 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/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index a2c80f8b74..573da717fe 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -16,7 +16,7 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false); const [isOpenHtml, openHtml, closeHtml] = useToggle(false); const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false); - const { componentTemplates } = useSelector(getCourseSectionVertical); + const { componentTemplates = {} } = useSelector(getCourseSectionVertical); const handleCreateNewXBlock = (type, moduleName) => { switch (type) { diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.jsx b/src/course-unit/breadcrumbs/Breadcrumbs.jsx index 8dd34cfc52..8d770fd23b 100644 --- a/src/course-unit/breadcrumbs/Breadcrumbs.jsx +++ b/src/course-unit/breadcrumbs/Breadcrumbs.jsx @@ -1,3 +1,4 @@ +import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Dropdown, Icon } from '@openedx/paragon'; @@ -6,73 +7,67 @@ import { ChevronRight as ChevronRightIcon, } from '@openedx/paragon/icons'; -import { createCorrectInternalRoute } from '../../utils'; import { getCourseSectionVertical } from '../data/selectors'; +import { adoptCourseSectionUrl } from '../utils'; import messages from './messages'; -const Breadcrumbs = () => { +const Breadcrumbs = ({ courseId, sequenceId }) => { const intl = useIntl(); - const { ancestorXblocks } = useSelector(getCourseSectionVertical); - const [section, subsection] = ancestorXblocks ?? []; + const { ancestorXblocks = [] } = useSelector(getCourseSectionVertical); return ( ); }; +Breadcrumbs.propTypes = { + courseId: PropTypes.string.isRequired, + sequenceId: PropTypes.string.isRequired, +}; + export default Breadcrumbs; diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index 9ff040d63c..fbb51640b7 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -38,3 +38,23 @@ export const getXBlockSupportMessages = (intl) => ({ tooltip: intl.formatMessage(addComponentMessages.modalComponentSupportTooltipNotSupported), }, }); + +export const stateKeys = { + iframeHeight: 'iframeHeight', + hasLoaded: 'hasLoaded', + showError: 'showError', + windowTopOffset: 'windowTopOffset', +}; + +export const messageTypes = { + modal: 'plugin.modal', + resize: 'plugin.resize', + videoFullScreen: 'plugin.videoFullScreen', + refreshXBlock: 'refreshXBlock', + showMoveXBlockModal: 'showMoveXBlockModal', + handleViewXBlockContent: 'handleViewXBlockContent', +}; + +export const IFRAME_FEATURE_POLICY = ( + 'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *' +); 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..3ca1733114 --- /dev/null +++ b/src/course-unit/context/iFrameContext.tsx @@ -0,0 +1,40 @@ +import React, { + createContext, MutableRefObject, useState, useMemo, +} from 'react'; + +export interface IframeContextType { + setIframeRef: (ref: MutableRefObject) => void; + sendMessageToIframe: (messageType: string, payload: any) => void; +} + +export const IframeContext = createContext(undefined); + +export const IframeProvider: React.FC = ({ children }) => { + const [iframeRef, setIframeRef] = useState | null>(null); + + const sendMessageToIframe = (messageType: string, payload: any) => { + const iframeWindow = iframeRef?.current?.contentWindow; + if (iframeWindow) { + try { + iframeWindow.postMessage({ type: messageType, payload }, '*'); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to send message to iframe:', error); + } + } else { + // eslint-disable-next-line no-console + console.warn('Iframe is not accessible or loaded yet.'); + } + }; + + const value = useMemo(() => ({ + setIframeRef, + sendMessageToIframe, + }), [setIframeRef, sendMessageToIframe]); + + return ( + + {children} + + ); +}; diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx deleted file mode 100644 index 2d8f6221e8..0000000000 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ /dev/null @@ -1,192 +0,0 @@ -import { useEffect, useRef } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { useDispatch, useSelector } from 'react-redux'; -import { - ActionRow, Card, Dropdown, Icon, IconButton, useToggle, -} from '@openedx/paragon'; -import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { useNavigate, useSearchParams } from 'react-router-dom'; - -import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors'; -import DeleteModal from '../../generic/delete-modal/DeleteModal'; -import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; -import SortableItem from '../../generic/drag-helper/SortableItem'; -import { scrollToElement } from '../../course-outline/utils'; -import { COURSE_BLOCK_NAMES } from '../../constants'; -import { copyToClipboard } from '../../generic/data/thunks'; -import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; -import XBlockMessages from './xblock-messages/XBlockMessages'; -import messages from './messages'; - -const CourseXBlock = ({ - id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo, - handleConfigureSubmit, validationMessages, ...props -}) => { - const courseXBlockElementRef = useRef(null); - const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); - const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); - const dispatch = useDispatch(); - const navigate = useNavigate(); - const canEdit = useSelector(getCanEdit); - const courseId = useSelector(getCourseId); - const intl = useIntl(); - - const [searchParams] = useSearchParams(); - const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === id; - - const visibilityMessage = userPartitionInfo.selectedGroupsLabel - ? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel }) - : null; - - const currentItemData = { - category: COURSE_BLOCK_NAMES.component.id, - displayName: title, - userPartitionInfo, - showCorrectness: 'always', - }; - - const onDeleteSubmit = () => { - unitXBlockActions.handleDelete(id); - closeDeleteModal(); - }; - - const handleEdit = () => { - switch (type) { - case COMPONENT_TYPES.html: - case COMPONENT_TYPES.problem: - case COMPONENT_TYPES.video: - navigate(`/course/${courseId}/editor/${type}/${id}`); - break; - default: - } - }; - - const onConfigureSubmit = (...arg) => { - handleConfigureSubmit(id, ...arg, closeConfigureModal); - }; - - useEffect(() => { - // if this item has been newly added, scroll to it. - if (courseXBlockElementRef.current && (shouldScroll || isScrolledToElement)) { - scrollToElement(courseXBlockElementRef.current); - } - }, [isScrolledToElement]); - - return ( -
- - - - - - - unitXBlockActions.handleDuplicate(id)}> - {intl.formatMessage(messages.blockLabelButtonDuplicate)} - - - {intl.formatMessage(messages.blockLabelButtonMove)} - - {canEdit && ( - dispatch(copyToClipboard(id))}> - {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)} - - )} - - {intl.formatMessage(messages.blockLabelButtonManageAccess)} - - - {intl.formatMessage(messages.blockLabelButtonDelete)} - - - - - - - )} - /> - - -
- - -
- ); -}; - -CourseXBlock.defaultProps = { - validationMessages: [], - shouldScroll: false, -}; - -CourseXBlock.propTypes = { - id: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - shouldScroll: PropTypes.bool, - validationMessages: PropTypes.arrayOf(PropTypes.shape({ - type: PropTypes.string, - text: PropTypes.string, - })), - unitXBlockActions: PropTypes.shape({ - handleDelete: PropTypes.func, - handleDuplicate: PropTypes.func, - }).isRequired, - userPartitionInfo: PropTypes.shape({ - selectablePartitions: PropTypes.arrayOf(PropTypes.shape({ - groups: PropTypes.arrayOf(PropTypes.shape({ - deleted: PropTypes.bool, - id: PropTypes.number, - name: PropTypes.string, - selected: PropTypes.bool, - })), - id: PropTypes.number, - name: PropTypes.string, - scheme: PropTypes.string, - })), - selectedPartitionIndex: PropTypes.number, - selectedGroupsLabel: PropTypes.string, - }).isRequired, - handleConfigureSubmit: PropTypes.func.isRequired, -}; - -export default CourseXBlock; diff --git a/src/course-unit/course-xblock/CourseXBlock.scss b/src/course-unit/course-xblock/CourseXBlock.scss deleted file mode 100644 index 4ae9f6dab1..0000000000 --- a/src/course-unit/course-xblock/CourseXBlock.scss +++ /dev/null @@ -1,36 +0,0 @@ -.course-unit { - .course-unit__xblocks { - .course-unit__xblock:not(:first-child) { - margin-top: 1.75rem; - } - - .pgn__card-header { - display: flex; - justify-content: space-between; - border-bottom: 1px solid $light-400; - padding-bottom: map-get($spacers, 2); - - &:not(:has(.pgn__card-header-subtitle-md)) { - align-items: center; - } - } - - .pgn__card-header-subtitle-md { - margin-top: 0; - font-size: $font-size-sm; - } - - .pgn__card-header-title-md { - font: 700 1.375rem/1.75rem $font-family-sans-serif; - color: $black; - } - - .pgn__card-section { - padding: map-get($spacers, 3\.5) 0; - } - } - - .unit-iframe__wrapper .alert-danger { - margin-bottom: 0; - } -} diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx deleted file mode 100644 index 0cdf05d4f6..0000000000 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ /dev/null @@ -1,314 +0,0 @@ -import { - render, waitFor, within, -} from '@testing-library/react'; -import { useSelector } from 'react-redux'; -import userEvent from '@testing-library/user-event'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; -import { AppProvider } from '@edx/frontend-platform/react'; -import MockAdapter from 'axios-mock-adapter'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -import configureModalMessages from '../../generic/configure-modal/messages'; -import deleteModalMessages from '../../generic/delete-modal/messages'; -import initializeStore from '../../store'; -import { getCourseSectionVerticalApiUrl, getXBlockBaseApiUrl } from '../data/api'; -import { fetchCourseSectionVerticalData } from '../data/thunk'; -import { executeThunk } from '../../utils'; -import { getCourseId } from '../data/selectors'; -import { PUBLISH_TYPES } from '../constants'; -import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; -import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__'; -import CourseXBlock from './CourseXBlock'; -import messages from './messages'; - -let axiosMock; -let store; -const courseId = '1234'; -const blockId = '567890'; -const handleDeleteMock = jest.fn(); -const handleDuplicateMock = jest.fn(); -const handleConfigureSubmitMock = jest.fn(); -const mockedUsedNavigate = jest.fn(); -const { - name, - block_id: id, - block_type: type, - user_partition_info: userPartitionInfo, -} = courseVerticalChildrenMock.children[0]; -const userPartitionInfoFormatted = camelCaseObject(userPartitionInfo); -const unitXBlockActionsMock = { - handleDelete: handleDeleteMock, - handleDuplicate: handleDuplicateMock, -}; - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockedUsedNavigate, -})); - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -const renderComponent = (props) => render( - - - - - , -); - -useSelector.mockImplementation((selector) => { - if (selector === getCourseId) { - return courseId; - } - return null; -}); - -describe('', () => { - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, courseSectionVerticalMock); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - }); - - it('render CourseXBlock component correctly', async () => { - const { getByText, getByLabelText } = renderComponent(); - - await waitFor(() => { - expect(getByText(name)).toBeInTheDocument(); - expect(getByLabelText(messages.blockAltButtonEdit.defaultMessage)).toBeInTheDocument(); - expect(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)).toBeInTheDocument(); - }); - }); - - it('render CourseXBlock component action dropdown correctly', async () => { - const { getByRole, getByLabelText } = renderComponent(); - - await waitFor(() => { - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - expect(getByRole('button', { name: messages.blockLabelButtonDuplicate.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.blockLabelButtonMove.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.blockLabelButtonManageAccess.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.blockLabelButtonDelete.defaultMessage })).toBeInTheDocument(); - }); - }); - - it('calls handleDuplicate when item is clicked', async () => { - const { getByText, getByLabelText } = renderComponent(); - - await waitFor(() => { - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const duplicateBtn = getByText(messages.blockLabelButtonDuplicate.defaultMessage); - - userEvent.click(duplicateBtn); - expect(handleDuplicateMock).toHaveBeenCalledTimes(1); - expect(handleDuplicateMock).toHaveBeenCalledWith(id); - }); - }); - - it('opens confirm delete modal and calls handleDelete when deleting was confirmed', async () => { - const { getByText, getByLabelText, getByRole } = renderComponent(); - - await waitFor(() => { - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const deleteBtn = getByText(messages.blockLabelButtonDelete.defaultMessage); - - userEvent.click(deleteBtn); - expect(getByText(/Delete this component?/)).toBeInTheDocument(); - expect(getByText(/Deleting this component is permanent and cannot be undone./)).toBeInTheDocument(); - expect(getByRole('button', { name: deleteModalMessages.cancelButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage })).toBeInTheDocument(); - - userEvent.click(getByRole('button', { name: deleteModalMessages.cancelButton.defaultMessage })); - expect(handleDeleteMock).not.toHaveBeenCalled(); - - userEvent.click(getByText(messages.blockLabelButtonDelete.defaultMessage)); - expect(getByText(/Delete this component?/)).toBeInTheDocument(); - - userEvent.click(deleteBtn); - userEvent.click(getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage })); - expect(handleDeleteMock).toHaveBeenCalled(); - expect(handleDeleteMock).toHaveBeenCalledWith(id); - }); - }); - - describe('edit', () => { - it('navigates to editor page on edit HTML xblock', () => { - const { getByText, getByRole } = renderComponent({ - type: COMPONENT_TYPES.html, - }); - - const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage }); - expect(getByText(name)).toBeInTheDocument(); - expect(editButton).toBeInTheDocument(); - - userEvent.click(editButton); - expect(mockedUsedNavigate).toHaveBeenCalled(); - expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`); - }); - - it('navigates to editor page on edit Video xblock', () => { - const { getByText, getByRole } = renderComponent({ - type: COMPONENT_TYPES.video, - }); - - const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage }); - expect(getByText(name)).toBeInTheDocument(); - expect(editButton).toBeInTheDocument(); - - userEvent.click(editButton); - expect(mockedUsedNavigate).toHaveBeenCalled(); - expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`); - }); - - it('navigates to editor page on edit Problem xblock', () => { - const { getByText, getByRole } = renderComponent({ - type: COMPONENT_TYPES.problem, - }); - - const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage }); - expect(getByText(name)).toBeInTheDocument(); - expect(editButton).toBeInTheDocument(); - - userEvent.click(editButton); - expect(mockedUsedNavigate).toHaveBeenCalled(); - expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`); - expect(handleDeleteMock).toHaveBeenCalledWith(id); - }); - }); - - describe('restrict access', () => { - it('opens restrict access modal successfully', async () => { - const { - getByText, - getByLabelText, - findByTestId, - } = renderComponent(); - - const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage; - const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage; - const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage; - - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); - - userEvent.click(accessBtn); - const configureModal = await findByTestId('configure-modal'); - - expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument(); - expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument(); - expect(within(configureModal).getByRole('button', { name: modalSaveBtnText })).toBeInTheDocument(); - }); - - it('closes restrict access modal when cancel button is clicked', async () => { - const { - getByText, - getByLabelText, - findByTestId, - } = renderComponent(); - - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); - - userEvent.click(accessBtn); - const configureModal = await findByTestId('configure-modal'); - expect(configureModal).toBeInTheDocument(); - - userEvent.click(within(configureModal).getByRole('button', { name: configureModalMessages.saveButton.defaultMessage })); - expect(handleConfigureSubmitMock).not.toHaveBeenCalled(); - }); - - it('handles submit restrict access data when save button is clicked', async () => { - axiosMock - .onPost(getXBlockBaseApiUrl(id), { - publish: PUBLISH_TYPES.republish, - metadata: { visible_to_staff_only: false, group_access: { 970807507: [1959537066] } }, - }) - .reply(200, { dummy: 'value' }); - - const { - getByText, - getByLabelText, - findByTestId, - getByRole, - } = renderComponent(); - const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name; - const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name; - - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); - - userEvent.click(accessBtn); - const configureModal = await findByTestId('configure-modal'); - expect(configureModal).toBeInTheDocument(); - expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument(); - expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument(); - - const restrictAccessSelect = getByRole('combobox', { - name: configureModalMessages.restrictAccessTo.defaultMessage, - }); - userEvent.selectOptions(restrictAccessSelect, '0'); - - // eslint-disable-next-line array-callback-return - userPartitionInfoFormatted.selectablePartitions[0].groups.map((group) => { - expect(within(configureModal).getByRole('checkbox', { name: group.name })).not.toBeChecked(); - expect(within(configureModal).queryByText(group.name)).toBeInTheDocument(); - }); - - const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 }); - userEvent.click(group1Checkbox); - expect(group1Checkbox).toBeChecked(); - - const saveModalBtnText = within(configureModal).getByRole('button', { - name: configureModalMessages.saveButton.defaultMessage, - }); - expect(saveModalBtnText).toBeInTheDocument(); - userEvent.click(saveModalBtnText); - await waitFor(() => { - expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1); - }); - }); - }); - - it('displays a visibility message if item has accessible restrictions', async () => { - const { getByText } = renderComponent( - { - userPartitionInfo: { - ...userPartitionInfoFormatted, - selectedGroupsLabel: 'Visibility group 1', - }, - }, - ); - - await waitFor(() => { - const visibilityMessage = messages.visibilityMessage.defaultMessage - .replace('{selectedGroupsLabel}', 'Visibility group 1'); - expect(getByText(visibilityMessage)).toBeInTheDocument(); - }); - }); -}); diff --git a/src/course-unit/course-xblock/constants.js b/src/course-unit/course-xblock/constants.js deleted file mode 100644 index 5f0177ce72..0000000000 --- a/src/course-unit/course-xblock/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -// eslint-disable-next-line import/prefer-default-export -export const MESSAGE_ERROR_TYPES = { - error: 'error', - warning: 'warning', -}; diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js deleted file mode 100644 index 3e1652de19..0000000000 --- a/src/course-unit/course-xblock/messages.js +++ /dev/null @@ -1,55 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - blockAltButtonEdit: { - id: 'course-authoring.course-unit.xblock.button.edit.alt', - defaultMessage: 'Edit', - description: 'The xblock edit button text', - }, - blockActionsDropdownAlt: { - id: 'course-authoring.course-unit.xblock.button.actions.alt', - defaultMessage: 'Actions', - description: 'The xblock three dots dropdown alt text', - }, - blockLabelButtonCopy: { - id: 'course-authoring.course-unit.xblock.button.copy.label', - defaultMessage: 'Copy', - description: 'The xblock copy button text', - }, - blockLabelButtonDuplicate: { - id: 'course-authoring.course-unit.xblock.button.duplicate.label', - defaultMessage: 'Duplicate', - description: 'The xblock duplicate button text', - }, - blockLabelButtonMove: { - id: 'course-authoring.course-unit.xblock.button.move.label', - defaultMessage: 'Move', - description: 'The xblock move button text', - }, - blockLabelButtonCopyToClipboard: { - id: 'course-authoring.course-unit.xblock.button.copyToClipboard.label', - defaultMessage: 'Copy to clipboard', - }, - blockLabelButtonManageAccess: { - id: 'course-authoring.course-unit.xblock.button.manageAccess.label', - defaultMessage: 'Manage access', - description: 'The xblock manage access button text', - }, - blockLabelButtonDelete: { - id: 'course-authoring.course-unit.xblock.button.delete.label', - defaultMessage: 'Delete', - description: 'The xblock delete button text', - }, - visibilityMessage: { - id: 'course-authoring.course-unit.xblock.visibility.message', - defaultMessage: 'Access restricted to: {selectedGroupsLabel}', - description: 'Group visibility accessibility text for xblock', - }, - validationSummary: { - id: 'course-authoring.course-unit.xblock.validation.summary', - defaultMessage: 'This component has validation issues.', - description: 'The alert text of the visibility validation issues', - }, -}); - -export default messages; diff --git a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx deleted file mode 100644 index 0d7e32a4b1..0000000000 --- a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import PropTypes from 'prop-types'; -import { Alert } from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Info as InfoIcon, WarningFilled as WarningIcon } from '@openedx/paragon/icons'; - -import messages from '../messages'; -import { MESSAGE_ERROR_TYPES } from '../constants'; -import { getMessagesBlockType } from './utils'; - -const XBlockMessages = ({ validationMessages }) => { - const intl = useIntl(); - const type = getMessagesBlockType(validationMessages); - const { warning } = MESSAGE_ERROR_TYPES; - const alertVariant = type === warning ? 'warning' : 'danger'; - const alertIcon = type === warning ? WarningIcon : InfoIcon; - - if (!validationMessages.length) { - return null; - } - - return ( - - - {intl.formatMessage(messages.validationSummary)} - -
    - {validationMessages.map(({ text }) => ( -
  • {text}
  • - ))} -
-
- ); -}; - -XBlockMessages.defaultProps = { - validationMessages: [], -}; - -XBlockMessages.propTypes = { - validationMessages: PropTypes.arrayOf(PropTypes.shape({ - type: PropTypes.string, - text: PropTypes.string, - })), -}; - -export default XBlockMessages; diff --git a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx deleted file mode 100644 index 8d7e36e98a..0000000000 --- a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import { render } from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; - -import messages from '../messages'; -import XBlockMessages from './XBlockMessages'; - -const renderComponent = (props) => render( - - - , -); - -describe('', () => { - it('renders without errors', () => { - renderComponent({ validationMessages: [] }); - }); - - it('does not render anything when there are no errors', () => { - const { container } = renderComponent({ validationMessages: [] }); - expect(container.firstChild).toBeNull(); - }); - - it('renders a warning Alert when there are warning errors', () => { - const validationMessages = [{ type: 'warning', text: 'This is a warning' }]; - const { getByText } = renderComponent({ validationMessages }); - - expect(getByText('This is a warning')).toBeInTheDocument(); - expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); - }); - - it('renders a danger Alert when there are danger errors', () => { - const validationMessages = [{ type: 'danger', text: 'This is a danger' }]; - const { getByText } = renderComponent({ validationMessages }); - - expect(getByText('This is a danger')).toBeInTheDocument(); - expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); - }); - - it('renders multiple error messages in a list', () => { - const validationMessages = [ - { type: 'warning', text: 'Warning 1' }, - { type: 'danger', text: 'Danger 1' }, - { type: 'danger', text: 'Danger 2' }, - ]; - const { getByText } = renderComponent({ validationMessages }); - - expect(getByText('Warning 1')).toBeInTheDocument(); - expect(getByText('Danger 1')).toBeInTheDocument(); - expect(getByText('Danger 2')).toBeInTheDocument(); - expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); - }); -}); diff --git a/src/course-unit/course-xblock/xblock-messages/utils.js b/src/course-unit/course-xblock/xblock-messages/utils.js deleted file mode 100644 index 2a815b7aa2..0000000000 --- a/src/course-unit/course-xblock/xblock-messages/utils.js +++ /dev/null @@ -1,16 +0,0 @@ -import { MESSAGE_ERROR_TYPES } from '../constants'; - -/** - * Determines the block type based on the types of messages in the given array. - * @param {Array} messages - An array of message objects. - * @param {Object[]} messages.type - The type of each message (e.g., MESSAGE_ERROR_TYPES.error). - * @returns {string} - The block type determined by the messages (e.g., 'warning' or 'error'). - */ -// eslint-disable-next-line import/prefer-default-export -export const getMessagesBlockType = (messages) => { - let type = MESSAGE_ERROR_TYPES.warning; - if (messages.some((message) => message.type === MESSAGE_ERROR_TYPES.error)) { - type = MESSAGE_ERROR_TYPES.error; - } - return type; -}; diff --git a/src/course-unit/course-xblock/xblock-messages/utils.test.js b/src/course-unit/course-xblock/xblock-messages/utils.test.js deleted file mode 100644 index 32e8dde4f6..0000000000 --- a/src/course-unit/course-xblock/xblock-messages/utils.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import { MESSAGE_ERROR_TYPES } from '../constants'; -import { getMessagesBlockType } from './utils'; - -describe('xblock-messages utils', () => { - describe('getMessagesBlockType', () => { - it('returns "warning" when there are no error messages', () => { - const messages = [ - { type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' }, - { type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' }, - ]; - const result = getMessagesBlockType(messages); - - expect(result).toBe(MESSAGE_ERROR_TYPES.warning); - }); - - it('returns "error" when there is at least one error message', () => { - const messages = [ - { type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' }, - { type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' }, - { type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' }, - ]; - const result = getMessagesBlockType(messages); - - expect(result).toBe(MESSAGE_ERROR_TYPES.error); - }); - - it('returns "error" when there are only error messages', () => { - const messages = [ - { type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' }, - { type: MESSAGE_ERROR_TYPES.error, text: 'Another error' }, - ]; - const result = getMessagesBlockType(messages); - - expect(result).toBe(MESSAGE_ERROR_TYPES.error); - }); - - it('returns "warning" when there are no messages', () => { - const messages = []; - const result = getMessagesBlockType(messages); - - expect(result).toBe(MESSAGE_ERROR_TYPES.warning); - }); - }); -}); diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 155e9d9878..cbd2503a12 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 getOutlineInfo = (courseId) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`; export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; /** @@ -89,15 +90,17 @@ export async function createCourseXblock({ * @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges). * @param {boolean} isVisible - The visibility status for students. * @param {boolean} groupAccess - Access group key set. + * @param {boolean} isDiscussionEnabled - Indicates whether the discussion feature is enabled. * @returns {Promise} A promise that resolves with the response data. */ -export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess) { +export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess, isDiscussionEnabled) { const body = { publish: groupAccess ? null : type, ...(type === PUBLISH_TYPES.republish ? { metadata: { visible_to_staff_only: isVisible ? true : null, group_access: groupAccess || null, + discussion_enabled: isDiscussionEnabled, }, } : {}), }; @@ -134,30 +137,29 @@ export async function deleteUnitItem(itemId) { } /** - * Duplicate a unit item. - * @param {string} itemId - * @param {string} XBlockId + * Get an object containing course outline data. + * @param {string} courseId - The identifier of the course. * @returns {Promise} */ -export async function duplicateUnitItem(itemId, XBlockId) { +export async function getCourseOutlineInfo(courseId) { const { data } = await getAuthenticatedHttpClient() - .post(postXBlockBaseApiUrl(), { - parent_locator: itemId, - duplicate_source_locator: XBlockId, - }); + .get(getOutlineInfo(courseId)); return data; } /** - * Sets the order list of XBlocks. - * @param {string} blockId - The identifier of the course unit. - * @param {Object[]} children - The array of child elements representing the updated order of XBlocks. - * @returns {Promise} - A promise that resolves to the updated data after setting the XBlock order. + * 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} - A promise that resolves to the response data from the server. */ -export async function setXBlockOrderList(blockId, children) { +export async function patchUnitItem(sourceLocator, targetParentLocator) { const { data } = await getAuthenticatedHttpClient() - .put(getXBlockBaseApiUrl(blockId), { children }); + .patch(postXBlockBaseApiUrl(), { + parent_locator: targetParentLocator, + move_source_locator: sourceLocator, + }); return 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 f0d5f8c3aa..1755d0960f 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 }) => { @@ -84,32 +93,17 @@ const slice = createSlice({ updateCourseVerticalChildrenLoadingStatus: (state, { payload }) => { state.loadingStatus.courseVerticalChildrenLoadingStatus = payload.status; }, - deleteXBlock: (state, { payload }) => { - state.courseVerticalChildren.children = state.courseVerticalChildren.children.filter( - (component) => component.id !== payload, - ); - }, - duplicateXBlock: (state, { payload }) => { - state.courseVerticalChildren = { - ...payload.newCourseVerticalChildren, - children: payload.newCourseVerticalChildren.children.map((component) => { - if (component.blockId === payload.newId) { - component.shouldScroll = true; - } - return component; - }), - }; - }, fetchStaticFileNoticesSuccess: (state, { payload }) => { state.staticFileNotices = payload; }, - reorderXBlockList: (state, { payload }) => { - // Create a map for payload IDs to their index for O(1) lookups - const indexMap = new Map(payload.map((id, index) => [id, index])); - - // Directly sort the children based on the order defined in payload - // This avoids the need to copy the array beforehand - state.courseVerticalChildren.children.sort((a, b) => (indexMap.get(a.id) || 0) - (indexMap.get(b.id) || 0)); + updateCourseOutlineInfo: (state, { payload }) => { + state.courseOutlineInfo = payload; + }, + updateCourseOutlineInfoLoadingStatus: (state, { payload }) => { + state.courseOutlineInfoLoadingStatus = payload.status; + }, + updateMovedXBlockParams: (state, { payload }) => { + state.movedXBlockParams = { ...state.movedXBlockParams, ...payload }; }, }, }); @@ -129,10 +123,10 @@ export const { updateLoadingCourseXblockStatus, updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, - deleteXBlock, - duplicateXBlock, fetchStaticFileNoticesSuccess, - reorderXBlockList, + updateCourseOutlineInfo, + updateCourseOutlineInfoLoadingStatus, + updateMovedXBlockParams, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index c2ac2be7c8..c3f7b956ef 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -17,8 +17,8 @@ import { getCourseVerticalChildren, handleCourseUnitVisibilityAndData, deleteUnitItem, - duplicateUnitItem, - setXBlockOrderList, + getCourseOutlineInfo, + patchUnitItem, } from './api'; import { updateLoadingCourseUnitStatus, @@ -33,10 +33,10 @@ import { updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, updateQueryPendingStatus, - deleteXBlock, - duplicateXBlock, fetchStaticFileNoticesSuccess, - reorderXBlockList, + updateCourseOutlineInfo, + updateCourseOutlineInfoLoadingStatus, + updateMovedXBlockParams, } from './slice'; import { getNotificationMessage } from './utils'; @@ -71,7 +71,7 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) { })); dispatch(updateModels({ modelType: 'units', - models: courseSectionVerticalData.units, + models: courseSectionVerticalData.units || [], })); dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices')))); localStorage.removeItem('staticFileNotices'); @@ -104,7 +104,7 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) { })); dispatch(updateModels({ modelType: 'units', - models: courseSectionVerticalData.units, + models: courseSectionVerticalData.units || [], })); dispatch(fetchSequenceSuccess({ sequenceId })); dispatch(fetchCourseItemSuccess(courseUnit)); @@ -119,15 +119,28 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) { }; } -export function editCourseUnitVisibilityAndData(itemId, type, isVisible, groupAccess, isModalView, blockId = itemId) { +export function editCourseUnitVisibilityAndData( + itemId, + type, + isVisible, + groupAccess, + isDiscussionEnabled, + blockId = itemId, +) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(updateQueryPendingStatus(true)); - const notification = getNotificationMessage(type, isVisible, isModalView); + const notification = getNotificationMessage(type, isVisible, true); dispatch(showProcessingNotification(notification)); try { - await handleCourseUnitVisibilityAndData(itemId, type, isVisible, groupAccess).then(async (result) => { + await handleCourseUnitVisibilityAndData( + itemId, + type, + isVisible, + groupAccess, + isDiscussionEnabled, + ).then(async (result) => { if (result) { const courseUnit = await getCourseUnitData(blockId); dispatch(fetchCourseItemSuccess(courseUnit)); @@ -206,6 +219,7 @@ export function fetchCourseVerticalChildrenData(itemId) { }; } +// TODO: use for xblock delete functionality export function deleteUnitItemQuery(itemId, xblockId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); @@ -213,7 +227,6 @@ export function deleteUnitItemQuery(itemId, xblockId) { try { await deleteUnitItem(xblockId); - dispatch(deleteXBlock(xblockId)); const { userClipboard } = await getCourseSectionVerticalData(itemId); dispatch(updateClipboardData(userClipboard)); const courseUnit = await getCourseUnitData(itemId); @@ -227,47 +240,53 @@ export function deleteUnitItemQuery(itemId, xblockId) { }; } -export function duplicateUnitItemQuery(itemId, xblockId) { +export function getCourseOutlineInfoQuery(courseId) { return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); + dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS })); try { - const { locator } = await duplicateUnitItem(itemId, xblockId); - const newCourseVerticalChildren = await getCourseVerticalChildren(itemId); - dispatch(duplicateXBlock({ - newId: locator, - newCourseVerticalChildren, - })); - const courseUnit = await getCourseUnitData(itemId); - dispatch(fetchCourseItemSuccess(courseUnit)); - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + await getCourseOutlineInfo(courseId).then(async (result) => { + if (result) { + dispatch(updateCourseOutlineInfo(result)); + dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + } + }); } catch (error) { - dispatch(hideProcessingNotification()); handleResponseErrors(error, dispatch, updateSavingStatus); + dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.FAILED })); } }; } -export function setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback) { +export function patchUnitItemQuery({ + sourceLocator, + targetParentLocator, + title, + currentParentLocator, + isMoving, + callbackFn, +}) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES[isMoving ? 'moving' : 'undoMoving'])); try { - await setXBlockOrderList(blockId, xblockListIds).then(async (result) => { - if (result) { - dispatch(reorderXBlockList(xblockListIds)); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - const courseUnit = await getCourseUnitData(blockId); - dispatch(fetchCourseItemSuccess(courseUnit)); - } - }); + await patchUnitItem(sourceLocator, isMoving ? targetParentLocator : currentParentLocator); + const xBlockParams = { + title, + isSuccess: true, + isUndo: !isMoving, + sourceLocator: sourceLocator || '', + targetParentLocator: targetParentLocator || '', + currentParentLocator: currentParentLocator || '', + }; + dispatch(updateMovedXBlockParams(xBlockParams)); + dispatch(hideProcessingNotification()); + dispatch(updateCourseOutlineInfo({})); + dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + callbackFn(); } catch (error) { - restoreCallback(); handleResponseErrors(error, dispatch, updateSavingStatus); - } finally { dispatch(hideProcessingNotification()); } }; diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js index b523b9ace6..ef589bb491 100644 --- a/src/course-unit/data/utils.js +++ b/src/course-unit/data/utils.js @@ -11,9 +11,9 @@ export function normalizeCourseSectionVerticalData(metadata) { sequence: { id: data.subsectionLocation, title: data.xblock.displayName, - unitIds: data.xblockInfo.ancestorInfo.ancestors[0].childInfo.children.map((item) => item.id), + unitIds: data.xblockInfo.ancestorInfo?.ancestors[0].childInfo.children.map((item) => item.id), }, - units: data.xblockInfo.ancestorInfo.ancestors[0].childInfo.children.map((unit) => ({ + units: data.xblockInfo.ancestorInfo?.ancestors[0].childInfo.children.map((unit) => ({ id: unit.id, sequenceId: data.subsectionLocation, bookmarked: unit.bookmarked, diff --git a/src/course-unit/header-navigations/HeaderNavigations.jsx b/src/course-unit/header-navigations/HeaderNavigations.jsx index 178c768dfd..a934c0c974 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.jsx +++ b/src/course-unit/header-navigations/HeaderNavigations.jsx @@ -1,27 +1,42 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; +import { Edit as EditIcon } from '@openedx/paragon/icons'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import messages from './messages'; -const HeaderNavigations = ({ headerNavigationsActions }) => { +const HeaderNavigations = ({ headerNavigationsActions, unitCategory }) => { const intl = useIntl(); - const { handleViewLive, handlePreview } = headerNavigationsActions; + const { handleViewLive, handlePreview, handleEdit } = headerNavigationsActions; return ( ); }; @@ -30,7 +45,9 @@ HeaderNavigations.propTypes = { headerNavigationsActions: PropTypes.shape({ handleViewLive: PropTypes.func.isRequired, handlePreview: PropTypes.func.isRequired, + handleEdit: PropTypes.func.isRequired, }).isRequired, + unitCategory: PropTypes.string.isRequired, }; export default HeaderNavigations; diff --git a/src/course-unit/header-navigations/HeaderNavigations.test.jsx b/src/course-unit/header-navigations/HeaderNavigations.test.jsx index e5a094247e..724f8b70c9 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.test.jsx +++ b/src/course-unit/header-navigations/HeaderNavigations.test.jsx @@ -1,14 +1,18 @@ import { fireEvent, render } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import HeaderNavigations from './HeaderNavigations'; import messages from './messages'; const handleViewLiveFn = jest.fn(); const handlePreviewFn = jest.fn(); +const handleEditFn = jest.fn(); + const headerNavigationsActions = { handleViewLive: handleViewLiveFn, handlePreview: handlePreviewFn, + handleEdit: handleEditFn, }; const renderComponent = (props) => render( @@ -22,14 +26,14 @@ const renderComponent = (props) => render( describe('', () => { it('render HeaderNavigations component correctly', () => { - const { getByRole } = renderComponent(); + const { getByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.previewButton.defaultMessage })).toBeInTheDocument(); }); - it('calls the correct handlers when clicking buttons', () => { - const { getByRole } = renderComponent(); + it('calls the correct handlers when clicking buttons for unit page', () => { + const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); const viewLiveButton = getByRole('button', { name: messages.viewLiveButton.defaultMessage }); fireEvent.click(viewLiveButton); @@ -38,5 +42,22 @@ describe('', () => { const previewButton = getByRole('button', { name: messages.previewButton.defaultMessage }); fireEvent.click(previewButton); expect(handlePreviewFn).toHaveBeenCalledTimes(1); + + const editButton = queryByRole('button', { name: messages.editButton.defaultMessage }); + expect(editButton).not.toBeInTheDocument(); + }); + + it('calls the correct handlers when clicking buttons for library page', () => { + const { getByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.libraryContent.id }); + + const editButton = getByRole('button', { name: messages.editButton.defaultMessage }); + fireEvent.click(editButton); + expect(handleViewLiveFn).toHaveBeenCalledTimes(1); + + const viewLiveButton = getByRole('button', { name: messages.viewLiveButton.defaultMessage }); + expect(viewLiveButton).not.toBeInTheDocument(); + + const previewButton = getByRole('button', { name: messages.previewButton.defaultMessage }); + expect(previewButton).not.toBeInTheDocument(); }); }); diff --git a/src/course-unit/header-navigations/messages.js b/src/course-unit/header-navigations/messages.ts similarity index 59% rename from src/course-unit/header-navigations/messages.js rename to src/course-unit/header-navigations/messages.ts index 55e60fc965..1a58965085 100644 --- a/src/course-unit/header-navigations/messages.js +++ b/src/course-unit/header-navigations/messages.ts @@ -4,10 +4,17 @@ const messages = defineMessages({ viewLiveButton: { id: 'course-authoring.course-unit.button.view-live', defaultMessage: 'View live version', + description: 'The unit view live button text', }, previewButton: { id: 'course-authoring.course-unit.button.preview', defaultMessage: 'Preview', + description: 'The unit preview button text', + }, + editButton: { + id: 'course-authoring.course-unit.button.preview', + defaultMessage: 'Edit', + description: 'The unit edit button text', }, }); diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx index 0d29404ba6..2219b467e4 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.jsx @@ -9,6 +9,7 @@ import { } from '@openedx/paragon/icons'; import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import { getCourseUnitData } from '../data/selectors'; import { updateQueryPendingStatus } from '../data/slice'; import messages from './messages'; @@ -85,6 +86,10 @@ const HeaderTitle = ({ onClose={closeConfigureModal} onConfigureSubmit={onConfigureSubmit} currentItemData={currentItemData} + isSelfPaced={false} + isXBlockComponent={ + [COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.component.id].includes(currentItemData.category) + } /> {getVisibilityMessage()} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 66182ef1fd..b1b02aa75b 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,8 +1,11 @@ -import { useEffect } from 'react'; +import { useCallback, 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 { useEventListener } from '../generic/hooks'; import { createNewCourseXBlock, fetchCourseUnitQuery, @@ -10,9 +13,9 @@ import { fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, deleteUnitItemQuery, - duplicateUnitItemQuery, - setXBlockOrderListQuery, editCourseUnitVisibilityAndData, + getCourseOutlineInfoQuery, + patchUnitItemQuery, } from './data/thunk'; import { getCourseSectionVertical, @@ -24,33 +27,41 @@ 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); const isLoading = useSelector(getIsLoading); const errorMessage = useSelector(getErrorMessage); const sequenceStatus = useSelector(getSequenceStatus); - const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical); + const { draftPreviewLink, publishedPreviewLink, xblockInfo = {} } = useSelector(getCourseSectionVertical); const courseVerticalChildren = useSelector(getCourseVerticalChildren); const staticFileNotices = useSelector(getStaticFileNotices); 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; - - const unitTitle = courseUnit.metadata?.displayName || ''; + const { displayName: unitTitle, category: unitCategory } = xblockInfo; const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id; const headerNavigationsActions = { @@ -60,14 +71,22 @@ export const useCourseUnit = ({ courseId, blockId }) => { handlePreview: () => { window.open(draftPreviewLink, '_blank'); }, + handleEdit: () => {}, }; const handleTitleEdit = () => { dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen)); }; - const handleConfigureSubmit = (id, isVisible, groupAccess, closeModalFn) => { - dispatch(editCourseUnitVisibilityAndData(id, PUBLISH_TYPES.republish, isVisible, groupAccess, true, blockId)); + const handleConfigureSubmit = (id, isVisible, groupAccess, isDiscussionEnabled, closeModalFn) => { + dispatch(editCourseUnitVisibilityAndData( + id, + PUBLISH_TYPES.republish, + isVisible, + groupAccess, + isDiscussionEnabled, + blockId, + )); closeModalFn(); }; @@ -99,18 +118,48 @@ export const useCourseUnit = ({ courseId, blockId }) => { ); const unitXBlockActions = { + // TODO: use for xblock delete functionality handleDelete: (XBlockId) => { dispatch(deleteUnitItemQuery(blockId, XBlockId)); }, - handleDuplicate: (XBlockId) => { - dispatch(duplicateUnitItemQuery(blockId, XBlockId)); - }, }; - const handleXBlockDragAndDrop = (xblockListIds, restoreCallback) => { - dispatch(setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback)); + 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}`); + }; + + const receiveMessage = useCallback(({ data }) => { + const { payload, type } = data; + + if (type === messageTypes.handleViewXBlockContent) { + const newUnitId = payload.destination.split('/').pop(); + navigate(`/course/${courseId}/container/${newUnitId}/${sequenceId}`); + } + }, []); + + useEventListener('message', receiveMessage); + useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateQueryPendingStatus(true)); @@ -123,12 +172,20 @@ 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, unitTitle, + unitCategory, errorMessage, sequenceStatus, savingStatus, @@ -145,8 +202,13 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleTitleEditSubmit, handleCreateNewCourseXBlock, handleConfigureSubmit, - courseVerticalChildren, - handleXBlockDragAndDrop, 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/constants.ts b/src/course-unit/move-modal/constants.ts new file mode 100644 index 0000000000..dddfb46230 --- /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, +}; + +export const CATEGORIES_KEYS = { + course: 'course', + chapter: 'chapter', + section: 'section', + sequential: 'sequential', + subsection: 'subsection', + vertical: 'vertical', + unit: 'unit', + component: 'component', + split_test: 'split_test', + group: 'group', +}; + +export const CATEGORY_RELATION_MAP = { + course: 'section', + section: 'subsection', + subsection: 'unit', + unit: 'component', +}; + +export const MOVE_DIRECTIONS = { + forward: 'forward', + backward: 'backward', +}; + +export const BASIC_BLOCK_TYPES = [ + CATEGORIES_KEYS.course, + CATEGORIES_KEYS.chapter, + CATEGORIES_KEYS.sequential, + CATEGORIES_KEYS.vertical, +]; diff --git a/src/course-unit/move-modal/hooks.tsx b/src/course-unit/move-modal/hooks.tsx new file mode 100644 index 0000000000..561f7b8934 --- /dev/null +++ b/src/course-unit/move-modal/hooks.tsx @@ -0,0 +1,235 @@ +import { + useCallback, useEffect, useState, useMemo, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { IntlShape } from 'react-intl'; +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_KEYS, CATEGORIES_TEXT, CATEGORY_RELATION_MAP, 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: IntlShape = useIntl(); + const dispatch = useDispatch(); + const { sendMessageToIframe } = useIframe(); + const courseOutlineInfo = useSelector(getCourseOutlineInfo); + const loadingStatus = useSelector(getCourseOutlineInfoLoadingStatus); + + const initialValues = useMemo(() => ({ + childrenInfo: { children: courseOutlineInfo.child_info?.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(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 === undefined) { + 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?.child_info?.children || [], + }, + })); + }, []); + + const getCategoryText = useCallback(() => ( + intl.formatMessage(CATEGORIES_TEXT[state.childrenInfo.category]) || '' + ), [intl, state.childrenInfo.category]); + + const breadcrumbs = getBreadcrumbs(state.visitedAncestors, intl.formatMessage); + + 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: CATEGORY_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 && loadingStatus === RequestStatus.SUCCESSFUL) { + updateParentItemsData(); + } + }, [loadingStatus, isOpenModal, updateParentItemsData]); + + useEffect(() => { + if (isOpenModal && loadingStatus === RequestStatus.SUCCESSFUL) { + updateChildrenItemsData(); + setDisplayedXBlocksCategories(); + enableMoveOperation(state.parentInfo.parent); + } + }, [ + state.parentInfo, isOpenModal, loadingStatus, updateChildrenItemsData, + setDisplayedXBlocksCategories, enableMoveOperation, + ]); + + return { + isLoading: loadingStatus === 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..137c6f5cee --- /dev/null +++ b/src/course-unit/move-modal/index.tsx @@ -0,0 +1,187 @@ +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 { LoadingSpinner } from '../../generic/Loading'; +import { CATEGORIES_KEYS } from './constants'; +import { IUseMoveModalParams, IXBlock, IXBlockInfo } from './interfaces'; +import { useMoveModal } from './hooks'; +import messages from './messages'; + +const MoveModal: FC = ({ + 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 getLoader = useCallback(() => ( +
+ +
+ ), []); + + const getBreadcrumbs = useCallback(() => ( + ( + { label: breadcrumb, 'data-parent-index': index } + ))} + activeLabel={breadcrumbs[breadcrumbs.length - 1]} + clickHandler={({ target }) => handleBreadcrumbsClick(target.dataset.parentIndex)} + /> + ), [isExtraSmall, breadcrumbs, handleBreadcrumbsClick]); + + const getEmptyMessage = useCallback(() => ( +
  • + {intl.formatMessage(messages.moveModalEmptyCategoryText, { + category: parentInfo.category, + categoryText: categoryText.toLowerCase(), + })} +
  • + ), [parentInfo.category, categoryText]); + + const getCategoryIndicator = useCallback(() => ( +
    + + {intl.formatMessage(messages.moveModalCategoryIndicatorAccessibilityText, { categoryText, displayName })} + + +
    + ), [categoryText, displayName]); + + const getCourseStructureItemButton = useCallback((xBlock: IXBlock, index: number) => ( + + ), [currentXBlockParentIds, handleXBlockClick]); + + const getCourseStructureItemSpan = useCallback((xBlock: IXBlock) => ( + + + {xBlock?.display_name} + + {currentXBlockParentIds.includes(xBlock.id) && ( + + {intl.formatMessage(messages.moveModalOutlineItemCurrentComponentLocationText)} + + )} + + ), [currentXBlockParentIds]); + + const getCourseStructureListItem = useCallback((xBlock: IXBlock, index: number) => ( +
  • + {sourceXBlockId !== xBlock.id && (xBlock?.child_info || childrenInfo.category !== CATEGORIES_KEYS.component) + ? getCourseStructureItemButton(xBlock, index) + : getCourseStructureItemSpan(xBlock)} +
  • + ), [sourceXBlockId, childrenInfo.category, getCourseStructureItemButton, getCourseStructureItemSpan]); + + return ( + + + + {intl.formatMessage(messages.moveModalTitle, { displayName })} + + + + {isLoading ? getLoader() : ( + <> + {getBreadcrumbs()} +
    + {getCategoryIndicator()} +
      + {!childrenInfo.children?.length + ? getEmptyMessage() + : childrenInfo.children.map( + (xBlock: IXBlock | IXBlockInfo, index: number) => ( + getCourseStructureListItem(xBlock as IXBlock, index) + ), + )} +
    +
    + + )} +
    + + + + {intl.formatMessage(messages.moveModalCancelButton)} + + + + +
    + ); +}; + +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..023161b54e --- /dev/null +++ b/src/course-unit/move-modal/interfaces.ts @@ -0,0 +1,83 @@ +export interface IXBlockInfo { + id: string; + displayName: string; + child_info?: { + children?: IXBlockInfo[]; + }; + category?: string; + has_children?: boolean; + 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; + child_info?: { + children?: ITreeNode[]; + }; +} + +export interface IAncestor { + category?: string; + display_name?: string; +} + +export interface IXBlockChildInfo { + category?: string; + display_name?: string; + children?: IXBlock[]; +} + +export interface IXBlock { + id: string; + display_name: string; + category: string; + has_children: boolean; + child_info?: 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..bb759e36c4 --- /dev/null +++ b/src/course-unit/move-modal/moveModal.test.tsx @@ -0,0 +1,195 @@ +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 { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { Store } from 'redux'; + +import userEvent from '@testing-library/user-event'; +import initializeStore from '../../store'; +import { getOutlineInfo } from '../data/api'; +import { courseOutlineInfoMock } from '../__mocks__'; +import { executeThunk } from '../../utils'; +import { getCourseOutlineInfoQuery } from '../data/thunk'; +import { IframeProvider } from '../context/iFrameContext'; +import MoveModal from './index'; +import messages from './messages'; + +interface CourseOutlineChildInfo { + category: string; + display_name: string; + children?: ICourseOutlineChild[]; +} + +interface ICourseOutlineChild { + id: string; + display_name: string; + category: string; + has_children: boolean; + video_sharing_enabled: boolean; + video_sharing_options: string; + video_sharing_doc_url: string; + child_info?: CourseOutlineChildInfo; +} + +let store: 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: ICourseOutlineChild[] | any = courseOutlineInfoMock?.child_info?.children || []; +const subsections: ICourseOutlineChild[] = sections[1]?.child_info?.children || []; +const units: ICourseOutlineChild[] = subsections[1]?.child_info?.children || []; +const components: ICourseOutlineChild[] = units[0]?.child_info?.children || []; + +const renderComponent = (props?: any) => render( + + + + + + + , +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + window.scrollTo = scrollToMockFn; + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getOutlineInfo(courseId)) + .reply(200, courseOutlineInfoMock); + await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch); + }); + + it('renders loading indicator correctly', async () => { + axiosMock + .onGet(getOutlineInfo(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(); + }); + + 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.map((section) => ( + expect(getByText(section.display_name)).toBeInTheDocument() + )); + + await waitFor(() => userEvent.click(getByRole('button', { name: new RegExp(sections[1].display_name, 'i') }))); + await waitFor(() => { + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSubsections.defaultMessage), + ).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(sections[1].display_name)).toBeInTheDocument(); + subsections.map((subsection) => ( + expect(getByRole('button', { name: new RegExp(subsection.display_name, 'i') })).toBeInTheDocument() + )); + }); + + await waitFor(() => userEvent.click(getByRole('button', { name: new RegExp(subsections[1].display_name, 'i') }))); + await waitFor(() => { + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsUnits.defaultMessage), + ).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(subsections[1].display_name)).toBeInTheDocument(); + units.map((unit) => ( + expect(getByRole('button', { name: new RegExp(unit.display_name, 'i') })).toBeInTheDocument() + )); + }); + + await waitFor(() => userEvent.click(getByRole('button', { name: new RegExp(units[0].display_name, 'i') }))); + await waitFor(() => { + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsComponents.defaultMessage), + ).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(units[0].display_name)).toBeInTheDocument(); + components.forEach((component) => { + if (component.display_name) { + expect(getByText(component.display_name)).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].display_name, 'i') }))); + await waitFor(() => userEvent.click(getByRole('button', { name: new RegExp(subsections[1].display_name, 'i') }))); + await waitFor(() => userEvent.click(within(breadcrumbs).getByText(sections[1].display_name))); + + await waitFor(() => { + expect( + within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSubsections.defaultMessage), + ).toBeInTheDocument(); + expect(within(breadcrumbs).getByText(sections[1].display_name)).toBeInTheDocument(); + subsections.map((subsection) => ( + expect(getByRole('button', { name: new RegExp(subsection.display_name, '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].display_name, 'i') }))); + await waitFor(() => userEvent.click(getByRole('button', { name: new RegExp(subsections[1].display_name, 'i') }))); + await waitFor(() => userEvent.click(getByRole('button', { name: new RegExp(units[7].display_name, 'i') }))); + + 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..18fea24ead --- /dev/null +++ b/src/course-unit/move-modal/utils.test.ts @@ -0,0 +1,175 @@ +import { CATEGORIES_KEYS } 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', + child_info: { + children: [ + { + id: 'child-1', + child_info: { + children: [ + { + id: 'grandchild-1', + child_info: { + children: [], + }, + }, + { + id: 'grandchild-2', + child_info: { + children: [], + }, + }, + ], + }, + }, + { + id: 'child-2', + child_info: { + 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', + has_children: 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', display_name: 'Chapter 1' }, + { category: 'section', display_name: '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, display_name: 'Course Name' }, + ]; + + const result = getBreadcrumbs(visitedAncestors, mockFormatMessage); + + expect(result).toEqual(['Course Outline']); + expect(mockFormatMessage).toHaveBeenCalledWith(messages.moveModalBreadcrumbsBaseCategory); + }); + + it('returns empty string if display_name is missing', () => { + const visitedAncestors: IAncestor[] = [ + { category: 'chapter', display_name: '' }, + ]; + + 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..99f685cfa4 --- /dev/null +++ b/src/course-unit/move-modal/utils.ts @@ -0,0 +1,122 @@ +import { IntlShape } from 'react-intl'; + +import { BASIC_BLOCK_TYPES, CATEGORIES_KEYS } 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 => { + switch (true) { + case category === CATEGORIES_KEYS.chapter: + return CATEGORIES_KEYS.section; + case category === CATEGORIES_KEYS.sequential: + return CATEGORIES_KEYS.subsection; + case category === CATEGORIES_KEYS.vertical: + return CATEGORIES_KEYS.unit; + default: + return 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.child_info?.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 { has_children: targetParentHasChildren } = targetParentInfo; + + if ( + sourceParentHasChildren + && sourceParentCategory + && !BASIC_BLOCK_TYPES.includes(sourceParentCategory) + ) { + sourceParentCategory = CATEGORIES_KEYS.vertical; + } + + if ( + targetParentHasChildren + && targetParentCategory + && !BASIC_BLOCK_TYPES.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: IntlShape['formatMessage'], +): string[] => { + if (!Array.isArray(visitedAncestors)) { + return []; + } + + return visitedAncestors.map((ancestor) => { + if (ancestor?.category === CATEGORIES_KEYS.course) { + return formatMessage(messages.moveModalBreadcrumbsBaseCategory); + } + + return ancestor?.display_name || ''; + }); +}; diff --git a/src/course-unit/utils.ts b/src/course-unit/utils.ts new file mode 100644 index 0000000000..68adaf191a --- /dev/null +++ b/src/course-unit/utils.ts @@ -0,0 +1,33 @@ +import { createCorrectInternalRoute } from '../utils'; + +/** + * Adapts API URL paths to the application's internal URL format based on predefined conditions. + * + * @param {Object} params - Parameters for URL adaptation. + * @param {string} params.url - The original API URL to transform. + * @param {string} params.courseId - The course ID. + * @param {string} params.sequenceId - The sequence ID. + * @returns {string} - A correctly formatted internal route for the application. + */ +// eslint-disable-next-line import/prefer-default-export +export const adoptCourseSectionUrl = ( + { url, courseId, sequenceId }: { url: string, courseId: string, sequenceId: string }, +): string => { + let newUrl = url; + const urlConditions = [ + { + regex: /^\/container\/(.+)/, + transform: ([, unitId]) => `/course/${courseId}/container/${unitId}/${sequenceId}`, + }, + ]; + + for (const { regex, transform } of urlConditions) { + const match = RegExp(regex).exec(url); + if (match) { + newUrl = transform([match[0], match[1]]); + break; + } + } + + return createCorrectInternalRoute(newUrl); +}; diff --git a/src/course-unit/xblock-container-iframe/hooks.tsx b/src/course-unit/xblock-container-iframe/hooks.tsx new file mode 100644 index 0000000000..1a81e7852a --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks.tsx @@ -0,0 +1,139 @@ +import { + useState, useLayoutEffect, useCallback, useEffect, +} from 'react'; +import { logError } from '@edx/frontend-platform/logging'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { useKeyedState } from '@edx/react-unit-test-utils'; + +import { useEventListener } from '../../generic/hooks'; +import { stateKeys, messageTypes } from '../constants'; + +interface UseIFrameBehaviorParams { + id: string; + iframeUrl: string; + onLoaded?: boolean; +} + +interface UseIFrameBehaviorReturn { + iframeHeight: number; + handleIFrameLoad: () => void; + showError: boolean; + hasLoaded: boolean; +} + +/** + * We discovered an error in Firefox where - upon iframe load - React would cease to call any + * useEffect hooks until the user interacts with the page again. This is particularly confusing + * when navigating between sequences, as the UI partially updates leaving the user in a nebulous + * state. + * + * We were able to solve this error by using a layout effect to update some component state, which + * executes synchronously on render. Somehow this forces React to continue it's lifecycle + * immediately, rather than waiting for user interaction. This layout effect could be anywhere in + * the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's + * a joke) one here so it wouldn't be accidentally removed elsewhere. + * + * If we remove this hook when one of these happens: + * 1. React figures out that there's an issue here and fixes a bug. + * 2. We cease to use an iframe for unit rendering. + * 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug. + * 4. We stop supporting Firefox. + * 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to + * Firefox/React for review, and they kindly help us figure out what in the world is happening + * so we can fix it. + * + * This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If + * we change whether or not the Unit component is re-mounted when the unit ID changes, this may + * become important, as this hook will otherwise only evaluate the useLayoutEffect once. + */ +export const useLoadBearingHook = (id: string): void => { + const setValue = useState(0)[1]; + useLayoutEffect(() => { + setValue(currentValue => currentValue + 1); + }, [id]); +}; + +/** + * Custom hook to manage iframe behavior. + * + * @param {Object} params - The parameters for the hook. + * @param {string} params.id - The unique identifier for the iframe. + * @param {string} params.iframeUrl - The URL of the iframe. + * @param {boolean} [params.onLoaded=true] - Flag to indicate if the iframe has loaded. + * @returns {Object} The state and handlers for the iframe. + * @returns {number} return.iframeHeight - The height of the iframe. + * @returns {Function} return.handleIFrameLoad - The handler for iframe load event. + * @returns {boolean} return.showError - Flag to indicate if there was an error loading the iframe. + * @returns {boolean} return.hasLoaded - Flag to indicate if the iframe has loaded. + */ +export const useIFrameBehavior = ({ + id, + iframeUrl, + onLoaded = true, +}: UseIFrameBehaviorParams): UseIFrameBehaviorReturn => { + // Do not remove this hook. See function description. + useLoadBearingHook(id); + + const [iframeHeight, setIframeHeight] = useKeyedState(stateKeys.iframeHeight, 0); + const [hasLoaded, setHasLoaded] = useKeyedState(stateKeys.hasLoaded, false); + const [showError, setShowError] = useKeyedState(stateKeys.showError, false); + const [windowTopOffset, setWindowTopOffset] = useKeyedState(stateKeys.windowTopOffset, null); + + const receiveMessage = useCallback(({ data }: MessageEvent) => { + const { payload, type } = data; + + if (type === messageTypes.resize) { + setIframeHeight(payload.height); + + if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { + setHasLoaded(true); + } + } else if (type === messageTypes.videoFullScreen) { + // We observe exit from the video xblock fullscreen mode + // and scroll to the previously saved scroll position + if (!payload.open && windowTopOffset !== null) { + window.scrollTo(0, Number(windowTopOffset)); + } + + // We listen for this message from LMS to know when we need to + // save or reset scroll position on toggle video xblock fullscreen mode + setWindowTopOffset(payload.open ? window.scrollY : null); + } else if (data.offset) { + // We listen for this message from LMS to know when the page needs to + // be scrolled to another location on the page. + window.scrollTo(0, data.offset + document.getElementById('unit-iframe')!.offsetTop); + } + }, [ + id, + onLoaded, + hasLoaded, + setHasLoaded, + iframeHeight, + setIframeHeight, + windowTopOffset, + setWindowTopOffset, + ]); + + useEventListener('message', receiveMessage); + + const handleIFrameLoad = () => { + if (!hasLoaded) { + setShowError(true); + logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', { + iframeUrl, + }); + } + }; + + useEffect(() => { + setIframeHeight(0); + setHasLoaded(false); + }, [iframeUrl]); + + return { + iframeHeight, + handleIFrameLoad, + showError, + hasLoaded, + }; +}; diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx new file mode 100644 index 0000000000..761d637750 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -0,0 +1,57 @@ +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'; + +/** + * This offset is necessary to fully display the dropdown actions of the XBlock + * in case the XBlock does not have content inside. + */ +const IFRAME_BOTTOM_OFFSET = 220; + +interface XBlockContainerIframeProps { + blockId: string; +} + +const XBlockContainerIframe: FC = ({ blockId }) => { + const intl = useIntl(); + const iframeRef = useRef(null); + const { setIframeRef } = useIframe(); + + const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`; + + const { iframeHeight } = useIFrameBehavior({ + id: blockId, + iframeUrl, + }); + + useEffect(() => { + setIframeRef(iframeRef); + }, [setIframeRef]); + + return ( +