feat: [FC-0070] listen to xblock interaction events (#1431)
This is part of the effort to support the new embedded Studio Unit Page. It includes changes to the CourseUnit component and the functionality of interaction between xblocks in the iframe and the react page. The following events have been processed: * delete event * Manage Access event (opening and closing a modal window) * edit event for new xblock React editors * clipboard events * duplicate event
This commit is contained in:
@@ -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 *'
|
||||
);
|
||||
|
||||
@@ -180,9 +180,11 @@ const CourseUnit = ({ courseId }) => {
|
||||
/>
|
||||
)}
|
||||
<XBlockContainerIframe
|
||||
courseId={courseId}
|
||||
blockId={blockId}
|
||||
unitXBlockActions={unitXBlockActions}
|
||||
courseVerticalChildren={courseVerticalChildren.children}
|
||||
handleConfigureSubmit={handleConfigureSubmit}
|
||||
/>
|
||||
<AddComponent
|
||||
blockId={blockId}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './data/api';
|
||||
import {
|
||||
createNewCourseXBlock,
|
||||
deleteUnitItemQuery,
|
||||
editCourseUnitVisibilityAndData,
|
||||
fetchCourseSectionVerticalData,
|
||||
fetchCourseUnitQuery,
|
||||
@@ -41,8 +42,9 @@ import {
|
||||
clipboardMockResponse,
|
||||
courseOutlineInfoMock,
|
||||
} from './__mocks__';
|
||||
import { clipboardUnit } from '../__mocks__';
|
||||
import { clipboardUnit, clipboardXBlock } from '../__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import { IFRAME_FEATURE_POLICY } from '../constants';
|
||||
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
|
||||
import pasteNotificationsMessages from './clipboard/paste-notification/messages';
|
||||
import headerNavigationsMessages from './header-navigations/messages';
|
||||
@@ -58,6 +60,7 @@ import addComponentMessages from './add-component/messages';
|
||||
import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
|
||||
import { IframeProvider } from './context/iFrameContext';
|
||||
import moveModalMessages from './move-modal/messages';
|
||||
import xblockContainerIframeMessages from './xblock-container-iframe/messages';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
@@ -67,6 +70,13 @@ const blockId = '567890';
|
||||
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
const userName = 'openedx';
|
||||
const handleConfigureSubmitMock = jest.fn();
|
||||
|
||||
const {
|
||||
block_id: id,
|
||||
user_partition_info: userPartitionInfo,
|
||||
} = courseVerticalChildrenMock.children[0];
|
||||
const userPartitionInfoFormatted = camelCaseObject(userPartitionInfo);
|
||||
|
||||
const postXBlockBody = {
|
||||
parent_locator: blockId,
|
||||
@@ -114,6 +124,22 @@ const clipboardBroadcastChannelMock = {
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => 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 = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
@@ -175,6 +201,259 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the course unit iframe with correct attributes', async () => {
|
||||
const { getByTitle } = render(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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('<CourseUnit />', () => {
|
||||
.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(<RootWrapper />);
|
||||
|
||||
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('<CourseUnit />', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XBlock restrict access', () => {
|
||||
it('opens xblock restrict access modal successfully', () => {
|
||||
const {
|
||||
getByTitle, getByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,8 +55,11 @@ export const messageTypes = {
|
||||
showMultipleComponentPicker: 'showMultipleComponentPicker',
|
||||
addSelectedComponentsToBank: 'addSelectedComponentsToBank',
|
||||
showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview',
|
||||
copyXBlock: 'copyXBlock',
|
||||
manageXBlockAccess: 'manageXBlockAccess',
|
||||
deleteXBlock: 'deleteXBlock',
|
||||
duplicateXBlock: 'duplicateXBlock',
|
||||
refreshXBlockPositions: 'refreshPositions',
|
||||
newXBlockEditor: 'newXBlockEditor',
|
||||
toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown',
|
||||
};
|
||||
|
||||
export const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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('<HeaderTitle />', () => {
|
||||
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('<HeaderTitle />', () => {
|
||||
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('<HeaderTitle />', () => {
|
||||
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);
|
||||
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);
|
||||
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('<HeaderTitle />', () => {
|
||||
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('<HeaderTitle />', () => {
|
||||
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(getByText(messages.someVisibilityMessage.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
5
src/course-unit/xblock-container-iframe/hooks/index.ts
Normal file
5
src/course-unit/xblock-container-iframe/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useIframeMessages } from './useIframeMessages';
|
||||
export { useIframeContent } from './useIframeContent';
|
||||
export { useMessageHandlers } from './useMessageHandlers';
|
||||
export { useIFrameBehavior } from './useIFrameBehavior';
|
||||
export { useLoadBearingHook } from './useLoadBearingHook';
|
||||
@@ -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(),
|
||||
25
src/course-unit/xblock-container-iframe/hooks/types.ts
Normal file
25
src/course-unit/xblock-container-iframe/hooks/types.ts
Normal file
@@ -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;
|
||||
handleRefetchXBlocks: () => void;
|
||||
handleDuplicateXBlock: (blockType: string, usageId: string) => void;
|
||||
handleManageXBlockAccess: (usageId: string) => void;
|
||||
};
|
||||
|
||||
export type MessageHandlersTypes = Record<string, (payload: any) => void>;
|
||||
|
||||
export interface UseIFrameBehaviorTypes {
|
||||
id: string;
|
||||
iframeUrl: string;
|
||||
onLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface UseIFrameBehaviorReturnTypes {
|
||||
iframeHeight: number;
|
||||
handleIFrameLoad: () => void;
|
||||
showError: boolean;
|
||||
hasLoaded: boolean;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useCallback, RefObject } from 'react';
|
||||
|
||||
import { messageTypes } from '../../constants';
|
||||
|
||||
/**
|
||||
* Hook for managing iframe content and providing utilities to interact with the iframe.
|
||||
*
|
||||
* @param {React.RefObject<HTMLIFrameElement>} iframeRef - A React ref for the iframe element.
|
||||
* @param {(ref: React.RefObject<HTMLIFrameElement>) => void} setIframeRef -
|
||||
* A function to associate the iframeRef with the parent context.
|
||||
* @param {(type: string, payload: any) => void} sendMessageToIframe - A function to send messages to the iframe.
|
||||
*
|
||||
* @returns {Object} - An object containing utility functions.
|
||||
* @returns {() => void} return.refreshIframeContent -
|
||||
* A function to refresh the iframe content by sending a specific message.
|
||||
*/
|
||||
export const useIframeContent = (
|
||||
iframeRef: RefObject<HTMLIFrameElement>,
|
||||
setIframeRef: (ref: RefObject<HTMLIFrameElement>) => void,
|
||||
sendMessageToIframe: (type: string, payload: any) => void,
|
||||
): { refreshIframeContent: () => void } => {
|
||||
useEffect(() => {
|
||||
setIframeRef(iframeRef);
|
||||
}, [setIframeRef, iframeRef]);
|
||||
|
||||
// TODO: this artificial delay is a temporary solution
|
||||
// to ensure the iframe content is properly refreshed.
|
||||
const refreshIframeContent = useCallback(() => {
|
||||
setTimeout(() => sendMessageToIframe(messageTypes.refreshXBlock, null), 1000);
|
||||
}, [sendMessageToIframe]);
|
||||
|
||||
return { refreshIframeContent };
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for managing and handling messages received by the iframe.
|
||||
*
|
||||
* @param {Record<string, (payload: any) => void>} messageHandlers -
|
||||
* A mapping of message types to their corresponding handler functions.
|
||||
*/
|
||||
export const useIframeMessages = (messageHandlers: Record<string, (payload: any) => 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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
handleRefetchXBlocks,
|
||||
handleDuplicateXBlock,
|
||||
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.refreshXBlockPositions]: handleRefetchXBlocks,
|
||||
[messageTypes.toggleCourseXBlockDropdown]: ({
|
||||
courseXBlockDropdownHeight,
|
||||
}: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight),
|
||||
}), [
|
||||
courseId,
|
||||
handleDeleteXBlock,
|
||||
handleRefetchXBlocks,
|
||||
handleDuplicateXBlock,
|
||||
handleManageXBlockAccess,
|
||||
]);
|
||||
@@ -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 supportedEditors from '../../editors/supportedEditors';
|
||||
import { fetchCourseUnitQuery } from '../data/thunk';
|
||||
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<XBlockContainerIframeProps> = ({ blockId }) => {
|
||||
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(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<AccessManagedXBlockDataTypes | {}>({});
|
||||
const [iframeOffset, setIframeOffset] = useState(0);
|
||||
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
|
||||
const [configureXBlockId, setConfigureXBlockId] = useState<string | null>(null);
|
||||
|
||||
const { iframeHeight } = useIFrameBehavior({
|
||||
id: blockId,
|
||||
iframeUrl,
|
||||
});
|
||||
const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]);
|
||||
|
||||
const { setIframeRef, sendMessageToIframe } = useIframe();
|
||||
const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl });
|
||||
const { refreshIframeContent } = useIframeContent(iframeRef, setIframeRef, sendMessageToIframe);
|
||||
|
||||
useEffect(() => {
|
||||
setIframeRef(iframeRef);
|
||||
}, [setIframeRef]);
|
||||
|
||||
const handleDuplicateXBlock = useCallback(
|
||||
(blockType: string, usageId: string) => {
|
||||
unitXBlockActions.handleDuplicate(usageId);
|
||||
if (supportedEditors[blockType]) {
|
||||
navigate(`/course/${courseId}/editor/${blockType}/${usageId}`);
|
||||
}
|
||||
refreshIframeContent();
|
||||
},
|
||||
[unitXBlockActions, courseId, navigate, refreshIframeContent],
|
||||
);
|
||||
|
||||
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 handleRefetchXBlocks = useCallback(() => {
|
||||
setTimeout(() => dispatch(fetchCourseUnitQuery(blockId)), 1000);
|
||||
}, [dispatch, blockId]);
|
||||
|
||||
const onDeleteSubmit = () => {
|
||||
if (deleteXBlockId) {
|
||||
unitXBlockActions.handleDelete(deleteXBlockId);
|
||||
closeDeleteModal();
|
||||
refreshIframeContent();
|
||||
}
|
||||
};
|
||||
|
||||
const onManageXBlockAccessSubmit = (...args: any[]) => {
|
||||
if (configureXBlockId) {
|
||||
handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal);
|
||||
setAccessManagedXBlockData({});
|
||||
refreshIframeContent();
|
||||
}
|
||||
};
|
||||
|
||||
const messageHandlers = useMessageHandlers({
|
||||
courseId,
|
||||
navigate,
|
||||
dispatch,
|
||||
setIframeOffset,
|
||||
handleDeleteXBlock,
|
||||
handleRefetchXBlocks,
|
||||
handleDuplicateXBlock,
|
||||
handleManageXBlockAccess,
|
||||
});
|
||||
|
||||
useIframeMessages(messageHandlers);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={intl.formatMessage(messages.xblockIframeTitle)}
|
||||
src={iframeUrl}
|
||||
frameBorder="0"
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: iframeHeight + IFRAME_BOTTOM_OFFSET }}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
/>
|
||||
<>
|
||||
<DeleteModal
|
||||
category="component"
|
||||
isOpen={isDeleteModalOpen}
|
||||
close={closeDeleteModal}
|
||||
onDeleteSubmit={onDeleteSubmit}
|
||||
/>
|
||||
{Object.keys(accessManagedXBlockData).length ? (
|
||||
<ConfigureModal
|
||||
isXBlockComponent
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => {
|
||||
closeConfigureModal();
|
||||
setAccessManagedXBlockData({});
|
||||
}}
|
||||
onConfigureSubmit={onManageXBlockAccessSubmit}
|
||||
currentItemData={accessManagedXBlockData as AccessManagedXBlockDataTypes}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
) : null}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={intl.formatMessage(messages.xblockIframeTitle)}
|
||||
src={iframeUrl}
|
||||
frameBorder="0"
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: iframeHeight + iframeOffset }}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
aria-label={intl.formatMessage(messages.xblockIframeLabel, { xblockCount: courseVerticalChildren.length })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
XBlockContainerIframe.propTypes = {
|
||||
blockId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default XBlockContainerIframe;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('<XBlockContainerIframe />', () => {
|
||||
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(
|
||||
<IntlProvider locale="en">
|
||||
<IframeProvider>
|
||||
<XBlockContainerIframe blockId={blockId} />
|
||||
</IframeProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
106
src/course-unit/xblock-container-iframe/types.ts
Normal file
106
src/course-unit/xblock-container-iframe/types.ts
Normal file
@@ -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<GroupTypes>;
|
||||
}
|
||||
|
||||
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<UserPartitionTypes>;
|
||||
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<XBlockTypes>;
|
||||
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<AccessManagedXBlockDataTypes, 'discussionEnabled'>;
|
||||
33
src/course-unit/xblock-container-iframe/utils.ts
Normal file
33
src/course-unit/xblock-container-iframe/utils.ts
Normal file
@@ -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}`;
|
||||
Reference in New Issue
Block a user