diff --git a/.gitignore b/.gitignore index 925d1768a4..810bfdb938 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .eslintcache .idea +.run node_modules npm-debug.log coverage diff --git a/src/constants.js b/src/constants.js index 163a16ef84..411d3f2486 100644 --- a/src/constants.js +++ b/src/constants.js @@ -76,3 +76,7 @@ export const REGEX_RULES = { specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/, noSpaceRule: /^\S*$/, }; + +export const IFRAME_FEATURE_POLICY = ( + 'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *' +); diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 4c54d6775e..b9cdff5877 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -180,9 +180,11 @@ const CourseUnit = ({ courseId }) => { /> )} clipboardBroadcastChannelMock); +/** + * Simulates receiving a post message event for testing purposes. + * This can be used to mimic events like deletion or other actions + * sent from Backbone or other sources via postMessage. + * + * @param {string} type - The type of the message event (e.g., 'deleteXBlock'). + * @param {Object} payload - The payload data for the message event. + */ +function simulatePostMessageEvent(type, payload) { + const messageEvent = new MessageEvent('message', { + data: { type, payload }, + }); + + window.dispatchEvent(messageEvent); +} + const RootWrapper = () => ( @@ -175,6 +201,259 @@ describe('', () => { }); }); + it('renders the course unit iframe with correct attributes', async () => { + const { getByTitle } = render(); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`); + expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY); + expect(iframe).toHaveAttribute('style', 'width: 100%; height: 0px;'); + expect(iframe).toHaveAttribute('scrolling', 'no'); + expect(iframe).toHaveAttribute('referrerpolicy', 'origin'); + expect(iframe).toHaveAttribute('loading', 'lazy'); + expect(iframe).toHaveAttribute('frameborder', '0'); + }); + }); + + it('adjusts iframe height dynamically based on courseXBlockDropdownHeight postMessage event', async () => { + const { getByTitle } = render(); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute('style', 'width: 100%; height: 0px;'); + simulatePostMessageEvent(messageTypes.toggleCourseXBlockDropdown, { + courseXBlockDropdownHeight: 200, + }); + expect(iframe).toHaveAttribute('style', 'width: 100%; height: 200px;'); + }); + }); + + it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { + const { + getByTitle, getByText, queryByRole, getAllByRole, getByRole, + } = render(); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.deleteXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + expect(getByText(/Delete this component?/i)).toBeInTheDocument(); + expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); + + expect(getByRole('dialog')).toBeInTheDocument(); + + // Find the Cancel and Delete buttons within the iframe by their specific classes + const cancelButton = getAllByRole('button', { name: /Cancel/i }) + .find(({ classList }) => classList.contains('btn-tertiary')); + const deleteButton = getAllByRole('button', { name: /Delete/i }) + .find(({ classList }) => classList.contains('btn-primary')); + + userEvent.click(cancelButton); + + simulatePostMessageEvent(messageTypes.deleteXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + expect(getByRole('dialog')).toBeInTheDocument(); + userEvent.click(deleteButton); + }); + + 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(); + }); + + axiosMock + .onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id)) + .replyOnce(200, { dummy: 'value' }); + await executeThunk(deleteUnitItemQuery(courseId, blockId), store.dispatch); + + const updatedCourseVerticalChildren = courseVerticalChildrenMock.children.filter( + child => child.block_id !== courseVerticalChildrenMock.children[0].block_id, + ); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + children: updatedCourseVerticalChildren, + isPublished: false, + canPasteComponent: true, + }); + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + // 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 () => { + const { + getByTitle, getByRole, getByText, queryByRole, + } = render(); + + simulatePostMessageEvent(messageTypes.duplicateXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + axiosMock + .onPost(postXBlockBaseApiUrl({ + parent_locator: blockId, + duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, + })) + .replyOnce(200, { locator: '1234567890' }); + + const updatedCourseVerticalChildren = [ + ...courseVerticalChildrenMock.children, + { + ...courseVerticalChildrenMock.children[0], + name: 'New Cloned XBlock', + }, + ]; + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + ...courseVerticalChildrenMock, + children: updatedCourseVerticalChildren, + }); + + await waitFor(() => { + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.duplicateXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + 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(); + }); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + + // 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('handles CourseUnit header action buttons', async () => { const { open } = window; window.open = jest.fn(); @@ -877,6 +1156,77 @@ describe('', () => { .toHaveBeenCalledWith(`/course/${courseId}/container/${blockId}/${updatedAncestorsChild.id}`, { replace: true }); }); + it('should increase the number of course XBlocks after copying and pasting a block', async () => { + const { getByRole, getByTitle } = render(); + + simulatePostMessageEvent(messageTypes.copyXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + 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(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.copyXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + const updatedCourseVerticalChildren = [ + ...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: '', + }, + }, + ]; + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + ...courseVerticalChildrenMock, + children: updatedCourseVerticalChildren, + }); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + }); + }); + it('displays a notification about new files after pasting a component', async () => { const { queryByTestId, getByTestId, getByRole, @@ -1324,4 +1674,147 @@ describe('', () => { ); }); }); + + describe('XBlock restrict access', () => { + it('opens xblock restrict access modal successfully', () => { + const { + getByTitle, getByTestId, + } = render(); + + const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage; + const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage; + const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage; + + waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const usageId = courseVerticalChildrenMock.children[0].block_id; + expect(iframe).toBeInTheDocument(); + + simulatePostMessageEvent(messageTypes.manageXBlockAccess, { + usageId, + }); + }); + + waitFor(() => { + const configureModal = getByTestId('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 xblock restrict access modal when cancel button is clicked', async () => { + const { + getByTitle, queryByTestId, getByTestId, + } = render(); + + waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toBeInTheDocument(); + simulatePostMessageEvent(messageTypes.manageXBlockAccess, { + usageId: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + waitFor(() => { + const configureModal = getByTestId('configure-modal'); + expect(configureModal).toBeInTheDocument(); + userEvent.click(within(configureModal).getByRole('button', { + name: configureModalMessages.cancelButton.defaultMessage, + })); + expect(handleConfigureSubmitMock).not.toHaveBeenCalled(); + }); + + expect(queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + + it('handles submit xblock 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 { + getByTitle, getByRole, getByTestId, + } = render(); + + const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name; + const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name; + + waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toBeInTheDocument(); + simulatePostMessageEvent(messageTypes.manageXBlockAccess, { + usageId: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + waitFor(() => { + const configureModal = getByTestId('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); + expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => { + const { getByTitle } = render(); + const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock)); + const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id; + + updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children + .map((child) => (child.block_id === targetBlockId + ? { ...child, block_type: 'html' } + : child)); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, updatedCourseVerticalChildrenMock); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toBeInTheDocument(); + simulatePostMessageEvent(messageTypes.currentXBlockId, { + id: targetBlockId, + }); + }); + + waitFor(() => { + simulatePostMessageEvent(messageTypes.duplicateXBlock, {}); + simulatePostMessageEvent(messageTypes.newXBlockEditor, {}); + expect(mockedUsedNavigate) + .toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true }); + }); + }); }); diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index 129fa55d9b..b260d289fa 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -52,11 +52,37 @@ export const messageTypes = { videoFullScreen: 'plugin.videoFullScreen', refreshXBlock: 'refreshXBlock', showMoveXBlockModal: 'showMoveXBlockModal', + completeXBlockMoving: 'completeXBlockMoving', + rollbackMovedXBlock: 'rollbackMovedXBlock', showMultipleComponentPicker: 'showMultipleComponentPicker', addSelectedComponentsToBank: 'addSelectedComponentsToBank', showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview', + copyXBlock: 'copyXBlock', + manageXBlockAccess: 'manageXBlockAccess', + completeManageXBlockAccess: 'completeManageXBlockAccess', + deleteXBlock: 'deleteXBlock', + completeXBlockDeleting: 'completeXBlockDeleting', + duplicateXBlock: 'duplicateXBlock', + completeXBlockDuplicating: 'completeXBlockDuplicating', + newXBlockEditor: 'newXBlockEditor', + toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown', + addXBlock: 'addXBlock', + scrollToXBlock: 'scrollToXBlock', }; -export const IFRAME_FEATURE_POLICY = ( - 'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *' -); +export const COMPONENT_TYPES = { + advanced: 'advanced', + discussion: 'discussion', + library: 'library', + html: 'html', + openassessment: 'openassessment', + problem: 'problem', + video: 'video', + dragAndDrop: 'drag-and-drop-v2', +}; + +export const COMPONENT_TYPES_WITH_NEW_EDITOR = { + html: 'html', + problem: 'problem', + video: 'video', +}; diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index aab66ea260..1755d0960f 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -93,22 +93,6 @@ 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; }, @@ -139,8 +123,6 @@ export const { updateLoadingCourseXblockStatus, updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, - deleteXBlock, - duplicateXBlock, fetchStaticFileNoticesSuccess, updateCourseOutlineInfo, updateCourseOutlineInfoLoadingStatus, diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index a0d421eea3..d7dda6133f 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -34,8 +34,6 @@ import { updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, updateQueryPendingStatus, - deleteXBlock, - duplicateXBlock, fetchStaticFileNoticesSuccess, updateCourseOutlineInfo, updateCourseOutlineInfoLoadingStatus, @@ -128,6 +126,7 @@ export function editCourseUnitVisibilityAndData( isVisible, groupAccess, isDiscussionEnabled, + callback, blockId = itemId, ) { return async (dispatch) => { @@ -145,6 +144,9 @@ export function editCourseUnitVisibilityAndData( isDiscussionEnabled, ).then(async (result) => { if (result) { + if (callback) { + callback(); + } const courseUnit = await getCourseUnitData(blockId); dispatch(fetchCourseItemSuccess(courseUnit)); const courseVerticalChildrenData = await getCourseVerticalChildren(blockId); @@ -162,8 +164,8 @@ export function editCourseUnitVisibilityAndData( export function createNewCourseXBlock(body, callback, blockId) { return async (dispatch) => { - dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS })); - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + // dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS })); + // dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); if (body.stagedContent) { dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting)); @@ -190,8 +192,8 @@ 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 })); + // dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.SUCCESSFUL })); + // dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); if (callback) { callback(result); } @@ -222,14 +224,14 @@ export function fetchCourseVerticalChildrenData(itemId) { }; } -export function deleteUnitItemQuery(itemId, xblockId) { +export function deleteUnitItemQuery(itemId, xblockId, callback) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); try { await deleteUnitItem(xblockId); - dispatch(deleteXBlock(xblockId)); + callback(); const { userClipboard } = await getCourseSectionVerticalData(itemId); dispatch(updateClipboardData(userClipboard)); const courseUnit = await getCourseUnitData(itemId); @@ -243,18 +245,14 @@ export function deleteUnitItemQuery(itemId, xblockId) { }; } -export function duplicateUnitItemQuery(itemId, xblockId) { +export function duplicateUnitItemQuery(itemId, xblockId, callback) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); try { - const { locator } = await duplicateUnitItem(itemId, xblockId); - const newCourseVerticalChildren = await getCourseVerticalChildren(itemId); - dispatch(duplicateXBlock({ - newId: locator, - newCourseVerticalChildren, - })); + const { courseKey, locator } = await duplicateUnitItem(itemId, xblockId); + callback(courseKey, locator); const courseUnit = await getCourseUnitData(itemId); dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(hideProcessingNotification()); @@ -310,7 +308,7 @@ export function patchUnitItemQuery({ dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS })); const courseUnit = await getCourseUnitData(currentParentLocator); dispatch(fetchCourseItemSuccess(courseUnit)); - callbackFn(); + callbackFn(sourceLocator); } catch (error) { handleResponseErrors(error, dispatch, updateSavingStatus); } finally { diff --git a/src/course-unit/header-title/HeaderTitle.test.jsx b/src/course-unit/header-title/HeaderTitle.test.jsx index 7e57c408e0..4383fcf6ca 100644 --- a/src/course-unit/header-title/HeaderTitle.test.jsx +++ b/src/course-unit/header-title/HeaderTitle.test.jsx @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; @@ -60,9 +60,11 @@ describe('', () => { it('render HeaderTitle component correctly', () => { const { getByText, getByRole } = renderComponent(); - expect(getByText(unitTitle)).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + waitFor(() => { + expect(getByText(unitTitle)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + }); }); it('render HeaderTitle with open edit form', () => { @@ -70,18 +72,22 @@ describe('', () => { isTitleEditFormOpen: true, }); - expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + waitFor(() => { + expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle); + expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + }); }); it('calls toggle edit title form by clicking on Edit button', () => { const { getByRole } = renderComponent(); - const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage }); - userEvent.click(editTitleButton); - expect(handleTitleEdit).toHaveBeenCalledTimes(1); + waitFor(() => { + const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage }); + userEvent.click(editTitleButton); + expect(handleTitleEdit).toHaveBeenCalledTimes(1); + }); }); it('calls saving title by clicking outside or press Enter key', async () => { @@ -89,16 +95,18 @@ describe('', () => { isTitleEditFormOpen: true, }); - const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage }); - userEvent.type(titleField, ' 1'); - expect(titleField).toHaveValue(`${unitTitle} 1`); - userEvent.click(document.body); - expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1); - - userEvent.click(titleField); - userEvent.type(titleField, ' 2[Enter]'); - expect(titleField).toHaveValue(`${unitTitle} 1 2`); - expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2); + waitFor(() => { + const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage }); + userEvent.type(titleField, ' 1'); + expect(titleField).toHaveValue(`${unitTitle} 1`); + userEvent.click(document.body); + expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1); + + userEvent.click(titleField); + userEvent.type(titleField, ' 2[Enter]'); + expect(titleField).toHaveValue(`${unitTitle} 1 2`); + expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2); + }); }); it('displays a visibility message with the selected groups for the unit', async () => { @@ -117,7 +125,9 @@ describe('', () => { const visibilityMessage = messages.definedVisibilityMessage.defaultMessage .replace('{selectedGroupsLabel}', 'Visibility group 1'); - expect(getByText(visibilityMessage)).toBeInTheDocument(); + waitFor(() => { + expect(getByText(visibilityMessage)).toBeInTheDocument(); + }); }); it('displays a visibility message with the selected groups for some of xblock', async () => { @@ -130,6 +140,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch); const { getByText } = renderComponent(); - expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument(); + waitFor(() => { + expect(getByText(messages.someVisibilityMessage.defaultMessage)).toBeInTheDocument(); + }); }); }); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 11731cc2ad..7fb8edaffa 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -84,6 +84,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { isVisible, groupAccess, isDiscussionEnabled, + () => sendMessageToIframe(messageTypes.completeManageXBlockAccess, null), blockId, )); closeModalFn(); @@ -112,16 +113,29 @@ export const useCourseUnit = ({ courseId, blockId }) => { } }; - const handleCreateNewCourseXBlock = (body, callback) => ( + const handleCreateNewCourseXBlock = ( + body, + callback = (result) => { + sendMessageToIframe(messageTypes.addXBlock, { data: result }); + }, + ) => ( dispatch(createNewCourseXBlock(body, callback, blockId)) ); const unitXBlockActions = { handleDelete: (XBlockId) => { - dispatch(deleteUnitItemQuery(blockId, XBlockId)); + dispatch(deleteUnitItemQuery( + blockId, + XBlockId, + () => sendMessageToIframe(messageTypes.completeXBlockDeleting, null), + )); }, handleDuplicate: (XBlockId) => { - dispatch(duplicateUnitItemQuery(blockId, XBlockId)); + dispatch(duplicateUnitItemQuery( + blockId, + XBlockId, + (courseKey, locator) => sendMessageToIframe(messageTypes.completeXBlockDuplicating, { courseKey, locator }), + )); }, }; @@ -136,7 +150,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { currentParentLocator, isMoving: false, callbackFn: () => { - sendMessageToIframe(messageTypes.refreshXBlock, null); + sendMessageToIframe(messageTypes.rollbackMovedXBlock, { locator: sourceLocator }); window.scrollTo({ top: 0, behavior: 'smooth' }); }, })); diff --git a/src/course-unit/move-modal/hooks.tsx b/src/course-unit/move-modal/hooks.tsx index 69ad13470c..d21014e3bb 100644 --- a/src/course-unit/move-modal/hooks.tsx +++ b/src/course-unit/move-modal/hooks.tsx @@ -184,8 +184,8 @@ export const useMoveModal = ({ title: state.sourceXBlockInfo.current.displayName, currentParentLocator: blockId, isMoving: true, - callbackFn: () => { - sendMessageToIframe(messageTypes.refreshXBlock, null); + callbackFn: (sourceLocator: string) => { + sendMessageToIframe(messageTypes.completeXBlockMoving, { locator: sourceLocator }); closeModal(); window.scrollTo({ top: 0, behavior: 'smooth' }); }, diff --git a/src/course-unit/sidebar/PublishControls.jsx b/src/course-unit/sidebar/PublishControls.jsx index 424594f35b..d5076aa838 100644 --- a/src/course-unit/sidebar/PublishControls.jsx +++ b/src/course-unit/sidebar/PublishControls.jsx @@ -4,9 +4,10 @@ import { useToggle } from '@openedx/paragon'; import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import useCourseUnitData from './hooks'; +import { useIframe } from '../context/hooks'; import { editCourseUnitVisibilityAndData } from '../data/thunk'; import { SidebarBody, SidebarFooter, SidebarHeader } from './components'; -import { PUBLISH_TYPES } from '../constants'; +import { PUBLISH_TYPES, messageTypes } from '../constants'; import { getCourseUnitData } from '../data/selectors'; import messages from './messages'; import ModalNotification from '../../generic/modal-notification'; @@ -20,6 +21,7 @@ const PublishControls = ({ blockId }) => { visibleToStaffOnly, } = useCourseUnitData(useSelector(getCourseUnitData)); const intl = useIntl(); + const { sendMessageToIframe } = useIframe(); const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); @@ -33,7 +35,14 @@ const PublishControls = ({ blockId }) => { const handleCourseUnitDiscardChanges = () => { closeDiscardModal(); - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges)); + dispatch(editCourseUnitVisibilityAndData( + blockId, + PUBLISH_TYPES.discardChanges, + null, + null, + null, + () => sendMessageToIframe(messageTypes.refreshXBlock, null), + )); }; const handleCourseUnitPublish = () => { diff --git a/src/course-unit/xblock-container-iframe/hooks/index.ts b/src/course-unit/xblock-container-iframe/hooks/index.ts new file mode 100644 index 0000000000..c49993dc1e --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/index.ts @@ -0,0 +1,5 @@ +export { useIframeMessages } from './useIframeMessages'; +export { useIframeContent } from './useIframeContent'; +export { useMessageHandlers } from './useMessageHandlers'; +export { useIFrameBehavior } from './useIFrameBehavior'; +export { useLoadBearingHook } from './useLoadBearingHook'; diff --git a/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx similarity index 97% rename from src/course-unit/xblock-container-iframe/tests/hooks.test.tsx rename to src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx index 13b5467622..8883efb04d 100644 --- a/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx @@ -3,8 +3,8 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useKeyedState } from '@edx/react-unit-test-utils'; import { logError } from '@edx/frontend-platform/logging'; -import { stateKeys, messageTypes } from '../../constants'; -import { useIFrameBehavior, useLoadBearingHook } from '../hooks'; +import { stateKeys, messageTypes } from '../../../constants'; +import { useLoadBearingHook, useIFrameBehavior } from '..'; jest.mock('@edx/react-unit-test-utils', () => ({ useKeyedState: jest.fn(), diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts new file mode 100644 index 0000000000..c759f403f9 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/types.ts @@ -0,0 +1,25 @@ +export type UseMessageHandlersTypes = { + courseId: string; + navigate: (path: string) => void; + dispatch: (action: any) => void; + setIframeOffset: (height: number) => void; + handleDeleteXBlock: (usageId: string) => void; + handleScrollToXBlock: (scrollOffset: number) => void; + handleDuplicateXBlock: (blockType: string, usageId: string) => void; + handleManageXBlockAccess: (usageId: string) => void; +}; + +export type MessageHandlersTypes = Record void>; + +export interface UseIFrameBehaviorTypes { + id: string; + iframeUrl: string; + onLoaded?: boolean; +} + +export interface UseIFrameBehaviorReturnTypes { + iframeHeight: number; + handleIFrameLoad: () => void; + showError: boolean; + hasLoaded: boolean; +} diff --git a/src/course-unit/xblock-container-iframe/hooks.tsx b/src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx similarity index 58% rename from src/course-unit/xblock-container-iframe/hooks.tsx rename to src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx index 1a81e7852a..832ac94cd3 100644 --- a/src/course-unit/xblock-container-iframe/hooks.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx @@ -1,57 +1,12 @@ -import { - useState, useLayoutEffect, useCallback, useEffect, -} from 'react'; +import { 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]); -}; +import { useEventListener } from '../../../generic/hooks'; +import { stateKeys, messageTypes } from '../../constants'; +import { useLoadBearingHook } from './useLoadBearingHook'; +import { UseIFrameBehaviorTypes, UseIFrameBehaviorReturnTypes } from './types'; /** * Custom hook to manage iframe behavior. @@ -70,7 +25,7 @@ export const useIFrameBehavior = ({ id, iframeUrl, onLoaded = true, -}: UseIFrameBehaviorParams): UseIFrameBehaviorReturn => { +}: UseIFrameBehaviorTypes): UseIFrameBehaviorReturnTypes => { // Do not remove this hook. See function description. useLoadBearingHook(id); diff --git a/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx b/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx new file mode 100644 index 0000000000..17507a8241 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx @@ -0,0 +1,20 @@ +import { useEffect, RefObject } from 'react'; + +/** + * Hook for managing iframe content and providing utilities to interact with the iframe. + * + * @param {React.RefObject} iframeRef - A React ref for the iframe element. + * @param {(ref: React.RefObject) => void} setIframeRef - + * A function to associate the iframeRef with the parent context. + * + * @returns {Object} - An object containing utility functions. + * @returns {() => void} + */ +export const useIframeContent = ( + iframeRef: RefObject, + setIframeRef: (ref: RefObject) => void, +): void => { + useEffect(() => { + setIframeRef(iframeRef); + }, [setIframeRef, iframeRef]); +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx b/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx new file mode 100644 index 0000000000..6f192615da --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +/** + * Hook for managing and handling messages received by the iframe. + * + * @param {Record void>} messageHandlers - + * A mapping of message types to their corresponding handler functions. + */ +export const useIframeMessages = (messageHandlers: Record void>) => { + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const { type, payload } = event.data || {}; + if (type in messageHandlers) { + messageHandlers[type](payload); + } + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [messageHandlers]); +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx b/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx new file mode 100644 index 0000000000..73a38b0223 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx @@ -0,0 +1,33 @@ +import { useLayoutEffect, useState } from 'react'; + +/** + * 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]); +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx new file mode 100644 index 0000000000..8d40999304 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; + +import { copyToClipboard } from '../../../generic/data/thunks'; +import { messageTypes } from '../../constants'; +import { MessageHandlersTypes, UseMessageHandlersTypes } from './types'; + +/** + * Hook for creating message handlers used to handle iframe messages. + * + * @param params - The parameters required to create message handlers. + * @returns {MessageHandlersTypes} - An object mapping message types to their handler functions. + */ +export const useMessageHandlers = ({ + courseId, + navigate, + dispatch, + setIframeOffset, + handleDeleteXBlock, + handleDuplicateXBlock, + handleScrollToXBlock, + handleManageXBlockAccess, +}: UseMessageHandlersTypes): MessageHandlersTypes => useMemo(() => ({ + [messageTypes.copyXBlock]: ({ usageId }) => dispatch(copyToClipboard(usageId)), + [messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId), + [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`), + [messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId), + [messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId), + [messageTypes.scrollToXBlock]: ({ scrollOffset }) => handleScrollToXBlock(scrollOffset), + [messageTypes.toggleCourseXBlockDropdown]: ({ + courseXBlockDropdownHeight, + }: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight), +}), [ + courseId, + handleDeleteXBlock, + handleDuplicateXBlock, + handleManageXBlockAccess, + handleScrollToXBlock, +]); diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 761d637750..0bad117b56 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,57 +1,150 @@ -import { useRef, useEffect, FC } from 'react'; -import PropTypes from 'prop-types'; +import { + useRef, FC, useEffect, useState, useMemo, useCallback, +} from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; +import { useToggle } from '@openedx/paragon'; +import { useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; -import { IFRAME_FEATURE_POLICY } from '../constants'; +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; +import { IFRAME_FEATURE_POLICY } from '../../constants'; +import { COMPONENT_TYPES_WITH_NEW_EDITOR } from '../constants'; import { useIframe } from '../context/hooks'; -import { useIFrameBehavior } from './hooks'; +import { + useMessageHandlers, + useIframeContent, + useIframeMessages, + useIFrameBehavior, +} from './hooks'; +import { formatAccessManagedXBlockData, getIframeUrl } from './utils'; 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; +import { + XBlockContainerIframeProps, + AccessManagedXBlockDataTypes, +} from './types'; -interface XBlockContainerIframeProps { - blockId: string; -} - -const XBlockContainerIframe: FC = ({ blockId }) => { +const XBlockContainerIframe: FC = ({ + courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, +}) => { const intl = useIntl(); const iframeRef = useRef(null); - const { setIframeRef } = useIframe(); + const dispatch = useDispatch(); + const navigate = useNavigate(); - const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`; + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const [accessManagedXBlockData, setAccessManagedXBlockData] = useState({}); + const [iframeOffset, setIframeOffset] = useState(0); + const [deleteXBlockId, setDeleteXBlockId] = useState(null); + const [configureXBlockId, setConfigureXBlockId] = useState(null); - const { iframeHeight } = useIFrameBehavior({ - id: blockId, - iframeUrl, - }); + const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]); + + const { setIframeRef } = useIframe(); + const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl }); + + useIframeContent(iframeRef, setIframeRef); useEffect(() => { setIframeRef(iframeRef); }, [setIframeRef]); - return ( -