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 +46,13 @@ const CourseUnit = ({ courseId }) => { isLoading, sequenceId, unitTitle, + unitCategory, errorMessage, sequenceStatus, savingStatus, isTitleEditFormOpen, staticFileNotices, currentlyVisibleToStudents, - unitXBlockActions, sharedClipboardData, showPasteXBlock, showPasteUnit, @@ -55,22 +61,37 @@ const CourseUnit = ({ courseId }) => { handleTitleEdit, handleCreateNewCourseXBlock, handleConfigureSubmit, - courseVerticalChildren, - handleXBlockDragAndDrop, canPasteComponent, + isMoveModalOpen, + openMoveModal, + closeMoveModal, + movedXBlockParams, + handleRollbackMovedXBlock, + handleCloseXBlockMovedAlert, + handleNavigateToTargetUnit, + addComponentTemplateData, + setAddComponentTemplateData, + handleSubmitAddComponentModal, } = 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 isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.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 +109,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 && ( + {showPasteXBlock && canPasteComponent && isUnitVerticalType && ( )} + - - - - {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' - && ( - - + {isUnitVerticalType && ( + <> + + + + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + + + + )} + + + + + )} + {isSplitTestType && ( + + )} - - - diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 5a3c203c7a..fa96286310 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -1,9 +1,14 @@ @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"; +@import "./xblock-container-iframe"; + +.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..779385f6c4 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -10,121 +10,210 @@ import ComponentModalView from './add-component-modals/ComponentModalView'; import AddComponentButton from './add-component-btn'; import messages from './messages'; -const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { +const AddComponent = ({ + parentLocator, + handleCreateNewCourseXBlock, + isUnitVerticalType, + handleSubmitAddComponentModal, + addComponentTemplateData, + setAddComponentTemplateData, +}) => { const navigate = useNavigate(); const intl = useIntl(); 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 isRequestedModalView = addComponentTemplateData?.model?.type; + const blockId = addComponentTemplateData.parentLocator || parentLocator; const handleCreateNewXBlock = (type, moduleName) => { + const preventDisplayLoading = isRequestedModalView && !isUnitVerticalType; switch (type) { case COMPONENT_TYPES.discussion: case COMPONENT_TYPES.dragAndDrop: - handleCreateNewCourseXBlock({ type, parentLocator: blockId }); + handleCreateNewCourseXBlock({ + type, + parentLocator: blockId, + }, null, preventDisplayLoading); break; case COMPONENT_TYPES.problem: case COMPONENT_TYPES.video: - handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => { - navigate(`/course/${courseKey}/editor/${type}/${locator}`); - }); + handleCreateNewCourseXBlock( + { + type, + parentLocator: blockId, + }, + ({ courseKey, locator }) => navigate(`/course/${courseKey}/editor/${type}/${locator}`), + preventDisplayLoading, + ); break; // TODO: The library functional will be a bit different of current legacy (CMS) // behaviour and this ticket is on hold (blocked by other development team). case COMPONENT_TYPES.library: - handleCreateNewCourseXBlock({ type, category: 'library_content', parentLocator: blockId }); + handleCreateNewCourseXBlock( + { + type, + category: 'library_content', + parentLocator: blockId, + }, + null, + preventDisplayLoading, + ); break; case COMPONENT_TYPES.advanced: - handleCreateNewCourseXBlock({ - type: moduleName, category: moduleName, parentLocator: blockId, - }); + handleCreateNewCourseXBlock( + { + type: moduleName, + category: moduleName, + parentLocator: blockId, + }, + null, + preventDisplayLoading, + ); break; case COMPONENT_TYPES.openassessment: - handleCreateNewCourseXBlock({ - boilerplate: moduleName, category: type, parentLocator: blockId, - }); + handleCreateNewCourseXBlock( + { + boilerplate: moduleName, + category: + type, + parentLocator: blockId, + }, + null, + preventDisplayLoading, + ); break; case COMPONENT_TYPES.html: - handleCreateNewCourseXBlock({ - type, - boilerplate: moduleName, - parentLocator: blockId, - }, ({ courseKey, locator }) => { - navigate(`/course/${courseKey}/editor/html/${locator}`); - }); + handleCreateNewCourseXBlock( + { + type, + boilerplate: moduleName, + parentLocator: blockId, + }, + ({ courseKey, locator }) => navigate(`/course/${courseKey}/editor/html/${locator}`), + preventDisplayLoading, + ); break; default: } + if (preventDisplayLoading) { + handleSubmitAddComponentModal(); + } }; - if (!Object.keys(componentTemplates).length) { - return null; + if (isRequestedModalView && !isUnitVerticalType) { + return ( + {}, + close: () => setAddComponentTemplateData({}), + isOpen: addComponentTemplateData.model, + }} + /> + ); } - return ( -
-
{intl.formatMessage(messages.title)}
-
    - {componentTemplates.map((component) => { - const { type, displayName } = component; - let modalParams; + if (Object.keys(componentTemplates).length && isUnitVerticalType) { + return ( +
    +
    {intl.formatMessage(messages.title)}
    +
      + {componentTemplates.map((component) => { + const { type, displayName } = component; + let modalParams; + + if (!component.templates.length) { + return null; + } - if (!component.templates.length) { - return null; - } + switch (type) { + case COMPONENT_TYPES.advanced: + modalParams = { + open: openAdvanced, + close: closeAdvanced, + isOpen: isOpenAdvanced, + }; + break; + case COMPONENT_TYPES.html: + modalParams = { + open: openHtml, + close: closeHtml, + isOpen: isOpenHtml, + }; + break; + case COMPONENT_TYPES.openassessment: + modalParams = { + open: openOpenAssessment, + close: closeOpenAssessment, + isOpen: isOpenOpenAssessment, + }; + break; + default: + return ( +
    • + handleCreateNewXBlock(type)} + displayName={displayName} + type={type} + /> +
    • + ); + } - switch (type) { - case COMPONENT_TYPES.advanced: - modalParams = { - open: openAdvanced, - close: closeAdvanced, - isOpen: isOpenAdvanced, - }; - break; - case COMPONENT_TYPES.html: - modalParams = { - open: openHtml, - close: closeHtml, - isOpen: isOpenHtml, - }; - break; - case COMPONENT_TYPES.openassessment: - modalParams = { - open: openOpenAssessment, - close: closeOpenAssessment, - isOpen: isOpenOpenAssessment, - }; - break; - default: - return ( -
    • - handleCreateNewXBlock(type)} - displayName={displayName} - type={type} - /> -
    • - ); - } + return ( + + ); + })} +
    +
    + ); + } + + return null; +}; - return ( - - ); - })} -
-
- ); +AddComponent.defaultProps = { + addComponentTemplateData: {}, + setAddComponentTemplateData: () => {}, }; AddComponent.propTypes = { - blockId: PropTypes.string.isRequired, + isUnitVerticalType: PropTypes.bool.isRequired, + parentLocator: PropTypes.string.isRequired, handleCreateNewCourseXBlock: PropTypes.func.isRequired, + addComponentTemplateData: { + blockId: PropTypes.string.isRequired, + model: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + category: PropTypes.string, + type: PropTypes.string.isRequired, + templates: PropTypes.arrayOf( + PropTypes.shape({ + boilerplateName: PropTypes.string, + category: PropTypes.string, + displayName: PropTypes.string.isRequired, + supportLevel: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + }), + ), + supportLegend: PropTypes.shape({ + allowUnsupportedXblocks: PropTypes.bool, + documentationLabel: PropTypes.string, + showLegend: PropTypes.bool, + }), + }), + }, + setAddComponentTemplateData: PropTypes.func, + handleSubmitAddComponentModal: PropTypes.func.isRequired, }; export default AddComponent; diff --git a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx index 4f387d4bd6..bb3946350b 100644 --- a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx +++ b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx @@ -14,13 +14,14 @@ const ComponentModalView = ({ component, modalParams, handleCreateNewXBlock, + isRequestedModalView, }) => { const intl = useIntl(); const dispatch = useDispatch(); const [moduleTitle, setModuleTitle] = useState(''); const { open, close, isOpen } = modalParams; const { - type, displayName, templates, supportLegend, + type, displayName, templates = [], supportLegend, } = component; const supportLabels = getXBlockSupportMessages(intl); @@ -30,15 +31,19 @@ const ComponentModalView = ({ setModuleTitle(''); }; + const renderAddComponentButton = () => ( +
  • + +
  • + ); + return ( <> -
  • - -
  • + {!isRequestedModalView && renderAddComponentButton()} { +const Breadcrumbs = ({ courseId, sequenceId }) => { const intl = useIntl(); - const { ancestorXblocks } = useSelector(getCourseSectionVertical); - const [section, subsection] = ancestorXblocks ?? []; + const { ancestorXblocks = [] } = useSelector(getCourseSectionVertical); + + const hasChildWithUrl = (children = []) => !!children.filter((child) => child.url).length; return ( - ); }; +Breadcrumbs.propTypes = { + courseId: PropTypes.string.isRequired, + sequenceId: PropTypes.string.isRequired, +}; + export default Breadcrumbs; diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.scss b/src/course-unit/breadcrumbs/Breadcrumbs.scss index d816b76c90..42ba106fca 100644 --- a/src/course-unit/breadcrumbs/Breadcrumbs.scss +++ b/src/course-unit/breadcrumbs/Breadcrumbs.scss @@ -3,7 +3,7 @@ background: transparent; } - .sub-header-title .sub-header-breadcrumbs { + .sub-header-breadcrumbs { .dropdown-toggle::after { display: none; } diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index 9ff040d63c..6c58c27b37 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -38,3 +38,24 @@ 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', + showComponentTemplates: 'showComponentTemplates', +}; + +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..6d3f5657c3 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)); @@ -144,11 +157,12 @@ export function editCourseUnitVisibilityAndData(itemId, type, isVisible, groupAc }; } -export function createNewCourseXBlock(body, callback, blockId) { +export function createNewCourseXBlock(body, callback, blockId, preventDisplayLoading) { return async (dispatch) => { - dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS })); dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - + if (!preventDisplayLoading) { + dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS })); + } if (body.stagedContent) { dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting)); } else { @@ -174,8 +188,10 @@ export function createNewCourseXBlock(body, callback, blockId) { const courseVerticalChildrenData = await getCourseVerticalChildren(blockId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); - dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + if (!preventDisplayLoading) { + dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.SUCCESSFUL })); + } if (callback) { callback(result); } @@ -186,7 +202,9 @@ export function createNewCourseXBlock(body, callback, blockId) { }); } catch (error) { dispatch(hideProcessingNotification()); - dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.FAILED })); + if (!preventDisplayLoading) { + dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.FAILED })); + } handleResponseErrors(error, dispatch, updateSavingStatus); } }; @@ -206,6 +224,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 +232,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 +245,55 @@ 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 })); + const courseUnit = await getCourseUnitData(currentParentLocator); + dispatch(fetchCourseItemSuccess(courseUnit)); + 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..5f0d958599 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, category }) => { 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, + category: 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..30e8146e58 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'; @@ -25,7 +26,13 @@ const HeaderTitle = ({ const [titleValue, setTitleValue] = useState(unitTitle); const currentItemData = useSelector(getCourseUnitData); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); - const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo; + const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo ?? {}; + + const isXBlockComponent = [ + COURSE_BLOCK_NAMES.libraryContent.id, + COURSE_BLOCK_NAMES.splitTest.id, + COURSE_BLOCK_NAMES.component.id, + ].includes(currentItemData.category); const onConfigureSubmit = (...arg) => { handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal); @@ -85,6 +92,8 @@ const HeaderTitle = ({ onClose={closeConfigureModal} onConfigureSubmit={onConfigureSubmit} currentItemData={currentItemData} + isSelfPaced={false} + isXBlockComponent={isXBlockComponent} /> {getVisibilityMessage()} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 66182ef1fd..221587a920 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,8 +1,12 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useToggle } from '@openedx/paragon'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; import { RequestStatus } from '../data/constants'; +import { useCopyToClipboard } from '../generic/clipboard'; +import { useEventListener } from '../generic/hooks'; import { createNewCourseXBlock, fetchCourseUnitQuery, @@ -10,9 +14,9 @@ import { fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, deleteUnitItemQuery, - duplicateUnitItemQuery, - setXBlockOrderListQuery, editCourseUnitVisibilityAndData, + getCourseOutlineInfoQuery, + patchUnitItemQuery, } from './data/thunk'; import { getCourseSectionVertical, @@ -24,33 +28,42 @@ 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 [addComponentTemplateData, setAddComponentTemplateData] = useState({}); + 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 +73,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(); }; @@ -94,23 +115,66 @@ export const useCourseUnit = ({ courseId, blockId }) => { } }; - const handleCreateNewCourseXBlock = (body, callback) => ( - dispatch(createNewCourseXBlock(body, callback, blockId)) + const handleCreateNewCourseXBlock = (body, callback, preventDisplayLoading) => ( + dispatch(createNewCourseXBlock(body, callback, blockId, preventDisplayLoading)) ); 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 handleSubmitAddComponentModal = () => { + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + setAddComponentTemplateData({}); + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); + }; + + const receiveMessage = useCallback(({ data }) => { + const { payload, type } = data; + + if (type === messageTypes.handleViewXBlockContent) { + const newUnitId = payload.destination.split('/').pop(); + navigate(`/course/${courseId}/container/${newUnitId}/${sequenceId}`); + } + + if (type === messageTypes.showComponentTemplates) { + setAddComponentTemplateData(camelCaseObject(payload)); + } + }, []); + + useEventListener('message', receiveMessage); + useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateQueryPendingStatus(true)); @@ -123,12 +187,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 +217,16 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleTitleEditSubmit, handleCreateNewCourseXBlock, handleConfigureSubmit, - courseVerticalChildren, - handleXBlockDragAndDrop, canPasteComponent, + isMoveModalOpen, + openMoveModal, + closeMoveModal, + handleRollbackMovedXBlock, + handleCloseXBlockMovedAlert, + movedXBlockParams, + handleNavigateToTargetUnit, + addComponentTemplateData, + setAddComponentTemplateData, + handleSubmitAddComponentModal, }; }; 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/sidebar/Sidebar.scss b/src/course-unit/sidebar/Sidebar.scss index f60bfe0879..3aea46c49a 100644 --- a/src/course-unit/sidebar/Sidebar.scss +++ b/src/course-unit/sidebar/Sidebar.scss @@ -81,4 +81,19 @@ text-decoration: line-through; } } + + .course-split-test-sidebar { + padding: $spacer; + + @extend %base-font-params; + + .course-split-test-sidebar-title { + font-size: $font-size-base; + line-height: $line-height-base; + } + + .course-split-test-sidebar-devider { + width: 100%; + } + } } diff --git a/src/course-unit/sidebar/SplitTestSidebarInfo.tsx b/src/course-unit/sidebar/SplitTestSidebarInfo.tsx new file mode 100644 index 0000000000..8d09b76d1e --- /dev/null +++ b/src/course-unit/sidebar/SplitTestSidebarInfo.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Card, Stack } from '@openedx/paragon'; +import { Link } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const SplitTestSidebarInfo = () => { + const intl = useIntl(); + const boldTagWrapper = (chunks: React.ReactNode) => {chunks}; + + return ( + + +

    + {intl.formatMessage(messages.sidebarSplitTestAddComponentTitle)} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestSelectComponentType, { bold_tag: boldTagWrapper })} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestComponentAdded)} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestEditComponentTitle)} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestEditComponentInstruction, { bold_tag: boldTagWrapper })} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestReorganizeComponentTitle)} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestReorganizeComponentInstruction)} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestReorganizeGroupsInstruction)} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestExperimentComponentTitle)} +

    +

    + {intl.formatMessage(messages.sidebarSplitTestExperimentComponentInstruction)} +

    +
    + + {intl.formatMessage(messages.sidebarSplitTestLearnMoreLinkLabel)} + +
    +
    + ); +}; + +SplitTestSidebarInfo.propTypes = {}; + +export default SplitTestSidebarInfo; diff --git a/src/course-unit/sidebar/messages.js b/src/course-unit/sidebar/messages.js index 7d9d161d5c..a0ab9ac39e 100644 --- a/src/course-unit/sidebar/messages.js +++ b/src/course-unit/sidebar/messages.js @@ -137,6 +137,61 @@ const messages = defineMessages({ id: 'course-authoring.course-unit.modal.make-visibility.description', defaultMessage: 'If the unit was previously published and released to students, any changes you made to the unit when it was hidden will now be visible to students. Do you want to proceed?', }, + sidebarSplitTestAddComponentTitle: { + id: 'course-authoring.course-unit.split-test.sidebar.add-component.title', + defaultMessage: 'Adding components', + description: 'Title for the section that explains how to add components to a split test', + }, + sidebarSplitTestSelectComponentType: { + id: 'course-authoring.course-unit.split-test.sidebar.add-component.select-type', + defaultMessage: 'Select a component type under {bold_tag}Add New Component{bold_tag}. Then select a template.', + description: 'Instruction text for selecting a component type and template when adding new components', + }, + sidebarSplitTestComponentAdded: { + id: 'course-authoring.course-unit.split-test.sidebar.add-component.component-added', + defaultMessage: 'The new component is added at the bottom of the page or group. You can then edit and move the component.', + description: 'Instruction text indicating that the component has been added and can be moved or edited', + }, + sidebarSplitTestEditComponentTitle: { + id: 'course-authoring.course-unit.split-test.sidebar.edit-component.title', + defaultMessage: 'Editing components', + description: 'Title for the section that explains how to edit components in a split test', + }, + sidebarSplitTestEditComponentInstruction: { + id: 'course-authoring.course-unit.split-test.sidebar.edit-component.instruction', + defaultMessage: 'Click the {bold_tag}Edit{bold_tag} icon in a component to edit its content.', + description: 'Instruction text for editing a component by clicking the edit icon', + }, + sidebarSplitTestReorganizeComponentTitle: { + id: 'course-authoring.course-unit.split-test.sidebar.reorganize-component.title', + defaultMessage: 'Reorganizing components', + description: 'Title for the section that explains how to reorganize components within a split test', + }, + sidebarSplitTestReorganizeComponentInstruction: { + id: 'course-authoring.course-unit.split-test.sidebar.reorganize-component.instruction', + defaultMessage: 'Drag components to new locations within this component.', + description: 'Instruction text for reorganizing components by dragging them to new locations within a split test', + }, + sidebarSplitTestReorganizeGroupsInstruction: { + id: 'course-authoring.course-unit.split-test.sidebar.reorganize-component.drag-to-groups', + defaultMessage: 'For content experiments, you can drag components to other groups.', + description: 'Instruction text for dragging components to other groups for content experiments', + }, + sidebarSplitTestExperimentComponentTitle: { + id: 'course-authoring.course-unit.split-test.sidebar.experiment-component.title', + defaultMessage: 'Working with content experiments', + description: 'Title for the section that explains how to work with content experiments', + }, + sidebarSplitTestExperimentComponentInstruction: { + id: 'course-authoring.course-unit.split-test.sidebar.experiment-component.confirm-config', + defaultMessage: 'Confirm that you have properly configured content in each of your experiment groups.', + description: 'Instruction text reminding users to check content configuration in each experiment group', + }, + sidebarSplitTestLearnMoreLinkLabel: { + id: 'course-authoring.course-unit.split-test.sidebar.learn-more-link.label', + defaultMessage: 'Learn more about component containers', + description: 'Text for a link that directs users to more information about component containers in the split test setup.', + }, }); export default messages; 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.scss b/src/course-unit/xblock-container-iframe/index.scss new file mode 100644 index 0000000000..5624c0e984 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/index.scss @@ -0,0 +1,4 @@ +.xblock-container-iframe { + width: calc(100% + ($spacer * .3125)); + margin: 0 (-($spacer * .3125)); +} 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..76b0673c99 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -0,0 +1,58 @@ +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 ( +