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 (
-
+ const handleDuplicateXBlock = useCallback(
+ (blockType: string, usageId: string) => {
+ unitXBlockActions.handleDuplicate(usageId);
+ if (COMPONENT_TYPES_WITH_NEW_EDITOR[blockType]) {
+ navigate(`/course/${courseId}/editor/${blockType}/${usageId}`);
+ }
+ },
+ [unitXBlockActions, courseId, navigate],
);
-};
-XBlockContainerIframe.propTypes = {
- blockId: PropTypes.string.isRequired,
+ const handleDeleteXBlock = (usageId: string) => {
+ setDeleteXBlockId(usageId);
+ openDeleteModal();
+ };
+
+ const handleManageXBlockAccess = (usageId: string) => {
+ openConfigureModal();
+ setConfigureXBlockId(usageId);
+ const foundXBlock = courseVerticalChildren?.find(xblock => xblock.blockId === usageId);
+ if (foundXBlock) {
+ setAccessManagedXBlockData(formatAccessManagedXBlockData(foundXBlock, usageId));
+ }
+ };
+
+ const onDeleteSubmit = () => {
+ if (deleteXBlockId) {
+ unitXBlockActions.handleDelete(deleteXBlockId);
+ closeDeleteModal();
+ }
+ };
+
+ const onManageXBlockAccessSubmit = (...args: any[]) => {
+ if (configureXBlockId) {
+ handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal);
+ setAccessManagedXBlockData({});
+ }
+ };
+
+ const handleScrollToXBlock = (scrollOffset: number) => {
+ window.scrollBy({
+ top: scrollOffset,
+ behavior: 'smooth',
+ });
+ };
+
+ const messageHandlers = useMessageHandlers({
+ courseId,
+ navigate,
+ dispatch,
+ setIframeOffset,
+ handleDeleteXBlock,
+ handleDuplicateXBlock,
+ handleManageXBlockAccess,
+ handleScrollToXBlock,
+ });
+
+ useIframeMessages(messageHandlers);
+
+ return (
+ <>
+
+ {Object.keys(accessManagedXBlockData).length ? (
+ {
+ closeConfigureModal();
+ setAccessManagedXBlockData({});
+ }}
+ onConfigureSubmit={onManageXBlockAccessSubmit}
+ currentItemData={accessManagedXBlockData as AccessManagedXBlockDataTypes}
+ isSelfPaced={false}
+ />
+ ) : null}
+
+ >
+ );
};
export default XBlockContainerIframe;
diff --git a/src/course-unit/xblock-container-iframe/messages.ts b/src/course-unit/xblock-container-iframe/messages.ts
index 9ec46b07d5..fa8eebdd1e 100644
--- a/src/course-unit/xblock-container-iframe/messages.ts
+++ b/src/course-unit/xblock-container-iframe/messages.ts
@@ -6,6 +6,10 @@ const messages = defineMessages({
defaultMessage: 'Course unit iframe',
description: 'Title for the xblock iframe',
},
+ xblockIframeLabel: {
+ id: 'course-authoring.course-unit.xblock.iframe.label',
+ defaultMessage: '{xblockCount} xBlocks inside the frame',
+ },
});
export default messages;
diff --git a/src/course-unit/xblock-container-iframe/tests/XblockContainerIframe.test.tsx b/src/course-unit/xblock-container-iframe/tests/XblockContainerIframe.test.tsx
deleted file mode 100644
index b3bee233b8..0000000000
--- a/src/course-unit/xblock-container-iframe/tests/XblockContainerIframe.test.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { render } from '@testing-library/react';
-import { getConfig } from '@edx/frontend-platform';
-import { IntlProvider } from '@edx/frontend-platform/i18n';
-
-import { IFRAME_FEATURE_POLICY } from '../../constants';
-import { useIFrameBehavior } from '../hooks';
-import XBlockContainerIframe from '..';
-import { IframeProvider } from '../../context/iFrameContext';
-
-jest.mock('@edx/frontend-platform', () => ({
- getConfig: jest.fn(),
-}));
-
-jest.mock('../hooks', () => ({
- useIFrameBehavior: jest.fn(),
-}));
-
-describe('', () => {
- const blockId = 'test-block-id';
- const iframeUrl = `http://example.com/container_embed/${blockId}`;
- const iframeHeight = '500px';
-
- beforeEach(() => {
- (getConfig as jest.Mock).mockReturnValue({ STUDIO_BASE_URL: 'http://example.com' });
- (useIFrameBehavior as jest.Mock).mockReturnValue({ iframeHeight });
- });
-
- it('renders correctly with the given blockId', () => {
- const { getByTitle } = render(
-
-
-
-
- ,
- );
- const iframe = getByTitle('Course unit iframe');
-
- expect(iframe).toBeInTheDocument();
- expect(iframe).toHaveAttribute('src', iframeUrl);
- expect(iframe).toHaveAttribute('frameBorder', '0');
- expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
- expect(iframe).toHaveAttribute('allowFullScreen');
- expect(iframe).toHaveAttribute('loading', 'lazy');
- expect(iframe).toHaveAttribute('scrolling', 'no');
- expect(iframe).toHaveAttribute('referrerPolicy', 'origin');
- });
-});
diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts
new file mode 100644
index 0000000000..4806719053
--- /dev/null
+++ b/src/course-unit/xblock-container-iframe/types.ts
@@ -0,0 +1,106 @@
+export interface GroupTypes {
+ id: number;
+ name: string;
+ selected: boolean;
+ deleted: boolean;
+}
+
+export interface UserPartitionTypes {
+ id: number;
+ name: string;
+ scheme: string;
+ groups: Array;
+}
+
+export interface XBlockActionsTypes {
+ canCopy: boolean;
+ canDuplicate: boolean;
+ canMove: boolean;
+ canManageAccess: boolean;
+ canDelete: boolean;
+ canManageTags: boolean;
+}
+
+export interface XBlockTypes {
+ name: string;
+ blockId: string;
+ blockType: string;
+ userPartitionInfo: {
+ selectablePartitions: any[];
+ selectedPartitionIndex: number;
+ selectedGroupsLabel: string;
+ };
+ userPartitions: Array;
+ upstreamLink: string | null;
+ actions: XBlockActionsTypes;
+ validationMessages: any[];
+ renderError: string;
+ id: string;
+}
+
+export interface XBlockContainerIframeProps {
+ courseId: string;
+ blockId: string;
+ unitXBlockActions: {
+ handleDelete: (XBlockId: string | null) => void;
+ handleDuplicate: (XBlockId: string | null) => void;
+ };
+ courseVerticalChildren: Array;
+ handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;
+}
+
+export type UserPartitionInfoTypes = {
+ selectablePartitions: Array<{
+ groups: Array<{
+ deleted: boolean;
+ id: number;
+ name: string;
+ selected: boolean;
+ }>;
+ id: number;
+ name: string;
+ scheme: string;
+ }>;
+ selectedPartitionIndex: number;
+ selectedGroupsLabel: string;
+};
+
+export type PrereqTypes = {
+ blockDisplayName: string;
+ blockUsageKey: string;
+};
+
+export type AccessManagedXBlockDataTypes = {
+ id: string;
+ displayName?: string;
+ start?: string;
+ visibilityState?: string | boolean;
+ blockType: string;
+ due?: string;
+ isTimeLimited?: boolean;
+ defaultTimeLimitMinutes?: number;
+ hideAfterDue?: boolean;
+ showCorrectness?: string | boolean;
+ courseGraders?: string[];
+ category?: string;
+ format?: string;
+ userPartitionInfo?: UserPartitionInfoTypes;
+ ancestorHasStaffLock?: boolean;
+ isPrereq?: boolean;
+ prereqs?: PrereqTypes[];
+ prereq?: number;
+ prereqMinScore?: number;
+ prereqMinCompletion?: number;
+ releasedToStudents?: boolean;
+ wasExamEverLinkedWithExternal?: boolean;
+ isProctoredExam?: boolean;
+ isOnboardingExam?: boolean;
+ isPracticeExam?: boolean;
+ examReviewRules?: string;
+ supportsOnboarding?: boolean;
+ showReviewRules?: boolean;
+ onlineProctoringRules?: string;
+ discussionEnabled: boolean;
+};
+
+export type FormattedAccessManagedXBlockDataTypes = Omit;
diff --git a/src/course-unit/xblock-container-iframe/utils.ts b/src/course-unit/xblock-container-iframe/utils.ts
new file mode 100644
index 0000000000..56bf462834
--- /dev/null
+++ b/src/course-unit/xblock-container-iframe/utils.ts
@@ -0,0 +1,33 @@
+import { getConfig } from '@edx/frontend-platform';
+
+import { COURSE_BLOCK_NAMES } from '../../constants';
+import { FormattedAccessManagedXBlockDataTypes, XBlockTypes } from './types';
+
+/**
+ * Formats the XBlock data into a standardized structure for access management.
+ *
+ * @param {XBlockTypes} xblock - The XBlock object containing the original data.
+ * @param {string} usageId - The unique identifier for the XBlock.
+ *
+ * @returns {FormattedAccessManagedXBlockDataTypes} - The formatted XBlock data, ready for access management operations.
+ */
+export const formatAccessManagedXBlockData = (
+ xblock: XBlockTypes,
+ usageId: string,
+): FormattedAccessManagedXBlockDataTypes => ({
+ category: COURSE_BLOCK_NAMES.component.id,
+ displayName: xblock.name,
+ userPartitionInfo: xblock.userPartitionInfo,
+ showCorrectness: 'always',
+ blockType: xblock.blockType,
+ id: usageId,
+});
+
+/**
+ * Generates the iframe URL for the given block ID.
+ *
+ * @param {string} blockId - The unique identifier of the block.
+ *
+ * @returns {string} - The generated iframe URL.
+ */
+export const getIframeUrl = (blockId: string): string => `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`;
diff --git a/src/generic/clipboard/paste-component/components/PasteButton.jsx b/src/generic/clipboard/paste-component/components/PasteButton.jsx
index a13dc28c6b..173e57ce33 100644
--- a/src/generic/clipboard/paste-component/components/PasteButton.jsx
+++ b/src/generic/clipboard/paste-component/components/PasteButton.jsx
@@ -7,7 +7,7 @@ const PasteButton = ({ onClick, text, className }) => {
const { blockId } = useParams();
const handlePasteXBlockComponent = () => {
- onClick({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId);
+ onClick({ stagedContent: 'clipboard', parentLocator: blockId });
};
return (