From cc25c40833ec9e3dd58b566de238669056a68921 Mon Sep 17 00:00:00 2001 From: Peter Kulko Date: Wed, 23 Oct 2024 19:44:44 +0300 Subject: [PATCH] feat: listen to xblock interaction events feat: new edit modals --- src/course-unit/CourseUnit.jsx | 10 +- src/course-unit/constants.js | 6 + src/course-unit/data/api.js | 16 ++ src/course-unit/data/slice.js | 2 +- src/course-unit/data/thunk.js | 20 +- src/course-unit/hooks.jsx | 13 +- src/course-unit/sidebar/PublishControls.jsx | 9 +- .../xblock-container-iframe/index.tsx | 220 +++++++++++++++--- src/editors/hooks.js | 3 + 9 files changed, 262 insertions(+), 37 deletions(-) diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 68aa3a3d46..74b2a20807 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -50,6 +50,7 @@ const CourseUnit = ({ courseId }) => { isTitleEditFormOpen, staticFileNotices, currentlyVisibleToStudents, + unitXBlockActions, sharedClipboardData, showPasteXBlock, showPasteUnit, @@ -58,6 +59,7 @@ const CourseUnit = ({ courseId }) => { handleTitleEdit, handleCreateNewCourseXBlock, handleConfigureSubmit, + courseVerticalChildren, canPasteComponent, isMoveModalOpen, openMoveModal, @@ -176,7 +178,13 @@ const CourseUnit = ({ courseId }) => { courseId={courseId} /> )} - + } + */ +export async function duplicateUnitItem(itemId, XBlockId) { + const { data } = await getAuthenticatedHttpClient() + .post(postXBlockBaseApiUrl(), { + parent_locator: itemId, + duplicate_source_locator: XBlockId, + }); + + return data; +} + /** * Get an object containing course outline data. * @param {string} courseId - The identifier of the course. diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index fec0ba7dc2..1755d0960f 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -28,7 +28,7 @@ const slice = createSlice({ title: '', sourceLocator: '', targetParentLocator: '', - } + }, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index ac7e1a62d5..a21a0c6f21 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -17,6 +17,7 @@ import { getCourseVerticalChildren, handleCourseUnitVisibilityAndData, deleteUnitItem, + duplicateUnitItem, getCourseOutlineInfo, patchUnitItem, } from './api'; @@ -206,7 +207,6 @@ export function fetchCourseVerticalChildrenData(itemId) { }; } -// TODO: use for xblock delete functionality export function deleteUnitItemQuery(itemId, xblockId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); @@ -227,6 +227,24 @@ export function deleteUnitItemQuery(itemId, xblockId) { }; } +export function duplicateUnitItemQuery(itemId, xblockId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); + + try { + await duplicateUnitItem(itemId, xblockId); + const courseUnit = await getCourseUnitData(itemId); + dispatch(fetchCourseItemSuccess(courseUnit)); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(hideProcessingNotification()); + handleResponseErrors(error, dispatch, updateSavingStatus); + } + }; +} + export function getCourseOutlineInfoQuery(courseId) { return async (dispatch) => { dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS })); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index bb78de3b2c..bf8b657ca2 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect } from 'react'; +import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useToggle } from '@openedx/paragon'; @@ -13,6 +13,7 @@ import { fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, deleteUnitItemQuery, + duplicateUnitItemQuery, editCourseUnitVisibilityAndData, getCourseOutlineInfoQuery, patchUnitItemQuery, @@ -38,7 +39,6 @@ import { import { useIframe } from './context/hooks'; import { messageTypes, PUBLISH_TYPES } from './constants'; - // eslint-disable-next-line import/prefer-default-export export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -112,14 +112,18 @@ export const useCourseUnit = ({ courseId, blockId }) => { ); const unitXBlockActions = { - // TODO: use for xblock delete functionality handleDelete: (XBlockId) => { dispatch(deleteUnitItemQuery(blockId, XBlockId)); }, + handleDuplicate: (XBlockId) => { + dispatch(duplicateUnitItemQuery(blockId, XBlockId)); + }, }; const handleRollbackMovedXBlock = () => { - const { sourceLocator, targetParentLocator, title, currentParentLocator } = movedXBlockParams; + const { + sourceLocator, targetParentLocator, title, currentParentLocator, + } = movedXBlockParams; dispatch(patchUnitItemQuery({ sourceLocator, targetParentLocator, @@ -183,6 +187,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleTitleEditSubmit, handleCreateNewCourseXBlock, handleConfigureSubmit, + courseVerticalChildren, canPasteComponent, isMoveModalOpen, openMoveModal, diff --git a/src/course-unit/sidebar/PublishControls.jsx b/src/course-unit/sidebar/PublishControls.jsx index 424594f35b..0ef08baf28 100644 --- a/src/course-unit/sidebar/PublishControls.jsx +++ b/src/course-unit/sidebar/PublishControls.jsx @@ -4,9 +4,10 @@ import { useToggle } from '@openedx/paragon'; import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import useCourseUnitData from './hooks'; +import { useIframe } from '../context/hooks'; import { editCourseUnitVisibilityAndData } from '../data/thunk'; import { SidebarBody, SidebarFooter, SidebarHeader } from './components'; -import { PUBLISH_TYPES } from '../constants'; +import { PUBLISH_TYPES, messageTypes } from '../constants'; import { getCourseUnitData } from '../data/selectors'; import messages from './messages'; import ModalNotification from '../../generic/modal-notification'; @@ -20,6 +21,7 @@ const PublishControls = ({ blockId }) => { visibleToStaffOnly, } = useCourseUnitData(useSelector(getCourseUnitData)); const intl = useIntl(); + const { sendMessageToIframe } = useIframe(); const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); @@ -34,6 +36,11 @@ const PublishControls = ({ blockId }) => { const handleCourseUnitDiscardChanges = () => { closeDiscardModal(); dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges)); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); }; const handleCourseUnitPublish = () => { diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 67d625ab1d..39b8705d0d 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,57 +1,219 @@ -import { useRef, useEffect, FC } from 'react'; -import PropTypes from 'prop-types'; +import { + useRef, FC, useEffect, useState, +} 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 {useIframe} from '../context/hooks'; +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; +import { copyToClipboard } from '../../generic/data/thunks'; +import { COURSE_BLOCK_NAMES } from '../../constants'; +import { IFRAME_FEATURE_POLICY, messageTypes } from '../constants'; +import { fetchCourseUnitQuery } from '../data/thunk'; +import { useIframe } from '../context/hooks'; import { useIFrameBehavior } from './hooks'; import messages from './messages'; -/** - * This offset is necessary to fully display the dropdown actions of the XBlock - * in case the XBlock does not have content inside. - */ const IFRAME_BOTTOM_OFFSET = 220; interface XBlockContainerIframeProps { + courseId: string; blockId: string; + unitXBlockActions: { + handleDelete: (XBlockId: string) => void; + }; + xblocks: Array<{ + name: string; + blockId: string; + blockType: string; + userPartitionInfo: { + selectablePartitions: any[]; + selectedPartitionIndex: number; + selectedGroupsLabel: string; + }; + userPartitions: Array<{ + id: number; + name: string; + scheme: string; + groups: Array<{ + id: number; + name: string; + selected: boolean; + deleted: boolean; + }>; + }>; + upstreamLink: string | null; + actions: { + canCopy: boolean; + canDuplicate: boolean; + canMove: boolean; + canManageAccess: boolean; + canDelete: boolean; + canManageTags: boolean; + }; + validationMessages: any[]; + renderError: string; + id: string; + }>; + handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void; } -const XBlockContainerIframe: FC = ({ blockId }) => { +const XBlockContainerIframe: FC = ({ + courseId, blockId, unitXBlockActions, xblocks, handleConfigureSubmit, +}) => { const intl = useIntl(); const iframeRef = useRef(null); - const { setIframeRef } = useIframe(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const [deleteXblockId, setDeleteXblockId] = useState(null); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const { setIframeRef, sendMessageToIframe } = useIframe(); + const [editXblockId, setEditXblockId] = useState(null); + const [currentXblockData, setCurrentXblockData] = useState>({}); const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`; + useEffect(() => { + setIframeRef(iframeRef); + }, [setIframeRef]); + + const handleDelete = (id: string) => { + openDeleteModal(); + setDeleteXblockId(id); + }; + + const handleConfigure = (id: string) => { + openConfigureModal(); + setEditXblockId(id); + + const foundXBlockInfo = xblocks?.find(block => block.blockId === id); + + if (foundXBlockInfo) { + const { name, userPartitionInfo } = foundXBlockInfo; + + setCurrentXblockData({ + category: COURSE_BLOCK_NAMES.component.id, + displayName: name, + userPartitionInfo, + showCorrectness: 'always', + }); + } + }; + + const handleCopy = (id: string) => { + dispatch(copyToClipboard(id)); + }; + + const handleDuplicateXBlock = (id) => { + if (id) { + unitXBlockActions.handleDuplicate(id); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); + } + }; + + const handleRefreshXBlocks = () => { + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + dispatch(fetchCourseUnitQuery(blockId)); + }, 1000); + }; + + const navigateToNewXBlockEditor = (url: string) => { + navigate(`/course/${courseId}/editor${url}`); + }; + + useEffect(() => { + const messageHandlers: Record void> = { + [messageTypes.deleteXBlock]: (payload) => handleDelete(payload.id), + [messageTypes.manageXBlockAccess]: (payload) => handleConfigure(payload.id), + [messageTypes.copyXBlock]: (payload) => handleCopy(payload.id), + [messageTypes.duplicateXBlock]: (payload) => handleDuplicateXBlock(payload.id), + [messageTypes.refreshPositions]: handleRefreshXBlocks, + [messageTypes.newXBlockEditor]: (payload) => navigateToNewXBlockEditor(payload.url), + }; + + const handleMessage = (event: MessageEvent) => { + const { type, payload } = event.data || {}; + console.log('MESSAGE FROM IFRAME =================>', { type, payload }); + if (type && messageHandlers[type]) { + messageHandlers[type](payload); + } + }; + + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [dispatch, blockId, xblocks]); + const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl, }); - useEffect(() => { - setIframeRef(iframeRef); - }, [setIframeRef]); + const handleDeleteItemSubmit = () => { + if (deleteXblockId) { + unitXBlockActions.handleDelete(deleteXblockId); + closeDeleteModal(); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); + } + }; + + const onConfigureSubmit = (...args: any[]) => { + if (editXblockId) { + handleConfigureSubmit(editXblockId, ...args, closeConfigureModal); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); + } + }; return ( -