diff --git a/src/constants.js b/src/constants.js
index 163a16ef84..bf4696d734 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 b235e832b9..0fef91b9e5 100644
--- a/src/course-unit/CourseUnit.jsx
+++ b/src/course-unit/CourseUnit.jsx
@@ -179,9 +179,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 = () => (
@@ -172,6 +191,248 @@ 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('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);
+ // waitFor(() => expect(getByRole('dialog')).not.toBeInTheDocument());
+
+ simulatePostMessageEvent(messageTypes.deleteXBlock, {
+ id: courseVerticalChildrenMock.children[0].block_id,
+ });
+
+ expect(getByRole('dialog')).toBeInTheDocument();
+ userEvent.click(deleteButton);
+ // waitFor(() => expect(getByRole('dialog')).not.toBeInTheDocument());
+ });
+
+ 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();
@@ -874,6 +1135,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,
diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js
index ebadb310b4..747361afa2 100644
--- a/src/course-unit/constants.js
+++ b/src/course-unit/constants.js
@@ -52,8 +52,23 @@ export const messageTypes = {
videoFullScreen: 'plugin.videoFullScreen',
refreshXBlock: 'refreshXBlock',
showMoveXBlockModal: 'showMoveXBlockModal',
+ copyXBlock: 'copyXBlock',
+ manageXBlockAccess: 'manageXBlockAccess',
+ deleteXBlock: 'deleteXBlock',
+ duplicateXBlock: 'duplicateXBlock',
+ refreshPositions: 'refreshPositions',
+ newXBlockEditor: 'newXBlockEditor',
+ currentXBlockId: 'currentXBlockId',
+ toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown',
};
-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',
+};
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..8f560e75a5 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,
@@ -229,7 +227,6 @@ export function deleteUnitItemQuery(itemId, xblockId) {
try {
await deleteUnitItem(xblockId);
- dispatch(deleteXBlock(xblockId));
const { userClipboard } = await getCourseSectionVerticalData(itemId);
dispatch(updateClipboardData(userClipboard));
const courseUnit = await getCourseUnitData(itemId);
@@ -249,12 +246,7 @@ export function duplicateUnitItemQuery(itemId, xblockId) {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
try {
- const { locator } = await duplicateUnitItem(itemId, xblockId);
- const newCourseVerticalChildren = await getCourseVerticalChildren(itemId);
- dispatch(duplicateXBlock({
- newId: locator,
- newCourseVerticalChildren,
- }));
+ await duplicateUnitItem(itemId, xblockId);
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(hideProcessingNotification());
diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx
index 336d986fab..bb3cd3a72c 100644
--- a/src/course-unit/header-title/HeaderTitle.jsx
+++ b/src/course-unit/header-title/HeaderTitle.jsx
@@ -11,6 +11,8 @@ import {
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import { getCourseUnitData } from '../data/selectors';
import { updateQueryPendingStatus } from '../data/slice';
+import { messageTypes } from '../constants';
+import { useIframe } from '../context/hooks';
import messages from './messages';
const HeaderTitle = ({
@@ -26,9 +28,15 @@ const HeaderTitle = ({
const currentItemData = useSelector(getCourseUnitData);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo;
+ const { sendMessageToIframe } = useIframe();
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
+ // TODO: this artificial delay is a temporary solution
+ // to ensure the iframe content is properly refreshed.
+ setTimeout(() => {
+ sendMessageToIframe(messageTypes.refreshXBlock, null);
+ }, 1000);
};
const getVisibilityMessage = () => {
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/sidebar/PublishControls.jsx b/src/course-unit/sidebar/PublishControls.jsx
index 424594f35b..0ef08baf28 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);
@@ -34,6 +36,11 @@ const PublishControls = ({ blockId }) => {
const handleCourseUnitDiscardChanges = () => {
closeDiscardModal();
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges));
+ // TODO: this artificial delay is a temporary solution
+ // to ensure the iframe content is properly refreshed.
+ setTimeout(() => {
+ sendMessageToIframe(messageTypes.refreshXBlock, null);
+ }, 1000);
};
const handleCourseUnitPublish = () => {
diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx
index 761d637750..b74347ac44 100644
--- a/src/course-unit/xblock-container-iframe/index.tsx
+++ b/src/course-unit/xblock-container-iframe/index.tsx
@@ -1,57 +1,183 @@
-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 { copyToClipboard } from '../../generic/data/thunks';
+import { COURSE_BLOCK_NAMES, IFRAME_FEATURE_POLICY } from '../../constants';
+import { messageTypes, COMPONENT_TYPES } from '../constants';
+import { fetchCourseUnitQuery } from '../data/thunk';
import { useIframe } from '../context/hooks';
import { useIFrameBehavior } from './hooks';
import messages from './messages';
-/**
- * This offset is necessary to fully display the dropdown actions of the XBlock
- * in case the XBlock does not have content inside.
- */
-const IFRAME_BOTTOM_OFFSET = 220;
+import {
+ XBlockContainerIframeProps,
+ XBlockDataTypes,
+ MessagePayloadTypes,
+} 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 [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
+ const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
+ const { setIframeRef, sendMessageToIframe } = useIframe();
+ const [currentXBlockId, setCurrentXBlockId] = useState(null);
+ const [currentXBlockData, setCurrentXBlockData] = useState({});
+ const [courseXBlockIframeOffset, setCourseXBlockIframeOffset] = useState(0);
+
+ const iframeUrl = useMemo(() => `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`, [blockId]);
+
+ useEffect(() => {
+ setIframeRef(iframeRef);
+ }, [setIframeRef]);
+
+ useEffect(() => {
+ if (currentXBlockId) {
+ const foundXBlockInfo = courseVerticalChildren?.find(xblock => xblock.blockId === currentXBlockId);
+ if (foundXBlockInfo) {
+ const { name, userPartitionInfo, blockType } = foundXBlockInfo;
+
+ setCurrentXBlockData({
+ category: COURSE_BLOCK_NAMES.component.id,
+ displayName: name,
+ userPartitionInfo,
+ showCorrectness: 'always',
+ blockType,
+ id: currentXBlockId,
+ });
+ }
+ }
+ }, [isConfigureModalOpen, currentXBlockId, courseVerticalChildren]);
+
+ const handleRefreshIframe = useCallback(() => {
+ // TODO: this artificial delay is a temporary solution
+ // to ensure the iframe content is properly refreshed.
+ setTimeout(() => {
+ sendMessageToIframe(messageTypes.refreshXBlock, null);
+ }, 1000);
+ }, [sendMessageToIframe]);
+
+ const handleDuplicateXBlock = useCallback(
+ (xblockData: XBlockDataTypes) => {
+ const duplicateAndNavigate = (blockType: string) => {
+ unitXBlockActions.handleDuplicate(xblockData.id);
+ if ([COMPONENT_TYPES.html, COMPONENT_TYPES.problem, COMPONENT_TYPES.video].includes(blockType)) {
+ navigate(`/course/${courseId}/editor/${blockType}/${xblockData.id}`);
+ }
+ handleRefreshIframe();
+ };
+
+ duplicateAndNavigate(xblockData.blockType);
+ },
+ [unitXBlockActions, courseId, navigate, handleRefreshIframe],
+ );
+
+ const handleRefetchXBlocks = useCallback(() => {
+ // TODO: this artificial delay is a temporary solution
+ // to ensure the iframe content is properly refreshed.
+ setTimeout(() => {
+ dispatch(fetchCourseUnitQuery(blockId));
+ }, 1000);
+ }, [dispatch, blockId]);
+
+ useEffect(() => {
+ const messageHandlers: Record void> = {
+ [messageTypes.deleteXBlock]: openDeleteModal,
+ [messageTypes.manageXBlockAccess]: () => openConfigureModal(),
+ [messageTypes.copyXBlock]: () => dispatch(copyToClipboard(currentXBlockId)),
+ [messageTypes.duplicateXBlock]: () => handleDuplicateXBlock(currentXBlockData),
+ [messageTypes.refreshPositions]: handleRefetchXBlocks,
+ [messageTypes.newXBlockEditor]: ({ url }) => navigate(`/course/${courseId}/editor${url}`),
+ [messageTypes.currentXBlockId]: ({ id }) => setCurrentXBlockId(id),
+ [messageTypes.toggleCourseXBlockDropdown]: ({
+ courseXBlockDropdownHeight,
+ }) => setCourseXBlockIframeOffset(courseXBlockDropdownHeight),
+ };
+
+ const handleMessage = (event: MessageEvent) => {
+ const { type, payload } = event.data || {};
+
+ if (type && messageHandlers[type]) {
+ messageHandlers[type](payload);
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
- const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`;
+ return () => {
+ window.removeEventListener('message', handleMessage);
+ };
+ }, [dispatch, blockId, courseVerticalChildren, currentXBlockId, currentXBlockData]);
const { iframeHeight } = useIFrameBehavior({
id: blockId,
iframeUrl,
});
- useEffect(() => {
- setIframeRef(iframeRef);
- }, [setIframeRef]);
+ const handleDeleteItemSubmit = () => {
+ if (currentXBlockId) {
+ unitXBlockActions.handleDelete(currentXBlockId);
+ closeDeleteModal();
+ handleRefreshIframe();
+ }
+ };
+
+ const onConfigureSubmit = (...args: any[]) => {
+ if (currentXBlockId) {
+ handleConfigureSubmit(currentXBlockId, ...args, closeConfigureModal);
+ handleRefreshIframe();
+ }
+ };
return (
-
+ <>
+
+ {currentXBlockId && (
+
+ )}
+
+ >
);
};
-XBlockContainerIframe.propTypes = {
- blockId: PropTypes.string.isRequired,
-};
-
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/tests/hooks.test.tsx b/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx
index 13b5467622..5afd3fea70 100644
--- a/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx
+++ b/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx
@@ -1,10 +1,9 @@
-import React from 'react';
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 { useIFrameBehavior } from '../hooks';
jest.mock('@edx/react-unit-test-utils', () => ({
useKeyedState: jest.fn(),
@@ -52,32 +51,6 @@ describe('useIFrameBehavior', () => {
expect(result.current.hasLoaded).toBe(false);
});
- it('scrolls to previous position on video fullscreen exit', () => {
- const mockWindowTopOffset = 100;
-
- (useKeyedState as jest.Mock).mockImplementation((key) => {
- if (key === stateKeys.windowTopOffset) {
- return [mockWindowTopOffset, setWindowTopOffset];
- }
- return [null, jest.fn()];
- });
-
- renderHook(() => useIFrameBehavior({ id, iframeUrl }));
-
- const message = {
- data: {
- type: messageTypes.videoFullScreen,
- payload: { open: false },
- },
- };
-
- act(() => {
- window.dispatchEvent(new MessageEvent('message', message));
- });
-
- expect(window.scrollTo).toHaveBeenCalledWith(0, mockWindowTopOffset);
- });
-
it('handles resize message correctly', () => {
renderHook(() => useIFrameBehavior({ id, iframeUrl }));
@@ -153,21 +126,3 @@ describe('useIFrameBehavior', () => {
expect(setHasLoaded).toHaveBeenCalledWith(false);
});
});
-
-describe('useLoadBearingHook', () => {
- it('updates state when id changes', () => {
- const setValue = jest.fn();
- jest.spyOn(React, 'useState').mockReturnValue([0, setValue]);
-
- const { rerender } = renderHook(({ id }) => useLoadBearingHook(id), {
- initialProps: { id: 'initial-id' },
- });
-
- setValue.mockClear();
-
- rerender({ id: 'new-id' });
-
- expect(setValue).toHaveBeenCalledWith(expect.any(Function));
- expect(setValue.mock.calls);
- });
-});
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..bb7f05201e
--- /dev/null
+++ b/src/course-unit/xblock-container-iframe/types.ts
@@ -0,0 +1,62 @@
+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 interface XBlockDataTypes {
+ id: string | null;
+ blockType: string;
+}
+
+export interface XBlock {
+ name: string;
+ blockId: string;
+ blockType: string;
+ userPartitionInfo: {
+ selectablePartitions: any[];
+ selectedPartitionIndex: number;
+ selectedGroupsLabel: string;
+ };
+ userPartitions: Array;
+ upstreamLink: string | null;
+ actions: XBlockActions;
+ validationMessages: any[];
+ renderError: string;
+ id: string;
+}
+
+export interface UserPartition {
+ id: number;
+ name: string;
+ scheme: string;
+ groups: Array;
+}
+
+export interface Group {
+ id: number;
+ name: string;
+ selected: boolean;
+ deleted: boolean;
+}
+
+export interface XBlockActions {
+ canCopy: boolean;
+ canDuplicate: boolean;
+ canMove: boolean;
+ canManageAccess: boolean;
+ canDelete: boolean;
+ canManageTags: boolean;
+}
+
+export type MessagePayloadTypes = {
+ id: string;
+ url?: string;
+ message?: string;
+ courseXBlockDropdownHeight: number;
+};
diff --git a/src/editors/hooks.ts b/src/editors/hooks.ts
index d2a7403d87..a7d0aa856b 100644
--- a/src/editors/hooks.ts
+++ b/src/editors/hooks.ts
@@ -13,6 +13,9 @@ export const initializeApp = ({ dispatch, data }) => useEffect(
);
export const navigateTo = (destination: string | URL) => {
+ // TODO: once the "Remove backend redirects (use SPA functionality)" PR (#1372) is merged,
+ // the editor will utilize SPA functionality, allowing navigation back
+ // to the course unit page without a full page reload.
window.location.assign(destination);
};