From 411607ec5986eb897ad9ed0bf5d4292aa627e085 Mon Sep 17 00:00:00 2001
From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com>
Date: Mon, 3 Mar 2025 06:07:04 -0800
Subject: [PATCH] feat: [FC-0070] Manage Tags interoperation (#1454)
Added interaction between MFE and Legacy tagging functionality for xblocks on the Course unit page.
---
src/content-tags-drawer/data/apiHooks.jsx | 40 ++--
src/course-unit/CourseUnit.jsx | 4 +-
src/course-unit/CourseUnit.test.jsx | 63 ++++++
.../add-component/AddComponent.jsx | 3 +-
src/course-unit/constants.js | 1 +
src/course-unit/context/hooks.tsx | 1 -
src/course-unit/hooks.jsx | 59 +++++-
src/course-unit/hooks.test.jsx | 181 ++++++++++++++++++
.../xblock-container-iframe/hooks/types.ts | 1 +
.../hooks/useMessageHandlers.tsx | 2 +
.../xblock-container-iframe/index.tsx | 21 +-
11 files changed, 355 insertions(+), 21 deletions(-)
create mode 100644 src/course-unit/hooks.test.jsx
diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx
index f7e5dd2c1..6be799933 100644
--- a/src/content-tags-drawer/data/apiHooks.jsx
+++ b/src/content-tags-drawer/data/apiHooks.jsx
@@ -126,6 +126,7 @@ export const useContentData = (contentId) => (
*/
export const useContentTaxonomyTagsUpdater = (contentId) => {
const queryClient = useQueryClient();
+ const unitIframe = window.frames['xblock-iframe'];
return useMutation({
/**
@@ -160,7 +161,8 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
onSuccess: /* istanbul ignore next */ () => {
/* istanbul ignore next */
if (window.top != null) {
- // This send messages to the parent page if the drawer is called from a iframe.
+ // Sends messages to the parent page if the drawer was opened
+ // from an iframe or the unit iframe within the course.
// Is used on Studio to update tags data and counts.
// In the future, when the Course Outline Page and Unit Page are integrated into this MFE,
// they should just use React Query to load the tag counts, and React Query will automatically
@@ -169,26 +171,32 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
// Sends content tags.
getContentTaxonomyTagsData(contentId).then((data) => {
- const contentData = {
- contentId,
- ...data,
+ const contentData = { contentId, ...data };
+
+ const message = {
+ type: 'authoring.events.tags.updated',
+ data: contentData,
};
- window.top?.postMessage(
- { type: 'authoring.events.tags.updated', data: contentData },
- getConfig().STUDIO_BASE_URL,
- );
+
+ const targetOrigin = getConfig().STUDIO_BASE_URL;
+
+ unitIframe?.postMessage(message, targetOrigin);
+ window.top?.postMessage(message, targetOrigin);
});
// Sends tags count.
- getContentTaxonomyTagsCount(contentId).then((data) => {
- const contentData = {
- contentId,
- count: data,
+ getContentTaxonomyTagsCount(contentId).then((count) => {
+ const contentData = { contentId, count };
+
+ const message = {
+ type: 'authoring.events.tags.count.updated',
+ data: contentData,
};
- window.top?.postMessage(
- { type: 'authoring.events.tags.count.updated', data: contentData },
- getConfig().STUDIO_BASE_URL,
- );
+
+ const targetOrigin = getConfig().STUDIO_BASE_URL;
+
+ unitIframe?.postMessage(message, targetOrigin);
+ window.top?.postMessage(message, targetOrigin);
});
}
},
diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx
index a09b98596..6f34b089d 100644
--- a/src/course-unit/CourseUnit.jsx
+++ b/src/course-unit/CourseUnit.jsx
@@ -28,7 +28,7 @@ import Breadcrumbs from './breadcrumbs/Breadcrumbs';
import HeaderNavigations from './header-navigations/HeaderNavigations';
import Sequence from './course-sequence';
import Sidebar from './sidebar';
-import { useCourseUnit, useLayoutGrid } from './hooks';
+import { useCourseUnit, useLayoutGrid, useScrollToLastPosition } from './hooks';
import messages from './messages';
import PublishControls from './sidebar/PublishControls';
import LocationInfo from './sidebar/LocationInfo';
@@ -79,6 +79,8 @@ const CourseUnit = ({ courseId }) => {
document.title = getPageHeadTitle('', unitTitle);
}, [unitTitle]);
+ useScrollToLastPosition();
+
const {
isShow: isShowProcessingNotification,
title: processingNotificationTitle,
diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx
index 7d81a9c7b..121a95c6a 100644
--- a/src/course-unit/CourseUnit.test.jsx
+++ b/src/course-unit/CourseUnit.test.jsx
@@ -54,6 +54,7 @@ import sidebarMessages from './sidebar/messages';
import { extractCourseUnitId } from './sidebar/utils';
import CourseUnit from './CourseUnit';
+import tagsDrawerMessages from '../content-tags-drawer/messages';
import { getClipboardUrl } from '../generic/data/api';
import configureModalMessages from '../generic/configure-modal/messages';
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
@@ -92,6 +93,9 @@ jest.mock('react-router-dom', () => ({
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(({ queryKey }) => {
+ const taxonomyApiHooksModule = jest.requireActual('../taxonomy/data/apiHooks');
+ const actualQueryKeys = taxonomyApiHooksModule.taxonomyQueryKeys;
+
if (queryKey[0] === 'contentTaxonomyTags') {
return {
data: {
@@ -105,6 +109,14 @@ jest.mock('@tanstack/react-query', () => ({
isSuccess: true,
};
}
+ if (actualQueryKeys.all.includes(queryKey[0])) {
+ return {
+ data: {
+ results: [],
+ },
+ isSuccess: true,
+ };
+ }
return {
data: {},
isSuccess: true,
@@ -261,6 +273,19 @@ describe('', () => {
});
});
+ it('renders the xBlocks iframe and opens the tags drawer on postMessage event', async () => {
+ const { getByTitle, getByText } = render();
+
+ await waitFor(() => {
+ const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
+ expect(xblocksIframe).toBeInTheDocument();
+ });
+
+ simulatePostMessageEvent(messageTypes.openManageTags, { contentId: blockId });
+
+ expect(getByText(tagsDrawerMessages.headerSubtitle.defaultMessage)).toBeInTheDocument();
+ });
+
it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => {
const { getByTitle, queryByTitle } = render();
@@ -750,6 +775,44 @@ describe('', () => {
)).toBeInTheDocument();
});
+ it('handle creating Text xblock and saves scroll position in localStorage', async () => {
+ const { getByText, getByRole } = render();
+ const xblockType = 'text';
+
+ axiosMock
+ .onPost(postXBlockBaseApiUrl({ type: xblockType, category: 'html', parentLocator: blockId }))
+ .reply(200, courseCreateXblockMock);
+
+ window.scrollTo(0, 250);
+ Object.defineProperty(window, 'scrollY', { value: 250, configurable: true });
+
+ await waitFor(() => {
+ const textButton = screen.getByRole('button', { name: /Text/i });
+
+ expect(getByText(addComponentMessages.title.defaultMessage)).toBeInTheDocument();
+
+ userEvent.click(textButton);
+
+ const addXBlockDialog = getByRole('dialog');
+ expect(addXBlockDialog).toBeInTheDocument();
+
+ expect(getByText(
+ addComponentMessages.modalContainerTitle.defaultMessage.replace('{componentTitle}', xblockType),
+ )).toBeInTheDocument();
+
+ const textRadio = screen.getByRole('radio', { name: /Text/i });
+ userEvent.click(textRadio);
+ expect(textRadio).toBeChecked();
+
+ const selectBtn = getByRole('button', { name: addComponentMessages.modalBtnText.defaultMessage });
+ expect(selectBtn).toBeInTheDocument();
+
+ userEvent.click(selectBtn);
+ });
+
+ expect(localStorage.getItem('createXBlockLastYPosition')).toBe('250');
+ });
+
it('correct addition of a new course unit after click on the "Add new unit" button', async () => {
const { getByRole, getAllByTestId } = render();
let units = null;
diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx
index 7d74995e0..4f71d1b63 100644
--- a/src/course-unit/add-component/AddComponent.jsx
+++ b/src/course-unit/add-component/AddComponent.jsx
@@ -61,7 +61,7 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
- localStorage.setItem('modalEditLastYPosition', window.scrollY);
+ localStorage.setItem('createXBlockLastYPosition', window.scrollY);
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
});
break;
@@ -92,6 +92,7 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
boilerplate: moduleName,
parentLocator: blockId,
}, ({ courseKey, locator }) => {
+ localStorage.setItem('createXBlockLastYPosition', window.scrollY);
navigate(`/course/${courseKey}/editor/html/${locator}`);
});
break;
diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js
index 2b03e789a..09608eed0 100644
--- a/src/course-unit/constants.js
+++ b/src/course-unit/constants.js
@@ -75,4 +75,5 @@ export const messageTypes = {
completeXBlockEditing: 'completeXBlockEditing',
studioAjaxError: 'studioAjaxError',
refreshPositions: 'refreshPositions',
+ openManageTags: 'openManageTags',
};
diff --git a/src/course-unit/context/hooks.tsx b/src/course-unit/context/hooks.tsx
index 9760c07af..ee4ed1666 100644
--- a/src/course-unit/context/hooks.tsx
+++ b/src/course-unit/context/hooks.tsx
@@ -2,7 +2,6 @@ import { useContext } from 'react';
import { IframeContext, IframeContextType } from './iFrameContext';
-// eslint-disable-next-line import/prefer-default-export
export const useIframe = (): IframeContextType => {
const context = useContext(IframeContext);
if (!context) {
diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx
index 21bb8dab1..915aa9ff0 100644
--- a/src/course-unit/hooks.jsx
+++ b/src/course-unit/hooks.jsx
@@ -1,4 +1,6 @@
-import { useCallback, useEffect, useMemo } from 'react';
+import {
+ useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
@@ -259,3 +261,58 @@ export const useLayoutGrid = (unitCategory, isUnitLibraryType) => (
return isUnitLibraryType ? layouts.fullWidth : layouts.default;
}, [unitCategory])
);
+
+/**
+ * Custom hook that restores the scroll position from `localStorage` after a page reload.
+ * It listens for a `plugin.resize` message event and scrolls the window to the saved position
+ * after a 1-second delay, provided no new resize messages are received during that time.
+ *
+ * @param {string} [storageKey='createXBlockLastYPosition'] -
+ * The key used to store the last scroll position in `localStorage`.
+ */
+export const useScrollToLastPosition = (storageKey = 'createXBlockLastYPosition') => {
+ const timeoutRef = useRef(null);
+ const [hasLastPosition, setHasLastPosition] = useState(() => !!localStorage.getItem(storageKey));
+
+ const scrollToLastPosition = useCallback(() => {
+ const lastYPosition = localStorage.getItem(storageKey);
+ if (!lastYPosition) {
+ setHasLastPosition(false);
+ return;
+ }
+
+ const yPosition = parseInt(lastYPosition, 10);
+ if (!Number.isNaN(yPosition)) {
+ window.scrollTo({ top: yPosition, behavior: 'smooth' });
+ localStorage.removeItem(storageKey);
+ setHasLastPosition(false);
+ }
+ }, [storageKey]);
+
+ const handleMessage = useCallback((event) => {
+ if (event.data?.type === messageTypes.resize) {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+
+ timeoutRef.current = setTimeout(scrollToLastPosition, 1000);
+ }
+ }, [scrollToLastPosition]);
+
+ useEffect(() => {
+ if (!hasLastPosition) {
+ return undefined;
+ }
+
+ window.addEventListener('message', handleMessage);
+
+ return () => {
+ window.removeEventListener('message', handleMessage);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, [hasLastPosition, handleMessage]);
+
+ return null;
+};
diff --git a/src/course-unit/hooks.test.jsx b/src/course-unit/hooks.test.jsx
new file mode 100644
index 000000000..eb3917dd4
--- /dev/null
+++ b/src/course-unit/hooks.test.jsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useScrollToLastPosition, useLayoutGrid } from './hooks';
+import { messageTypes } from './constants';
+
+jest.useFakeTimers();
+
+describe('useLayoutGrid', () => {
+ it('returns fullWidth layout when isUnitLibraryType is true', () => {
+ const { result } = renderHook(() => useLayoutGrid('someCategory', true));
+
+ expect(result.current).toEqual({
+ lg: [{ span: 12 }, { span: 0 }],
+ md: [{ span: 12 }, { span: 0 }],
+ sm: [{ span: 12 }, { span: 0 }],
+ xs: [{ span: 12 }, { span: 0 }],
+ xl: [{ span: 12 }, { span: 0 }],
+ });
+ });
+
+ it('returns default layout when isUnitLibraryType is false', () => {
+ const { result } = renderHook(() => useLayoutGrid('someCategory', false));
+
+ expect(result.current).toEqual({
+ lg: [{ span: 8 }, { span: 4 }],
+ md: [{ span: 8 }, { span: 4 }],
+ sm: [{ span: 8 }, { span: 3 }],
+ xs: [{ span: 9 }, { span: 3 }],
+ xl: [{ span: 9 }, { span: 3 }],
+ });
+ });
+
+ it('does not recompute layout if unitCategory remains the same', () => {
+ const { result, rerender } = renderHook(
+ ({ unitCategory, isUnitLibraryType }) => useLayoutGrid(unitCategory, isUnitLibraryType),
+ { initialProps: { unitCategory: 'category1', isUnitLibraryType: false } },
+ );
+
+ const firstResult = result.current;
+
+ rerender({ unitCategory: 'category1', isUnitLibraryType: false });
+
+ expect(result.current).toBe(firstResult);
+ });
+
+ it('recomputes layout when unitCategory changes', () => {
+ const { result, rerender } = renderHook(
+ ({ unitCategory, isUnitLibraryType }) => useLayoutGrid(unitCategory, isUnitLibraryType),
+ { initialProps: { unitCategory: 'category1', isUnitLibraryType: false } },
+ );
+
+ const firstResult = result.current;
+
+ rerender({ unitCategory: 'category2', isUnitLibraryType: false });
+
+ expect(result.current).not.toBe(firstResult);
+ });
+});
+
+describe('useScrollToLastPosition', () => {
+ const storageKey = 'createXBlockLastYPosition';
+ let scrollToSpy;
+ let clearTimeoutSpy;
+ let setTimeoutSpy;
+ let setStateSpy;
+
+ beforeEach(() => {
+ localStorage.clear();
+ scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+ setTimeoutSpy = jest.spyOn(global, 'setTimeout');
+ setStateSpy = jest.spyOn(React, 'useState');
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ scrollToSpy.mockRestore();
+ clearTimeoutSpy.mockRestore();
+ setTimeoutSpy.mockRestore();
+ setStateSpy.mockRestore();
+ });
+
+ it('should not add event listener if no lastYPosition is in localStorage', () => {
+ const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
+
+ renderHook(() => useScrollToLastPosition(storageKey));
+
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('message', expect.any(Function));
+
+ addEventListenerSpy.mockRestore();
+ });
+
+ it('should add event listener if lastYPosition exists in localStorage', () => {
+ localStorage.setItem(storageKey, '500');
+
+ const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
+
+ renderHook(() => useScrollToLastPosition(storageKey));
+
+ expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function));
+
+ addEventListenerSpy.mockRestore();
+ });
+
+ it('should scroll to saved position on message event', () => {
+ localStorage.setItem(storageKey, '500');
+
+ const { unmount } = renderHook(() => useScrollToLastPosition(storageKey));
+
+ act(() => {
+ window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
+ jest.advanceTimersByTime(1000);
+ });
+
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: 'smooth' });
+ expect(localStorage.getItem(storageKey)).toBeNull();
+
+ unmount();
+ });
+
+ it('should clear timeout and remove event listener on unmount', () => {
+ localStorage.setItem(storageKey, '500');
+
+ const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
+
+ const { unmount } = renderHook(() => useScrollToLastPosition(storageKey));
+
+ unmount();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function));
+ });
+
+ it('should clear previous timeout before setting a new one on multiple message events', () => {
+ localStorage.setItem(storageKey, '500');
+
+ renderHook(() => useScrollToLastPosition(storageKey));
+
+ act(() => {
+ window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
+ window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
+ });
+
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ expect(setTimeoutSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should not scroll if multiple message events prevent execution of setTimeout callback', () => {
+ localStorage.setItem(storageKey, '500');
+
+ renderHook(() => useScrollToLastPosition(storageKey));
+
+ act(() => {
+ window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
+ jest.advanceTimersByTime(500);
+ window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
+ });
+
+ expect(window.scrollTo).not.toHaveBeenCalled();
+ });
+
+ it('should scroll only after the final timeout', () => {
+ localStorage.setItem(storageKey, '500');
+
+ renderHook(() => useScrollToLastPosition(storageKey));
+
+ act(() => {
+ window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
+ jest.advanceTimersByTime(1000);
+ });
+
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: 'smooth' });
+ });
+
+ it('should NOT set hasLastPosition to false if lastYPosition exists and is valid', () => {
+ localStorage.setItem(storageKey, '500');
+
+ renderHook(() => useScrollToLastPosition(storageKey));
+
+ expect(setStateSpy).not.toHaveBeenCalledWith(false);
+ });
+});
diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts
index 840d82dbf..73b8441c1 100644
--- a/src/course-unit/xblock-container-iframe/hooks/types.ts
+++ b/src/course-unit/xblock-container-iframe/hooks/types.ts
@@ -11,6 +11,7 @@ export type UseMessageHandlersTypes = {
handleCloseLegacyEditorXBlockModal: () => void;
handleSaveEditedXBlockData: () => void;
handleFinishXBlockDragging: () => void;
+ handleOpenManageTagsModal: (id: string) => void;
};
export type MessageHandlersTypes = Record void>;
diff --git a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx
index 1c2879ec9..9ab881917 100644
--- a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx
+++ b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx
@@ -26,6 +26,7 @@ export const useMessageHandlers = ({
handleCloseLegacyEditorXBlockModal,
handleSaveEditedXBlockData,
handleFinishXBlockDragging,
+ handleOpenManageTagsModal,
}: UseMessageHandlersTypes): MessageHandlersTypes => useMemo(() => ({
[messageTypes.copyXBlock]: ({ usageId }) => dispatch(copyToClipboard(usageId)),
[messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId),
@@ -41,6 +42,7 @@ export const useMessageHandlers = ({
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
[messageTypes.studioAjaxError]: ({ error }) => handleResponseErrors(error, dispatch, updateSavingStatus),
[messageTypes.refreshPositions]: handleFinishXBlockDragging,
+ [messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId),
}), [
courseId,
handleDeleteXBlock,
diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx
index 6e6df33ab..e297053a9 100644
--- a/src/course-unit/xblock-container-iframe/index.tsx
+++ b/src/course-unit/xblock-container-iframe/index.tsx
@@ -2,7 +2,7 @@ import {
useRef, FC, useEffect, useState, useMemo, useCallback,
} from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { useToggle } from '@openedx/paragon';
+import { useToggle, Sheet } from '@openedx/paragon';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
@@ -10,6 +10,7 @@ import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import ModalIframe from '../../generic/modal-iframe';
import { IFRAME_FEATURE_POLICY } from '../../constants';
+import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer';
import supportedEditors from '../../editors/supportedEditors';
import { useIframe } from '../context/hooks';
import { updateCourseUnitSidebar } from '../data/thunk';
@@ -43,6 +44,7 @@ const XBlockContainerIframe: FC = ({
const [deleteXBlockId, setDeleteXBlockId] = useState(null);
const [configureXBlockId, setConfigureXBlockId] = useState(null);
const [showLegacyEditModal, setShowLegacyEditModal] = useState(false);
+ const [isManageTagsOpen, openManageTagsModal, closeManageTagsModal] = useToggle(false);
const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]);
const legacyEditModalUrl = useMemo(() => getLegacyEditModalUrl(configureXBlockId), [configureXBlockId]);
@@ -120,6 +122,11 @@ const XBlockContainerIframe: FC = ({
dispatch(updateCourseUnitSidebar(blockId));
};
+ const handleOpenManageTagsModal = (id: string) => {
+ setConfigureXBlockId(id);
+ openManageTagsModal();
+ };
+
const messageHandlers = useMessageHandlers({
courseId,
navigate,
@@ -133,6 +140,7 @@ const XBlockContainerIframe: FC = ({
handleCloseLegacyEditorXBlockModal,
handleSaveEditedXBlockData,
handleFinishXBlockDragging,
+ handleOpenManageTagsModal,
});
useIframeMessages(messageHandlers);
@@ -167,6 +175,7 @@ const XBlockContainerIframe: FC = ({