feat: [FC-0070] Manage Tags interoperation (#1454)

Added interaction between MFE and Legacy tagging functionality for xblocks on the Course unit page.
This commit is contained in:
Peter Kulko
2025-03-03 06:07:04 -08:00
committed by GitHub
parent 06d591df13
commit 411607ec59
11 changed files with 355 additions and 21 deletions

View File

@@ -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);
});
}
},

View File

@@ -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,

View File

@@ -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('<CourseUnit />', () => {
});
});
it('renders the xBlocks iframe and opens the tags drawer on postMessage event', async () => {
const { getByTitle, getByText } = render(<RootWrapper />);
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(<RootWrapper />);
@@ -750,6 +775,44 @@ describe('<CourseUnit />', () => {
)).toBeInTheDocument();
});
it('handle creating Text xblock and saves scroll position in localStorage', async () => {
const { getByText, getByRole } = render(<RootWrapper />);
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(<RootWrapper />);
let units = null;

View File

@@ -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;

View File

@@ -75,4 +75,5 @@ export const messageTypes = {
completeXBlockEditing: 'completeXBlockEditing',
studioAjaxError: 'studioAjaxError',
refreshPositions: 'refreshPositions',
openManageTags: 'openManageTags',
};

View File

@@ -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) {

View File

@@ -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;
};

View File

@@ -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);
});
});

View File

@@ -11,6 +11,7 @@ export type UseMessageHandlersTypes = {
handleCloseLegacyEditorXBlockModal: () => void;
handleSaveEditedXBlockData: () => void;
handleFinishXBlockDragging: () => void;
handleOpenManageTagsModal: (id: string) => void;
};
export type MessageHandlersTypes = Record<string, (payload: any) => void>;

View File

@@ -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,

View File

@@ -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<XBlockContainerIframeProps> = ({
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
const [configureXBlockId, setConfigureXBlockId] = useState<string | null>(null);
const [showLegacyEditModal, setShowLegacyEditModal] = useState<boolean>(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<XBlockContainerIframeProps> = ({
dispatch(updateCourseUnitSidebar(blockId));
};
const handleOpenManageTagsModal = (id: string) => {
setConfigureXBlockId(id);
openManageTagsModal();
};
const messageHandlers = useMessageHandlers({
courseId,
navigate,
@@ -133,6 +140,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleCloseLegacyEditorXBlockModal,
handleSaveEditedXBlockData,
handleFinishXBlockDragging,
handleOpenManageTagsModal,
});
useIframeMessages(messageHandlers);
@@ -167,6 +175,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
<iframe
ref={iframeRef}
title={intl.formatMessage(messages.xblockIframeTitle)}
name="xblock-iframe"
src={iframeUrl}
frameBorder="0"
allow={IFRAME_FEATURE_POLICY}
@@ -177,6 +186,16 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
referrerPolicy="origin"
aria-label={intl.formatMessage(messages.xblockIframeLabel, { xblockCount: courseVerticalChildren.length })}
/>
{configureXBlockId && (
<Sheet
position="right"
show={isManageTagsOpen}
onClose={closeManageTagsModal}
blocking
>
<ContentTagsDrawer id={configureXBlockId} onClose={closeManageTagsModal} />
</Sheet>
)}
</>
);
};