chore: iframe rendering optimization (#1544)
Iframe reload optimizations for various xblock related actions. Added some improvements related to scrolling to the current xblock. Fixed behavior of the xblock action dropdown list.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.idea
|
.idea
|
||||||
|
.run
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ describe('<CourseUnit />', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
simulatePostMessageEvent(messageTypes.deleteXBlock, {
|
simulatePostMessageEvent(messageTypes.deleteXBlock, {
|
||||||
id: courseVerticalChildrenMock.children[0].block_id,
|
usageId: courseVerticalChildrenMock.children[0].block_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByText(/Delete this component?/i)).toBeInTheDocument();
|
expect(getByText(/Delete this component?/i)).toBeInTheDocument();
|
||||||
@@ -261,10 +261,10 @@ describe('<CourseUnit />', () => {
|
|||||||
const deleteButton = getAllByRole('button', { name: /Delete/i })
|
const deleteButton = getAllByRole('button', { name: /Delete/i })
|
||||||
.find(({ classList }) => classList.contains('btn-primary'));
|
.find(({ classList }) => classList.contains('btn-primary'));
|
||||||
|
|
||||||
userEvent.click(cancelButton);
|
expect(cancelButton).toBeInTheDocument();
|
||||||
|
|
||||||
simulatePostMessageEvent(messageTypes.deleteXBlock, {
|
simulatePostMessageEvent(messageTypes.deleteXBlock, {
|
||||||
id: courseVerticalChildrenMock.children[0].block_id,
|
usageId: courseVerticalChildrenMock.children[0].block_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByRole('dialog')).toBeInTheDocument();
|
expect(getByRole('dialog')).toBeInTheDocument();
|
||||||
@@ -300,8 +300,12 @@ describe('<CourseUnit />', () => {
|
|||||||
|
|
||||||
axiosMock
|
axiosMock
|
||||||
.onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id))
|
.onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id))
|
||||||
.replyOnce(200, { dummy: 'value' });
|
.reply(200, { dummy: 'value' });
|
||||||
await executeThunk(deleteUnitItemQuery(courseId, blockId), store.dispatch);
|
await executeThunk(deleteUnitItemQuery(
|
||||||
|
courseId,
|
||||||
|
courseVerticalChildrenMock.children[0].block_id,
|
||||||
|
simulatePostMessageEvent,
|
||||||
|
), store.dispatch);
|
||||||
|
|
||||||
const updatedCourseVerticalChildren = courseVerticalChildrenMock.children.filter(
|
const updatedCourseVerticalChildren = courseVerticalChildrenMock.children.filter(
|
||||||
child => child.block_id !== courseVerticalChildrenMock.children[0].block_id,
|
child => child.block_id !== courseVerticalChildrenMock.children[0].block_id,
|
||||||
@@ -1632,6 +1636,8 @@ describe('<CourseUnit />', () => {
|
|||||||
callbackFn: requestData.callbackFn,
|
callbackFn: requestData.callbackFn,
|
||||||
}), store.dispatch);
|
}), store.dispatch);
|
||||||
|
|
||||||
|
simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator });
|
||||||
|
|
||||||
const dismissButton = queryByRole('button', {
|
const dismissButton = queryByRole('button', {
|
||||||
name: /dismiss/i, hidden: true,
|
name: /dismiss/i, hidden: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
|
|||||||
case COMPONENT_TYPES.problem:
|
case COMPONENT_TYPES.problem:
|
||||||
case COMPONENT_TYPES.video:
|
case COMPONENT_TYPES.video:
|
||||||
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
|
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
|
||||||
|
localStorage.setItem('modalEditLastYPosition', window.scrollY);
|
||||||
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
|
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -52,15 +52,21 @@ export const messageTypes = {
|
|||||||
videoFullScreen: 'plugin.videoFullScreen',
|
videoFullScreen: 'plugin.videoFullScreen',
|
||||||
refreshXBlock: 'refreshXBlock',
|
refreshXBlock: 'refreshXBlock',
|
||||||
showMoveXBlockModal: 'showMoveXBlockModal',
|
showMoveXBlockModal: 'showMoveXBlockModal',
|
||||||
|
completeXBlockMoving: 'completeXBlockMoving',
|
||||||
|
rollbackMovedXBlock: 'rollbackMovedXBlock',
|
||||||
showMultipleComponentPicker: 'showMultipleComponentPicker',
|
showMultipleComponentPicker: 'showMultipleComponentPicker',
|
||||||
addSelectedComponentsToBank: 'addSelectedComponentsToBank',
|
addSelectedComponentsToBank: 'addSelectedComponentsToBank',
|
||||||
showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview',
|
showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview',
|
||||||
copyXBlock: 'copyXBlock',
|
copyXBlock: 'copyXBlock',
|
||||||
manageXBlockAccess: 'manageXBlockAccess',
|
manageXBlockAccess: 'manageXBlockAccess',
|
||||||
|
completeManageXBlockAccess: 'completeManageXBlockAccess',
|
||||||
deleteXBlock: 'deleteXBlock',
|
deleteXBlock: 'deleteXBlock',
|
||||||
|
completeXBlockDeleting: 'completeXBlockDeleting',
|
||||||
duplicateXBlock: 'duplicateXBlock',
|
duplicateXBlock: 'duplicateXBlock',
|
||||||
refreshXBlockPositions: 'refreshPositions',
|
completeXBlockDuplicating: 'completeXBlockDuplicating',
|
||||||
newXBlockEditor: 'newXBlockEditor',
|
newXBlockEditor: 'newXBlockEditor',
|
||||||
toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown',
|
toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown',
|
||||||
|
addXBlock: 'addXBlock',
|
||||||
|
scrollToXBlock: 'scrollToXBlock',
|
||||||
handleViewXBlockContent: 'handleViewXBlockContent',
|
handleViewXBlockContent: 'handleViewXBlockContent',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { RequestStatus } from '../../data/constants';
|
|||||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||||
import { updateModel, updateModels } from '../../generic/model-store';
|
import { updateModel, updateModels } from '../../generic/model-store';
|
||||||
import { updateClipboardData } from '../../generic/data/slice';
|
import { updateClipboardData } from '../../generic/data/slice';
|
||||||
|
import { messageTypes } from '../constants';
|
||||||
import {
|
import {
|
||||||
getCourseUnitData,
|
getCourseUnitData,
|
||||||
editUnitDisplayName,
|
editUnitDisplayName,
|
||||||
@@ -126,6 +127,7 @@ export function editCourseUnitVisibilityAndData(
|
|||||||
isVisible,
|
isVisible,
|
||||||
groupAccess,
|
groupAccess,
|
||||||
isDiscussionEnabled,
|
isDiscussionEnabled,
|
||||||
|
callback,
|
||||||
blockId = itemId,
|
blockId = itemId,
|
||||||
) {
|
) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
@@ -143,6 +145,9 @@ export function editCourseUnitVisibilityAndData(
|
|||||||
isDiscussionEnabled,
|
isDiscussionEnabled,
|
||||||
).then(async (result) => {
|
).then(async (result) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
|
if (callback) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
const courseUnit = await getCourseUnitData(blockId);
|
const courseUnit = await getCourseUnitData(blockId);
|
||||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||||
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
||||||
@@ -158,11 +163,8 @@ export function editCourseUnitVisibilityAndData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNewCourseXBlock(body, callback, blockId) {
|
export function createNewCourseXBlock(body, callback, blockId, sendMessageToIframe) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS }));
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
|
||||||
|
|
||||||
if (body.stagedContent) {
|
if (body.stagedContent) {
|
||||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
|
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
|
||||||
} else {
|
} else {
|
||||||
@@ -188,10 +190,10 @@ export function createNewCourseXBlock(body, callback, blockId) {
|
|||||||
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
||||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||||
dispatch(hideProcessingNotification());
|
dispatch(hideProcessingNotification());
|
||||||
dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.SUCCESSFUL }));
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback(result);
|
callback(result);
|
||||||
|
} else {
|
||||||
|
sendMessageToIframe(messageTypes.addXBlock, { data: result });
|
||||||
}
|
}
|
||||||
const currentBlockId = body.category === 'vertical' ? formattedResult.locator : blockId;
|
const currentBlockId = body.category === 'vertical' ? formattedResult.locator : blockId;
|
||||||
const courseUnit = await getCourseUnitData(currentBlockId);
|
const courseUnit = await getCourseUnitData(currentBlockId);
|
||||||
@@ -220,13 +222,14 @@ export function fetchCourseVerticalChildrenData(itemId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteUnitItemQuery(itemId, xblockId) {
|
export function deleteUnitItemQuery(itemId, xblockId, sendMessageToIframe) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteUnitItem(xblockId);
|
await deleteUnitItem(xblockId);
|
||||||
|
sendMessageToIframe(messageTypes.completeXBlockDeleting, { locator: xblockId });
|
||||||
const { userClipboard } = await getCourseSectionVerticalData(itemId);
|
const { userClipboard } = await getCourseSectionVerticalData(itemId);
|
||||||
dispatch(updateClipboardData(userClipboard));
|
dispatch(updateClipboardData(userClipboard));
|
||||||
const courseUnit = await getCourseUnitData(itemId);
|
const courseUnit = await getCourseUnitData(itemId);
|
||||||
@@ -240,13 +243,14 @@ export function deleteUnitItemQuery(itemId, xblockId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function duplicateUnitItemQuery(itemId, xblockId) {
|
export function duplicateUnitItemQuery(itemId, xblockId, callback) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
|
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await duplicateUnitItem(itemId, xblockId);
|
const { courseKey, locator } = await duplicateUnitItem(itemId, xblockId);
|
||||||
|
callback(courseKey, locator);
|
||||||
const courseUnit = await getCourseUnitData(itemId);
|
const courseUnit = await getCourseUnitData(itemId);
|
||||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||||
dispatch(hideProcessingNotification());
|
dispatch(hideProcessingNotification());
|
||||||
@@ -300,9 +304,13 @@ export function patchUnitItemQuery({
|
|||||||
dispatch(updateMovedXBlockParams(xBlockParams));
|
dispatch(updateMovedXBlockParams(xBlockParams));
|
||||||
dispatch(updateCourseOutlineInfo({}));
|
dispatch(updateCourseOutlineInfo({}));
|
||||||
dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||||
const courseUnit = await getCourseUnitData(currentParentLocator);
|
try {
|
||||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
const courseUnit = await getCourseUnitData(currentParentLocator);
|
||||||
callbackFn();
|
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||||
|
} catch (error) {
|
||||||
|
handleResponseErrors(error, dispatch, updateSavingStatus);
|
||||||
|
}
|
||||||
|
callbackFn(sourceLocator);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleResponseErrors(error, dispatch, updateSavingStatus);
|
handleResponseErrors(error, dispatch, updateSavingStatus);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
|
|||||||
import { COURSE_BLOCK_NAMES } from '../../constants';
|
import { COURSE_BLOCK_NAMES } from '../../constants';
|
||||||
import { getCourseUnitData } from '../data/selectors';
|
import { getCourseUnitData } from '../data/selectors';
|
||||||
import { updateQueryPendingStatus } from '../data/slice';
|
import { updateQueryPendingStatus } from '../data/slice';
|
||||||
import { messageTypes } from '../constants';
|
|
||||||
import { useIframe } from '../context/hooks';
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const HeaderTitle = ({
|
const HeaderTitle = ({
|
||||||
@@ -29,15 +27,9 @@ const HeaderTitle = ({
|
|||||||
const currentItemData = useSelector(getCourseUnitData);
|
const currentItemData = useSelector(getCourseUnitData);
|
||||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||||
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo;
|
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo;
|
||||||
const { sendMessageToIframe } = useIframe();
|
|
||||||
|
|
||||||
const onConfigureSubmit = (...arg) => {
|
const onConfigureSubmit = (...arg) => {
|
||||||
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
|
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 = () => {
|
const getVisibilityMessage = () => {
|
||||||
|
|||||||
@@ -60,11 +60,9 @@ describe('<HeaderTitle />', () => {
|
|||||||
it('render HeaderTitle component correctly', () => {
|
it('render HeaderTitle component correctly', () => {
|
||||||
const { getByText, getByRole } = renderComponent();
|
const { getByText, getByRole } = renderComponent();
|
||||||
|
|
||||||
waitFor(() => {
|
expect(getByText(unitTitle)).toBeInTheDocument();
|
||||||
expect(getByText(unitTitle)).toBeInTheDocument();
|
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument();
|
||||||
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument();
|
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument();
|
||||||
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('render HeaderTitle with open edit form', () => {
|
it('render HeaderTitle with open edit form', () => {
|
||||||
@@ -72,41 +70,35 @@ describe('<HeaderTitle />', () => {
|
|||||||
isTitleEditFormOpen: true,
|
isTitleEditFormOpen: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
waitFor(() => {
|
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument();
|
||||||
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument();
|
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle);
|
||||||
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle);
|
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument();
|
||||||
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument();
|
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument();
|
||||||
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls toggle edit title form by clicking on Edit button', () => {
|
it('calls toggle edit title form by clicking on Edit button', () => {
|
||||||
const { getByRole } = renderComponent();
|
const { getByRole } = renderComponent();
|
||||||
|
|
||||||
waitFor(() => {
|
const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage });
|
||||||
const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage });
|
userEvent.click(editTitleButton);
|
||||||
userEvent.click(editTitleButton);
|
expect(handleTitleEdit).toHaveBeenCalledTimes(1);
|
||||||
expect(handleTitleEdit).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls saving title by clicking outside or press Enter key', async () => {
|
it('calls saving title by clicking outside or press Enter key', () => {
|
||||||
const { getByRole } = renderComponent({
|
const { getByRole } = renderComponent({
|
||||||
isTitleEditFormOpen: true,
|
isTitleEditFormOpen: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
waitFor(() => {
|
const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage });
|
||||||
const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage });
|
userEvent.type(titleField, ' 1');
|
||||||
userEvent.type(titleField, ' 1');
|
expect(titleField).toHaveValue(`${unitTitle} 1`);
|
||||||
expect(titleField).toHaveValue(`${unitTitle} 1`);
|
userEvent.click(document.body);
|
||||||
userEvent.click(document.body);
|
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1);
|
||||||
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
userEvent.click(titleField);
|
userEvent.click(titleField);
|
||||||
userEvent.type(titleField, ' 2[Enter]');
|
userEvent.type(titleField, ' 2[Enter]');
|
||||||
expect(titleField).toHaveValue(`${unitTitle} 1 2`);
|
expect(titleField).toHaveValue(`${unitTitle} 1 2`);
|
||||||
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2);
|
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays a visibility message with the selected groups for the unit', async () => {
|
it('displays a visibility message with the selected groups for the unit', async () => {
|
||||||
@@ -125,7 +117,7 @@ describe('<HeaderTitle />', () => {
|
|||||||
const visibilityMessage = messages.definedVisibilityMessage.defaultMessage
|
const visibilityMessage = messages.definedVisibilityMessage.defaultMessage
|
||||||
.replace('{selectedGroupsLabel}', 'Visibility group 1');
|
.replace('{selectedGroupsLabel}', 'Visibility group 1');
|
||||||
|
|
||||||
waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getByText(visibilityMessage)).toBeInTheDocument();
|
expect(getByText(visibilityMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -140,8 +132,8 @@ describe('<HeaderTitle />', () => {
|
|||||||
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
|
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
|
||||||
const { getByText } = renderComponent();
|
const { getByText } = renderComponent();
|
||||||
|
|
||||||
waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getByText(messages.someVisibilityMessage.defaultMessage)).toBeInTheDocument();
|
expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
|||||||
isVisible,
|
isVisible,
|
||||||
groupAccess,
|
groupAccess,
|
||||||
isDiscussionEnabled,
|
isDiscussionEnabled,
|
||||||
|
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: id }),
|
||||||
blockId,
|
blockId,
|
||||||
));
|
));
|
||||||
if (typeof closeModalFn === 'function') {
|
if (typeof closeModalFn === 'function') {
|
||||||
@@ -119,15 +120,19 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNewCourseXBlock = (body, callback) => (
|
const handleCreateNewCourseXBlock = (body, callback) => (
|
||||||
dispatch(createNewCourseXBlock(body, callback, blockId))
|
dispatch(createNewCourseXBlock(body, callback, blockId, sendMessageToIframe))
|
||||||
);
|
);
|
||||||
|
|
||||||
const unitXBlockActions = {
|
const unitXBlockActions = {
|
||||||
handleDelete: (XBlockId) => {
|
handleDelete: (XBlockId) => {
|
||||||
dispatch(deleteUnitItemQuery(blockId, XBlockId));
|
dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
|
||||||
},
|
},
|
||||||
handleDuplicate: (XBlockId) => {
|
handleDuplicate: (XBlockId) => {
|
||||||
dispatch(duplicateUnitItemQuery(blockId, XBlockId));
|
dispatch(duplicateUnitItemQuery(
|
||||||
|
blockId,
|
||||||
|
XBlockId,
|
||||||
|
(courseKey, locator) => sendMessageToIframe(messageTypes.completeXBlockDuplicating, { courseKey, locator }),
|
||||||
|
));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,7 +147,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
|||||||
currentParentLocator,
|
currentParentLocator,
|
||||||
isMoving: false,
|
isMoving: false,
|
||||||
callbackFn: () => {
|
callbackFn: () => {
|
||||||
sendMessageToIframe(messageTypes.refreshXBlock, null);
|
sendMessageToIframe(messageTypes.rollbackMovedXBlock, { locator: sourceLocator });
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ export const useMoveModal = ({
|
|||||||
title: state.sourceXBlockInfo.current.displayName,
|
title: state.sourceXBlockInfo.current.displayName,
|
||||||
currentParentLocator: blockId,
|
currentParentLocator: blockId,
|
||||||
isMoving: true,
|
isMoving: true,
|
||||||
callbackFn: () => {
|
callbackFn: (sourceLocator: string) => {
|
||||||
sendMessageToIframe(messageTypes.refreshXBlock, null);
|
sendMessageToIframe(messageTypes.completeXBlockMoving, { locator: sourceLocator });
|
||||||
closeModal();
|
closeModal();
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ const PublishControls = ({ blockId }) => {
|
|||||||
|
|
||||||
const handleCourseUnitDiscardChanges = () => {
|
const handleCourseUnitDiscardChanges = () => {
|
||||||
closeDiscardModal();
|
closeDiscardModal();
|
||||||
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges));
|
dispatch(editCourseUnitVisibilityAndData(
|
||||||
// TODO: this artificial delay is a temporary solution
|
blockId,
|
||||||
// to ensure the iframe content is properly refreshed.
|
PUBLISH_TYPES.discardChanges,
|
||||||
setTimeout(() => {
|
null,
|
||||||
sendMessageToIframe(messageTypes.refreshXBlock, null);
|
null,
|
||||||
}, 1000);
|
null,
|
||||||
|
() => sendMessageToIframe(messageTypes.refreshXBlock, null),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCourseUnitPublish = () => {
|
const handleCourseUnitPublish = () => {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useKeyedState } from '@edx/react-unit-test-utils';
|
|||||||
import { logError } from '@edx/frontend-platform/logging';
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
|
|
||||||
import { stateKeys, messageTypes } from '../../../constants';
|
import { stateKeys, messageTypes } from '../../../constants';
|
||||||
import { useLoadBearingHook, useIFrameBehavior } from '..';
|
import { useLoadBearingHook, useIFrameBehavior, useMessageHandlers } from '..';
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
jest.mock('@edx/react-unit-test-utils', () => ({
|
jest.mock('@edx/react-unit-test-utils', () => ({
|
||||||
useKeyedState: jest.fn(),
|
useKeyedState: jest.fn(),
|
||||||
@@ -171,3 +173,36 @@ describe('useLoadBearingHook', () => {
|
|||||||
expect(setValue.mock.calls);
|
expect(setValue.mock.calls);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useMessageHandlers', () => {
|
||||||
|
it('calls handleScrollToXBlock after debounce delay', () => {
|
||||||
|
const mockHandleScrollToXBlock = jest.fn();
|
||||||
|
const courseId = 'course-v1:Test+101+2025';
|
||||||
|
const navigate = jest.fn();
|
||||||
|
const dispatch = jest.fn();
|
||||||
|
const setIframeOffset = jest.fn();
|
||||||
|
const handleDeleteXBlock = jest.fn();
|
||||||
|
const handleDuplicateXBlock = jest.fn();
|
||||||
|
const handleManageXBlockAccess = jest.fn();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMessageHandlers({
|
||||||
|
courseId,
|
||||||
|
navigate,
|
||||||
|
dispatch,
|
||||||
|
setIframeOffset,
|
||||||
|
handleDeleteXBlock,
|
||||||
|
handleDuplicateXBlock,
|
||||||
|
handleScrollToXBlock: mockHandleScrollToXBlock,
|
||||||
|
handleManageXBlockAccess,
|
||||||
|
}));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current[messageTypes.scrollToXBlock]({ scrollOffset: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
|
||||||
|
expect(mockHandleScrollToXBlock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockHandleScrollToXBlock).toHaveBeenCalledWith(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type UseMessageHandlersTypes = {
|
|||||||
dispatch: (action: any) => void;
|
dispatch: (action: any) => void;
|
||||||
setIframeOffset: (height: number) => void;
|
setIframeOffset: (height: number) => void;
|
||||||
handleDeleteXBlock: (usageId: string) => void;
|
handleDeleteXBlock: (usageId: string) => void;
|
||||||
handleRefetchXBlocks: () => void;
|
handleScrollToXBlock: (scrollOffset: number) => void;
|
||||||
handleDuplicateXBlock: (blockType: string, usageId: string) => void;
|
handleDuplicateXBlock: (blockType: string, usageId: string) => void;
|
||||||
handleManageXBlockAccess: (usageId: string) => void;
|
handleManageXBlockAccess: (usageId: string) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useEffect, useCallback, RefObject } from 'react';
|
import { useEffect, RefObject } from 'react';
|
||||||
|
|
||||||
import { messageTypes } from '../../constants';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing iframe content and providing utilities to interact with the iframe.
|
* Hook for managing iframe content and providing utilities to interact with the iframe.
|
||||||
@@ -8,26 +6,15 @@ import { messageTypes } from '../../constants';
|
|||||||
* @param {React.RefObject<HTMLIFrameElement>} iframeRef - A React ref for the iframe element.
|
* @param {React.RefObject<HTMLIFrameElement>} iframeRef - A React ref for the iframe element.
|
||||||
* @param {(ref: React.RefObject<HTMLIFrameElement>) => void} setIframeRef -
|
* @param {(ref: React.RefObject<HTMLIFrameElement>) => void} setIframeRef -
|
||||||
* A function to associate the iframeRef with the parent context.
|
* 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 {Object} - An object containing utility functions.
|
||||||
* @returns {() => void} return.refreshIframeContent -
|
* @returns {() => void}
|
||||||
* A function to refresh the iframe content by sending a specific message.
|
|
||||||
*/
|
*/
|
||||||
export const useIframeContent = (
|
export const useIframeContent = (
|
||||||
iframeRef: RefObject<HTMLIFrameElement>,
|
iframeRef: RefObject<HTMLIFrameElement>,
|
||||||
setIframeRef: (ref: RefObject<HTMLIFrameElement>) => void,
|
setIframeRef: (ref: RefObject<HTMLIFrameElement>) => void,
|
||||||
sendMessageToIframe: (type: string, payload: any) => void,
|
): void => {
|
||||||
): { refreshIframeContent: () => void } => {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIframeRef(iframeRef);
|
setIframeRef(iframeRef);
|
||||||
}, [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 };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
import { copyToClipboard } from '../../../generic/data/thunks';
|
import { copyToClipboard } from '../../../generic/data/thunks';
|
||||||
import { messageTypes } from '../../constants';
|
import { messageTypes } from '../../constants';
|
||||||
@@ -16,8 +17,8 @@ export const useMessageHandlers = ({
|
|||||||
dispatch,
|
dispatch,
|
||||||
setIframeOffset,
|
setIframeOffset,
|
||||||
handleDeleteXBlock,
|
handleDeleteXBlock,
|
||||||
handleRefetchXBlocks,
|
|
||||||
handleDuplicateXBlock,
|
handleDuplicateXBlock,
|
||||||
|
handleScrollToXBlock,
|
||||||
handleManageXBlockAccess,
|
handleManageXBlockAccess,
|
||||||
}: UseMessageHandlersTypes): MessageHandlersTypes => useMemo(() => ({
|
}: UseMessageHandlersTypes): MessageHandlersTypes => useMemo(() => ({
|
||||||
[messageTypes.copyXBlock]: ({ usageId }) => dispatch(copyToClipboard(usageId)),
|
[messageTypes.copyXBlock]: ({ usageId }) => dispatch(copyToClipboard(usageId)),
|
||||||
@@ -25,14 +26,14 @@ export const useMessageHandlers = ({
|
|||||||
[messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`),
|
[messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`),
|
||||||
[messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId),
|
[messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId),
|
||||||
[messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId),
|
[messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId),
|
||||||
[messageTypes.refreshXBlockPositions]: handleRefetchXBlocks,
|
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 3000),
|
||||||
[messageTypes.toggleCourseXBlockDropdown]: ({
|
[messageTypes.toggleCourseXBlockDropdown]: ({
|
||||||
courseXBlockDropdownHeight,
|
courseXBlockDropdownHeight,
|
||||||
}: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight),
|
}: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight),
|
||||||
}), [
|
}), [
|
||||||
courseId,
|
courseId,
|
||||||
handleDeleteXBlock,
|
handleDeleteXBlock,
|
||||||
handleRefetchXBlocks,
|
|
||||||
handleDuplicateXBlock,
|
handleDuplicateXBlock,
|
||||||
handleManageXBlockAccess,
|
handleManageXBlockAccess,
|
||||||
|
handleScrollToXBlock,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
|||||||
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
|
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
|
||||||
import { IFRAME_FEATURE_POLICY } from '../../constants';
|
import { IFRAME_FEATURE_POLICY } from '../../constants';
|
||||||
import supportedEditors from '../../editors/supportedEditors';
|
import supportedEditors from '../../editors/supportedEditors';
|
||||||
import { fetchCourseUnitQuery } from '../data/thunk';
|
|
||||||
import { useIframe } from '../context/hooks';
|
import { useIframe } from '../context/hooks';
|
||||||
import {
|
import {
|
||||||
useMessageHandlers,
|
useMessageHandlers,
|
||||||
@@ -43,9 +42,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
|||||||
|
|
||||||
const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]);
|
const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]);
|
||||||
|
|
||||||
const { setIframeRef, sendMessageToIframe } = useIframe();
|
const { setIframeRef } = useIframe();
|
||||||
const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl });
|
const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl });
|
||||||
const { refreshIframeContent } = useIframeContent(iframeRef, setIframeRef, sendMessageToIframe);
|
|
||||||
|
useIframeContent(iframeRef, setIframeRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIframeRef(iframeRef);
|
setIframeRef(iframeRef);
|
||||||
@@ -57,9 +57,8 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
|||||||
if (supportedEditors[blockType]) {
|
if (supportedEditors[blockType]) {
|
||||||
navigate(`/course/${courseId}/editor/${blockType}/${usageId}`);
|
navigate(`/course/${courseId}/editor/${blockType}/${usageId}`);
|
||||||
}
|
}
|
||||||
refreshIframeContent();
|
|
||||||
},
|
},
|
||||||
[unitXBlockActions, courseId, navigate, refreshIframeContent],
|
[unitXBlockActions, courseId, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteXBlock = (usageId: string) => {
|
const handleDeleteXBlock = (usageId: string) => {
|
||||||
@@ -76,15 +75,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefetchXBlocks = useCallback(() => {
|
|
||||||
setTimeout(() => dispatch(fetchCourseUnitQuery(blockId)), 1000);
|
|
||||||
}, [dispatch, blockId]);
|
|
||||||
|
|
||||||
const onDeleteSubmit = () => {
|
const onDeleteSubmit = () => {
|
||||||
if (deleteXBlockId) {
|
if (deleteXBlockId) {
|
||||||
unitXBlockActions.handleDelete(deleteXBlockId);
|
unitXBlockActions.handleDelete(deleteXBlockId);
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
refreshIframeContent();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,19 +86,25 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
|||||||
if (configureXBlockId) {
|
if (configureXBlockId) {
|
||||||
handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal);
|
handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal);
|
||||||
setAccessManagedXBlockData({});
|
setAccessManagedXBlockData({});
|
||||||
refreshIframeContent();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScrollToXBlock = (scrollOffset: number) => {
|
||||||
|
window.scrollBy({
|
||||||
|
top: scrollOffset,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const messageHandlers = useMessageHandlers({
|
const messageHandlers = useMessageHandlers({
|
||||||
courseId,
|
courseId,
|
||||||
navigate,
|
navigate,
|
||||||
dispatch,
|
dispatch,
|
||||||
setIframeOffset,
|
setIframeOffset,
|
||||||
handleDeleteXBlock,
|
handleDeleteXBlock,
|
||||||
handleRefetchXBlocks,
|
|
||||||
handleDuplicateXBlock,
|
handleDuplicateXBlock,
|
||||||
handleManageXBlockAccess,
|
handleManageXBlockAccess,
|
||||||
|
handleScrollToXBlock,
|
||||||
});
|
});
|
||||||
|
|
||||||
useIframeMessages(messageHandlers);
|
useIframeMessages(messageHandlers);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const PasteButton = ({ onClick, text, className }) => {
|
|||||||
const { blockId } = useParams();
|
const { blockId } = useParams();
|
||||||
|
|
||||||
const handlePasteXBlockComponent = () => {
|
const handlePasteXBlockComponent = () => {
|
||||||
onClick({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId);
|
onClick({ stagedContent: 'clipboard', parentLocator: blockId });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ ConfigureModal.propTypes = {
|
|||||||
supportsOnboarding: PropTypes.bool,
|
supportsOnboarding: PropTypes.bool,
|
||||||
showReviewRules: PropTypes.bool,
|
showReviewRules: PropTypes.bool,
|
||||||
onlineProctoringRules: PropTypes.string,
|
onlineProctoringRules: PropTypes.string,
|
||||||
discussionEnabled: PropTypes.bool.isRequired,
|
discussionEnabled: PropTypes.bool,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
isXBlockComponent: PropTypes.bool,
|
isXBlockComponent: PropTypes.bool,
|
||||||
isSelfPaced: PropTypes.bool.isRequired,
|
isSelfPaced: PropTypes.bool.isRequired,
|
||||||
|
|||||||
@@ -157,12 +157,15 @@ UnitTab.propTypes = {
|
|||||||
isLibraryContent: PropTypes.bool,
|
isLibraryContent: PropTypes.bool,
|
||||||
values: PropTypes.shape({
|
values: PropTypes.shape({
|
||||||
isVisibleToStaffOnly: PropTypes.bool.isRequired,
|
isVisibleToStaffOnly: PropTypes.bool.isRequired,
|
||||||
discussionEnabled: PropTypes.bool.isRequired,
|
discussionEnabled: PropTypes.bool,
|
||||||
selectedPartitionIndex: PropTypes.oneOfType([
|
selectedPartitionIndex: PropTypes.oneOfType([
|
||||||
PropTypes.string,
|
PropTypes.string,
|
||||||
PropTypes.number,
|
PropTypes.number,
|
||||||
]).isRequired,
|
]).isRequired,
|
||||||
selectedGroups: PropTypes.arrayOf(PropTypes.string),
|
selectedGroups: PropTypes.oneOfType([
|
||||||
|
PropTypes.arrayOf(PropTypes.string),
|
||||||
|
PropTypes.array,
|
||||||
|
]),
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
setFieldValue: PropTypes.func.isRequired,
|
setFieldValue: PropTypes.func.isRequired,
|
||||||
showWarning: PropTypes.bool.isRequired,
|
showWarning: PropTypes.bool.isRequired,
|
||||||
|
|||||||
Reference in New Issue
Block a user