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 = ({