feat: render iframe with xblocks (#1375)

This refactors the CourseUnit component by removing the DraggableList and CourseXBlock components and replacing them with a simpler XBlockContainerIframe. Additionally, it introduces new constants for iframe handling.
This commit is contained in:
Peter Kulko
2024-11-08 15:53:32 +02:00
committed by GitHub
parent f9ef00e29f
commit e59f2846e3
26 changed files with 591 additions and 1343 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
@@ -6,9 +6,7 @@ import { Container, Layout, Stack } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, injectIntl } from '@edx/frontend-platform/i18n';
import { Warning as WarningIcon } from '@openedx/paragon/icons';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import DraggableList from '../editors/sharedComponents/DraggableList';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import SubHeader from '../generic/sub-header/SubHeader';
import { RequestStatus } from '../data/constants';
@@ -20,7 +18,6 @@ import { SavingErrorAlert } from '../generic/saving-error-alert';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import Loading from '../generic/Loading';
import AddComponent from './add-component/AddComponent';
import CourseXBlock from './course-xblock/CourseXBlock';
import HeaderTitle from './header-title/HeaderTitle';
import Breadcrumbs from './breadcrumbs/Breadcrumbs';
import HeaderNavigations from './header-navigations/HeaderNavigations';
@@ -32,6 +29,7 @@ import PublishControls from './sidebar/PublishControls';
import LocationInfo from './sidebar/LocationInfo';
import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
import { PasteNotificationAlert } from './clipboard';
import XBlockContainerIframe from './xblock-container-iframe';
const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
@@ -56,21 +54,13 @@ const CourseUnit = ({ courseId }) => {
handleCreateNewCourseXBlock,
handleConfigureSubmit,
courseVerticalChildren,
handleXBlockDragAndDrop,
canPasteComponent,
} = useCourseUnit({ courseId, blockId });
const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]);
const [unitXBlocks, setUnitXBlocks] = useState(initialXBlocksData);
useEffect(() => {
document.title = getPageHeadTitle('', unitTitle);
}, [unitTitle]);
useEffect(() => {
setUnitXBlocks(courseVerticalChildren.children);
}, [courseVerticalChildren.children]);
const {
isShow: isShowProcessingNotification,
title: processingNotificationTitle,
@@ -88,12 +78,6 @@ const CourseUnit = ({ courseId }) => {
);
}
const finalizeXBlockOrder = () => (newXBlocks) => {
handleXBlockDragAndDrop(newXBlocks.map(xBlock => xBlock.id), () => {
setUnitXBlocks(initialXBlocksData);
});
};
return (
<>
<Container size="xl" className="course-unit px-4">
@@ -147,37 +131,11 @@ const CourseUnit = ({ courseId }) => {
courseId={courseId}
/>
)}
<Stack className="mb-4 course-unit__xblocks">
<DraggableList
itemList={unitXBlocks}
setState={setUnitXBlocks}
updateOrder={finalizeXBlockOrder}
>
<SortableContext
id="root"
items={unitXBlocks}
strategy={verticalListSortingStrategy}
>
{unitXBlocks.map(({
name, id, blockType: type, shouldScroll, userPartitionInfo, validationMessages,
}) => (
<CourseXBlock
id={id}
key={id}
title={name}
type={type}
blockId={blockId}
validationMessages={validationMessages}
shouldScroll={shouldScroll}
handleConfigureSubmit={handleConfigureSubmit}
unitXBlockActions={unitXBlockActions}
data-testid="course-xblock"
userPartitionInfo={userPartitionInfo}
/>
))}
</SortableContext>
</DraggableList>
</Stack>
<XBlockContainerIframe
blockId={blockId}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
/>
<AddComponent
blockId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}

View File

@@ -1,7 +1,6 @@
@import "./breadcrumbs/Breadcrumbs";
@import "./course-sequence/CourseSequence";
@import "./add-component/AddComponent";
@import "./course-xblock/CourseXBlock";
@import "./sidebar/Sidebar";
@import "./header-title/HeaderTitle";

View File

@@ -23,7 +23,6 @@ import {
} from './data/api';
import {
createNewCourseXBlock,
deleteUnitItemQuery,
editCourseUnitVisibilityAndData,
fetchCourseSectionVerticalData,
fetchCourseUnitQuery,
@@ -38,12 +37,8 @@ import {
courseVerticalChildrenMock,
clipboardMockResponse,
} from './__mocks__';
import {
clipboardUnit,
clipboardXBlock,
} from '../__mocks__';
import { clipboardUnit } from '../__mocks__';
import { executeThunk } from '../utils';
import deleteModalMessages from '../generic/delete-modal/messages';
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
import pasteNotificationsMessages from './clipboard/paste-notification/messages';
import headerNavigationsMessages from './header-navigations/messages';
@@ -54,12 +49,10 @@ import { extractCourseUnitId } from './sidebar/utils';
import CourseUnit from './CourseUnit';
import configureModalMessages from '../generic/configure-modal/messages';
import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
import messages from './messages';
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
import { RequestStatus } from '../data/constants';
let axiosMock;
let store;
@@ -556,76 +549,6 @@ describe('<CourseUnit />', () => {
});
});
it('checks whether xblock is deleted when corresponding delete button is clicked', async () => {
axiosMock
.onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id))
.replyOnce(200, { dummy: 'value' });
const {
getByText,
getAllByLabelText,
getByRole,
getAllByTestId,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(unitDisplayName)).toBeInTheDocument();
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);
const deleteBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonDelete.defaultMessage });
userEvent.click(deleteBtn);
expect(getByText(/Delete this component?/)).toBeInTheDocument();
const deleteConfirmBtn = getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage });
userEvent.click(deleteConfirmBtn);
expect(getAllByTestId('course-xblock')).toHaveLength(1);
});
});
it('checks whether xblock is duplicate when corresponding delete button is clicked', async () => {
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.replyOnce(200, { locator: '1234567890' });
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, {
...courseVerticalChildrenMock,
children: [
...courseVerticalChildrenMock.children,
{
name: 'New Cloned XBlock',
block_id: '1234567890',
block_type: 'drag-and-drop-v2',
user_partition_info: {},
},
],
});
const {
getByText,
getAllByLabelText,
getAllByTestId,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(unitDisplayName)).toBeInTheDocument();
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);
const duplicateBtn = getByText(courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage);
userEvent.click(duplicateBtn);
expect(getAllByTestId('course-xblock')).toHaveLength(3);
expect(getByText('New Cloned XBlock')).toBeInTheDocument();
});
});
it('should toggle visibility from sidebar and update course unit state accordingly', async () => {
const { getByRole, getByTestId } = render(<RootWrapper />);
let courseUnitSidebar;
@@ -792,189 +715,6 @@ describe('<CourseUnit />', () => {
expect(discardChangesBtn).not.toBeInTheDocument();
});
it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => {
const {
getByText,
getAllByLabelText,
getByRole,
getAllByTestId,
queryByRole,
} = render(<RootWrapper />);
await waitFor(() => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.makePublic,
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
axiosMock
.onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id))
.replyOnce(200, { dummy: 'value' });
await executeThunk(deleteUnitItemQuery(courseId, blockId), store.dispatch);
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(getByText(unitDisplayName)).toBeInTheDocument();
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);
const deleteBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonDelete.defaultMessage });
userEvent.click(deleteBtn);
expect(getByText(/Delete this component?/)).toBeInTheDocument();
const deleteConfirmBtn = getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage });
userEvent.click(deleteConfirmBtn);
expect(getAllByTestId('course-xblock')).toHaveLength(1);
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after removing the xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
)).toBeInTheDocument();
expect(getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument();
});
it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => {
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.replyOnce(200, { locator: '1234567890' });
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, {
...courseVerticalChildrenMock,
children: [
...courseVerticalChildrenMock.children,
{
...courseVerticalChildrenMock.children[0],
name: 'New Cloned XBlock',
},
],
});
const {
getByText,
getAllByLabelText,
getAllByTestId,
queryByRole,
getByRole,
} = render(<RootWrapper />);
await waitFor(() => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.makePublic,
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(getByText(unitDisplayName)).toBeInTheDocument();
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);
const duplicateBtn = getByText(courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage);
userEvent.click(duplicateBtn);
expect(getAllByTestId('course-xblock')).toHaveLength(3);
expect(getByText('New Cloned XBlock')).toBeInTheDocument();
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after duplicate the xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
)).toBeInTheDocument();
expect(getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument();
});
it('should toggle visibility from header configure modal and update course unit state accordingly', async () => {
const { getByRole, getByTestId } = render(<RootWrapper />);
let courseUnitSidebar;
@@ -1063,159 +803,6 @@ describe('<CourseUnit />', () => {
});
describe('Copy paste functionality', () => {
it('should display "Copy Unit" action button after enabling copy-paste units', async () => {
const { queryByText, queryByRole } = render(<RootWrapper />);
await waitFor(() => {
expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull();
expect(queryByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeNull();
});
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
expect(queryByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })).toBeInTheDocument();
});
it('should display clipboard information in popover when hovering over What\'s in clipboard text', async () => {
const {
queryByTestId, getByRole, getAllByLabelText, getByText,
} = render(<RootWrapper />);
await waitFor(() => {
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardXBlock,
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
const whatsInClipboardText = getByText(
pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage,
);
userEvent.hover(whatsInClipboardText);
const popoverContent = queryByTestId('popover-content');
expect(popoverContent.tagName).toBe('A');
expect(popoverContent).toHaveAttribute('href', clipboardXBlock.sourceEditUrl);
expect(within(popoverContent).getByText(clipboardXBlock.content.displayName)).toBeInTheDocument();
expect(within(popoverContent).getByText(clipboardXBlock.sourceContextTitle)).toBeInTheDocument();
expect(within(popoverContent).getByText(clipboardXBlock.content.blockTypeDisplay)).toBeInTheDocument();
fireEvent.blur(whatsInClipboardText);
await waitFor(() => expect(queryByTestId('popover-content')).toBeNull());
fireEvent.focus(whatsInClipboardText);
await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument());
fireEvent.mouseLeave(whatsInClipboardText);
await waitFor(() => expect(queryByTestId('popover-content')).toBeNull());
fireEvent.mouseEnter(whatsInClipboardText);
await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument());
});
it('should increase the number of course XBlocks after copying and pasting a block', async () => {
const {
getAllByTestId, getByRole, getAllByLabelText,
} = render(<RootWrapper />);
await waitFor(() => {
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardXBlock,
});
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage }));
await waitFor(() => {
expect(getAllByTestId('course-xblock')).toHaveLength(2);
});
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, {
...courseVerticalChildrenMock,
children: [
...courseVerticalChildrenMock.children,
{
name: 'Copy XBlock',
block_id: '1234567890',
block_type: 'drag-and-drop-v2',
user_partition_info: {
selectable_partitions: [],
selected_partition_index: -1,
selected_groups_label: '',
},
},
],
});
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
expect(getAllByTestId('course-xblock')).toHaveLength(3);
});
it('should display the "Paste component" button after copying a xblock to clipboard', async () => {
const { getByRole, getAllByLabelText } = render(<RootWrapper />);
await waitFor(() => {
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardXBlock,
});
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
});
it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => {
const {
getAllByTestId, getByRole,
@@ -1474,56 +1061,4 @@ describe('<CourseUnit />', () => {
)).not.toBeInTheDocument();
});
});
describe('Drag and drop', () => {
it('checks xblock list is restored to original order when API call fails', async () => {
const { findAllByRole } = render(<RootWrapper />);
const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = xBlocksDraggers[1];
axiosMock
.onPut(getXBlockBaseApiUrl(blockId))
.reply(500, { dummy: 'value' });
const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseUnit.savingStatus;
expect(saveStatus).toEqual(RequestStatus.FAILED);
});
const xBlock1New = store.getState().courseUnit.courseVerticalChildren.children[0].id;
expect(xBlock1).toBe(xBlock1New);
});
it('check that new xblock list is saved when dragged', async () => {
const { findAllByRole } = render(<RootWrapper />);
const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = xBlocksDraggers[1];
axiosMock
.onPut(getXBlockBaseApiUrl(blockId))
.reply(200, { dummy: 'value' });
const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseUnit.savingStatus;
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
});
const xBlock2 = store.getState().courseUnit.courseVerticalChildren.children[1].id;
expect(xBlock1).toBe(xBlock2);
});
});
});

View File

@@ -38,3 +38,20 @@ export const getXBlockSupportMessages = (intl) => ({
tooltip: intl.formatMessage(addComponentMessages.modalComponentSupportTooltipNotSupported),
},
});
export const stateKeys = {
iframeHeight: 'iframeHeight',
hasLoaded: 'hasLoaded',
showError: 'showError',
windowTopOffset: 'windowTopOffset',
};
export const messageTypes = {
modal: 'plugin.modal',
resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen',
};
export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
);

View File

@@ -0,0 +1,57 @@
import { renderHook } from '@testing-library/react-hooks';
import { useSelector } from 'react-redux';
import { useSequenceNavigationMetadata } from './hooks';
import { getCourseSectionVertical, getSequenceIds } from '../data/selectors';
import { useModel } from '../../generic/model-store';
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('../../generic/model-store', () => ({
useModel: jest.fn(),
}));
jest.mock('@openedx/paragon', () => ({
useWindowSize: jest.fn(),
}));
describe('useSequenceNavigationMetadata', () => {
const mockCourseId = 'course-v1:example';
const mockCurrentSequenceId = 'sequence-1';
const mockCurrentUnitId = 'unit-1';
const mockNextUrl = '/next-url';
const mockPrevUrl = '/prev-url';
const mockSequenceIds = ['sequence-1', 'sequence-2'];
const mockSequence = {
unitIds: ['unit-1', 'unit-2'],
};
beforeEach(() => {
useSelector.mockImplementation((selector) => {
if (selector === getCourseSectionVertical) { return { nextUrl: mockNextUrl, prevUrl: mockPrevUrl }; }
if (selector === getSequenceIds) { return mockSequenceIds; }
return null;
});
useModel.mockReturnValue(mockSequence);
});
it('sets isLastUnit to true if no nextUrl is provided', () => {
useSelector.mockReturnValueOnce({ nextUrl: null, prevUrl: mockPrevUrl });
const { result } = renderHook(
() => useSequenceNavigationMetadata(mockCourseId, mockCurrentSequenceId, mockCurrentUnitId),
);
expect(result.current.isLastUnit).toBe(true);
});
it('sets isFirstUnit to true if no prevUrl is provided', () => {
useSelector.mockReturnValueOnce({ nextUrl: mockNextUrl, prevUrl: null });
const { result } = renderHook(
() => useSequenceNavigationMetadata(mockCourseId, mockCurrentSequenceId, mockCurrentUnitId),
);
expect(result.current.isFirstUnit).toBe(true);
});
});

View File

@@ -1,196 +0,0 @@
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import {
ActionRow, Card, Dropdown, Icon, IconButton, useToggle,
} from '@openedx/paragon';
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSearchParams } from 'react-router-dom';
import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import SortableItem from '../../generic/drag-helper/SortableItem';
import { scrollToElement } from '../../course-outline/utils';
import { COURSE_BLOCK_NAMES } from '../../constants';
import { copyToClipboard } from '../../generic/data/thunks';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import XBlockMessages from './xblock-messages/XBlockMessages';
import messages from './messages';
import { createCorrectInternalRoute } from '../../utils';
const CourseXBlock = ({
id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo,
handleConfigureSubmit, validationMessages, ...props
}) => {
const courseXBlockElementRef = useRef(null);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const dispatch = useDispatch();
const canEdit = useSelector(getCanEdit);
const courseId = useSelector(getCourseId);
const intl = useIntl();
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const isScrolledToElement = locatorId === id;
const visibilityMessage = userPartitionInfo.selectedGroupsLabel
? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel })
: null;
const currentItemData = {
category: COURSE_BLOCK_NAMES.component.id,
displayName: title,
userPartitionInfo,
showCorrectness: 'always',
};
const onDeleteSubmit = () => {
unitXBlockActions.handleDelete(id);
closeDeleteModal();
};
const handleEdit = () => {
switch (type) {
case COMPONENT_TYPES.html:
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
// Not using useNavigate from react router to use browser navigation
// which allows us to block back button if unsaved changes in editor are present.
window.location.assign(
createCorrectInternalRoute(`/course/${courseId}/editor/${type}/${id}`),
);
break;
default:
}
};
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(id, ...arg, closeConfigureModal);
};
useEffect(() => {
// if this item has been newly added, scroll to it.
if (courseXBlockElementRef.current && (shouldScroll || isScrolledToElement)) {
scrollToElement(courseXBlockElementRef.current);
}
}, [isScrolledToElement]);
return (
<div
ref={courseXBlockElementRef}
{...props}
className={classNames('course-unit__xblock', {
'xblock-highlight': isScrolledToElement,
})}
>
<Card
as={SortableItem}
id={id}
draggable
category="xblock"
componentStyle={{ marginBottom: 0 }}
>
<Card.Header
title={title}
subtitle={visibilityMessage}
actions={(
<ActionRow className="mr-2">
<IconButton
alt={intl.formatMessage(messages.blockAltButtonEdit)}
iconAs={EditIcon}
onClick={handleEdit}
/>
<Dropdown>
<Dropdown.Toggle
id={id}
as={IconButton}
src={MoveVertIcon}
alt={intl.formatMessage(messages.blockActionsDropdownAlt)}
iconAs={Icon}
/>
<Dropdown.Menu>
<Dropdown.Item onClick={() => unitXBlockActions.handleDuplicate(id)}>
{intl.formatMessage(messages.blockLabelButtonDuplicate)}
</Dropdown.Item>
<Dropdown.Item>
{intl.formatMessage(messages.blockLabelButtonMove)}
</Dropdown.Item>
{canEdit && (
<Dropdown.Item onClick={() => dispatch(copyToClipboard(id))}>
{intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
</Dropdown.Item>
)}
<Dropdown.Item onClick={openConfigureModal}>
{intl.formatMessage(messages.blockLabelButtonManageAccess)}
</Dropdown.Item>
<Dropdown.Item onClick={openDeleteModal}>
{intl.formatMessage(messages.blockLabelButtonDelete)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<DeleteModal
category="component"
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
onDeleteSubmit={onDeleteSubmit}
/>
<ConfigureModal
isXBlockComponent
isOpen={isConfigureModalOpen}
onClose={closeConfigureModal}
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentItemData}
/>
</ActionRow>
)}
/>
<Card.Section>
<XBlockMessages validationMessages={validationMessages} />
<div className="w-100 bg-gray-100" style={{ height: 200 }} data-block-id={id} />
</Card.Section>
</Card>
</div>
);
};
CourseXBlock.defaultProps = {
validationMessages: [],
shouldScroll: false,
};
CourseXBlock.propTypes = {
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
shouldScroll: PropTypes.bool,
validationMessages: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.string,
text: PropTypes.string,
})),
unitXBlockActions: PropTypes.shape({
handleDelete: PropTypes.func,
handleDuplicate: PropTypes.func,
}).isRequired,
userPartitionInfo: PropTypes.shape({
selectablePartitions: PropTypes.arrayOf(PropTypes.shape({
groups: PropTypes.arrayOf(PropTypes.shape({
deleted: PropTypes.bool,
id: PropTypes.number,
name: PropTypes.string,
selected: PropTypes.bool,
})),
id: PropTypes.number,
name: PropTypes.string,
scheme: PropTypes.string,
})),
selectedPartitionIndex: PropTypes.number,
selectedGroupsLabel: PropTypes.string,
}).isRequired,
handleConfigureSubmit: PropTypes.func.isRequired,
};
export default CourseXBlock;

View File

@@ -1,36 +0,0 @@
.course-unit {
.course-unit__xblocks {
.course-unit__xblock:not(:first-child) {
margin-top: 1.75rem;
}
.pgn__card-header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid $light-400;
padding-bottom: map-get($spacers, 2);
&:not(:has(.pgn__card-header-subtitle-md)) {
align-items: center;
}
}
.pgn__card-header-subtitle-md {
margin-top: 0;
font-size: $font-size-sm;
}
.pgn__card-header-title-md {
font: 700 1.375rem/1.75rem $font-family-sans-serif;
color: $black;
}
.pgn__card-section {
padding: map-get($spacers, 3\.5) 0;
}
}
.unit-iframe__wrapper .alert-danger {
margin-bottom: 0;
}
}

View File

@@ -1,318 +0,0 @@
import {
render, waitFor, within,
} from '@testing-library/react';
import { useSelector } from 'react-redux';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import configureModalMessages from '../../generic/configure-modal/messages';
import deleteModalMessages from '../../generic/delete-modal/messages';
import initializeStore from '../../store';
import { getCourseSectionVerticalApiUrl, getXBlockBaseApiUrl } from '../data/api';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { executeThunk } from '../../utils';
import { getCourseId } from '../data/selectors';
import { PUBLISH_TYPES } from '../constants';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__';
import CourseXBlock from './CourseXBlock';
import messages from './messages';
let axiosMock;
let store;
const courseId = '1234';
const blockId = '567890';
const handleDeleteMock = jest.fn();
const handleDuplicateMock = jest.fn();
const handleConfigureSubmitMock = jest.fn();
const {
name,
block_id: id,
block_type: type,
user_partition_info: userPartitionInfo,
} = courseVerticalChildrenMock.children[0];
const userPartitionInfoFormatted = camelCaseObject(userPartitionInfo);
const unitXBlockActionsMock = {
handleDelete: handleDeleteMock,
handleDuplicate: handleDuplicateMock,
};
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
const renderComponent = (props) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseXBlock
id={id}
title={name}
type={type}
blockId={blockId}
unitXBlockActions={unitXBlockActionsMock}
userPartitionInfo={userPartitionInfoFormatted}
shouldScroll={false}
handleConfigureSubmit={handleConfigureSubmitMock}
{...props}
/>
</IntlProvider>
</AppProvider>,
);
useSelector.mockImplementation((selector) => {
if (selector === getCourseId) {
return courseId;
}
return null;
});
describe('<CourseXBlock />', () => {
const locationTemp = window.location;
beforeAll(() => {
delete window.location;
window.location = {
assign: jest.fn(),
};
});
afterAll(() => {
window.location = locationTemp;
});
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
});
it('render CourseXBlock component correctly', async () => {
const { getByText, getByLabelText } = renderComponent();
await waitFor(() => {
expect(getByText(name)).toBeInTheDocument();
expect(getByLabelText(messages.blockAltButtonEdit.defaultMessage)).toBeInTheDocument();
expect(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)).toBeInTheDocument();
});
});
it('render CourseXBlock component action dropdown correctly', async () => {
const { getByRole, getByLabelText } = renderComponent();
await waitFor(() => {
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
expect(getByRole('button', { name: messages.blockLabelButtonDuplicate.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.blockLabelButtonMove.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.blockLabelButtonManageAccess.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.blockLabelButtonDelete.defaultMessage })).toBeInTheDocument();
});
});
it('calls handleDuplicate when item is clicked', async () => {
const { getByText, getByLabelText } = renderComponent();
await waitFor(() => {
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
const duplicateBtn = getByText(messages.blockLabelButtonDuplicate.defaultMessage);
userEvent.click(duplicateBtn);
expect(handleDuplicateMock).toHaveBeenCalledTimes(1);
expect(handleDuplicateMock).toHaveBeenCalledWith(id);
});
});
it('opens confirm delete modal and calls handleDelete when deleting was confirmed', async () => {
const { getByText, getByLabelText, getByRole } = renderComponent();
await waitFor(() => {
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
const deleteBtn = getByText(messages.blockLabelButtonDelete.defaultMessage);
userEvent.click(deleteBtn);
expect(getByText(/Delete this component?/)).toBeInTheDocument();
expect(getByText(/Deleting this component is permanent and cannot be undone./)).toBeInTheDocument();
expect(getByRole('button', { name: deleteModalMessages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage })).toBeInTheDocument();
userEvent.click(getByRole('button', { name: deleteModalMessages.cancelButton.defaultMessage }));
expect(handleDeleteMock).not.toHaveBeenCalled();
userEvent.click(getByText(messages.blockLabelButtonDelete.defaultMessage));
expect(getByText(/Delete this component?/)).toBeInTheDocument();
userEvent.click(deleteBtn);
userEvent.click(getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage }));
expect(handleDeleteMock).toHaveBeenCalled();
expect(handleDeleteMock).toHaveBeenCalledWith(id);
});
});
describe('edit', () => {
it('navigates to editor page on edit HTML xblock', () => {
const { getByText, getByRole } = renderComponent({
type: COMPONENT_TYPES.html,
});
const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage });
expect(getByText(name)).toBeInTheDocument();
expect(editButton).toBeInTheDocument();
userEvent.click(editButton);
expect(window.location.assign).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`);
});
it('navigates to editor page on edit Video xblock', () => {
const { getByText, getByRole } = renderComponent({
type: COMPONENT_TYPES.video,
});
const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage });
expect(getByText(name)).toBeInTheDocument();
expect(editButton).toBeInTheDocument();
userEvent.click(editButton);
expect(window.location.assign).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`);
});
it('navigates to editor page on edit Problem xblock', () => {
const { getByText, getByRole } = renderComponent({
type: COMPONENT_TYPES.problem,
});
const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage });
expect(getByText(name)).toBeInTheDocument();
expect(editButton).toBeInTheDocument();
userEvent.click(editButton);
expect(window.location.assign).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`);
expect(handleDeleteMock).toHaveBeenCalledWith(id);
});
});
describe('restrict access', () => {
it('opens restrict access modal successfully', async () => {
const {
getByText,
getByLabelText,
findByTestId,
} = renderComponent();
const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage;
const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage;
const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage;
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage);
userEvent.click(accessBtn);
const configureModal = await findByTestId('configure-modal');
expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument();
expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument();
expect(within(configureModal).getByRole('button', { name: modalSaveBtnText })).toBeInTheDocument();
});
it('closes restrict access modal when cancel button is clicked', async () => {
const {
getByText,
getByLabelText,
findByTestId,
} = renderComponent();
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage);
userEvent.click(accessBtn);
const configureModal = await findByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
userEvent.click(within(configureModal).getByRole('button', { name: configureModalMessages.saveButton.defaultMessage }));
expect(handleConfigureSubmitMock).not.toHaveBeenCalled();
});
it('handles submit restrict access data when save button is clicked', async () => {
axiosMock
.onPost(getXBlockBaseApiUrl(id), {
publish: PUBLISH_TYPES.republish,
metadata: { visible_to_staff_only: false, group_access: { 970807507: [1959537066] } },
})
.reply(200, { dummy: 'value' });
const {
getByText,
getByLabelText,
findByTestId,
getByRole,
} = renderComponent();
const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name;
const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name;
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage);
userEvent.click(accessBtn);
const configureModal = await findByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument();
const restrictAccessSelect = getByRole('combobox', {
name: configureModalMessages.restrictAccessTo.defaultMessage,
});
userEvent.selectOptions(restrictAccessSelect, '0');
// eslint-disable-next-line array-callback-return
userPartitionInfoFormatted.selectablePartitions[0].groups.map((group) => {
expect(within(configureModal).getByRole('checkbox', { name: group.name })).not.toBeChecked();
expect(within(configureModal).queryByText(group.name)).toBeInTheDocument();
});
const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 });
userEvent.click(group1Checkbox);
expect(group1Checkbox).toBeChecked();
const saveModalBtnText = within(configureModal).getByRole('button', {
name: configureModalMessages.saveButton.defaultMessage,
});
expect(saveModalBtnText).toBeInTheDocument();
userEvent.click(saveModalBtnText);
await waitFor(() => {
expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1);
});
});
});
it('displays a visibility message if item has accessible restrictions', async () => {
const { getByText } = renderComponent(
{
userPartitionInfo: {
...userPartitionInfoFormatted,
selectedGroupsLabel: 'Visibility group 1',
},
},
);
await waitFor(() => {
const visibilityMessage = messages.visibilityMessage.defaultMessage
.replace('{selectedGroupsLabel}', 'Visibility group 1');
expect(getByText(visibilityMessage)).toBeInTheDocument();
});
});
});

View File

@@ -1,5 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export const MESSAGE_ERROR_TYPES = {
error: 'error',
warning: 'warning',
};

View File

@@ -1,55 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
blockAltButtonEdit: {
id: 'course-authoring.course-unit.xblock.button.edit.alt',
defaultMessage: 'Edit',
description: 'The xblock edit button text',
},
blockActionsDropdownAlt: {
id: 'course-authoring.course-unit.xblock.button.actions.alt',
defaultMessage: 'Actions',
description: 'The xblock three dots dropdown alt text',
},
blockLabelButtonCopy: {
id: 'course-authoring.course-unit.xblock.button.copy.label',
defaultMessage: 'Copy',
description: 'The xblock copy button text',
},
blockLabelButtonDuplicate: {
id: 'course-authoring.course-unit.xblock.button.duplicate.label',
defaultMessage: 'Duplicate',
description: 'The xblock duplicate button text',
},
blockLabelButtonMove: {
id: 'course-authoring.course-unit.xblock.button.move.label',
defaultMessage: 'Move',
description: 'The xblock move button text',
},
blockLabelButtonCopyToClipboard: {
id: 'course-authoring.course-unit.xblock.button.copyToClipboard.label',
defaultMessage: 'Copy to clipboard',
},
blockLabelButtonManageAccess: {
id: 'course-authoring.course-unit.xblock.button.manageAccess.label',
defaultMessage: 'Manage access',
description: 'The xblock manage access button text',
},
blockLabelButtonDelete: {
id: 'course-authoring.course-unit.xblock.button.delete.label',
defaultMessage: 'Delete',
description: 'The xblock delete button text',
},
visibilityMessage: {
id: 'course-authoring.course-unit.xblock.visibility.message',
defaultMessage: 'Access restricted to: {selectedGroupsLabel}',
description: 'Group visibility accessibility text for xblock',
},
validationSummary: {
id: 'course-authoring.course-unit.xblock.validation.summary',
defaultMessage: 'This component has validation issues.',
description: 'The alert text of the visibility validation issues',
},
});
export default messages;

View File

@@ -1,49 +0,0 @@
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Info as InfoIcon, WarningFilled as WarningIcon } from '@openedx/paragon/icons';
import messages from '../messages';
import { MESSAGE_ERROR_TYPES } from '../constants';
import { getMessagesBlockType } from './utils';
const XBlockMessages = ({ validationMessages }) => {
const intl = useIntl();
const type = getMessagesBlockType(validationMessages);
const { warning } = MESSAGE_ERROR_TYPES;
const alertVariant = type === warning ? 'warning' : 'danger';
const alertIcon = type === warning ? WarningIcon : InfoIcon;
if (!validationMessages.length) {
return null;
}
return (
<Alert
variant={alertVariant}
icon={alertIcon}
>
<Alert.Heading>
{intl.formatMessage(messages.validationSummary)}
</Alert.Heading>
<ul>
{validationMessages.map(({ text }) => (
<li key={text}>{text}</li>
))}
</ul>
</Alert>
);
};
XBlockMessages.defaultProps = {
validationMessages: [],
};
XBlockMessages.propTypes = {
validationMessages: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.string,
text: PropTypes.string,
})),
};
export default XBlockMessages;

View File

@@ -1,55 +0,0 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import XBlockMessages from './XBlockMessages';
const renderComponent = (props) => render(
<IntlProvider locale="en">
<XBlockMessages
{...props}
/>
</IntlProvider>,
);
describe('<XBlockMessages />', () => {
it('renders without errors', () => {
renderComponent({ validationMessages: [] });
});
it('does not render anything when there are no errors', () => {
const { container } = renderComponent({ validationMessages: [] });
expect(container.firstChild).toBeNull();
});
it('renders a warning Alert when there are warning errors', () => {
const validationMessages = [{ type: 'warning', text: 'This is a warning' }];
const { getByText } = renderComponent({ validationMessages });
expect(getByText('This is a warning')).toBeInTheDocument();
expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument();
});
it('renders a danger Alert when there are danger errors', () => {
const validationMessages = [{ type: 'danger', text: 'This is a danger' }];
const { getByText } = renderComponent({ validationMessages });
expect(getByText('This is a danger')).toBeInTheDocument();
expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument();
});
it('renders multiple error messages in a list', () => {
const validationMessages = [
{ type: 'warning', text: 'Warning 1' },
{ type: 'danger', text: 'Danger 1' },
{ type: 'danger', text: 'Danger 2' },
];
const { getByText } = renderComponent({ validationMessages });
expect(getByText('Warning 1')).toBeInTheDocument();
expect(getByText('Danger 1')).toBeInTheDocument();
expect(getByText('Danger 2')).toBeInTheDocument();
expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -1,16 +0,0 @@
import { MESSAGE_ERROR_TYPES } from '../constants';
/**
* Determines the block type based on the types of messages in the given array.
* @param {Array} messages - An array of message objects.
* @param {Object[]} messages.type - The type of each message (e.g., MESSAGE_ERROR_TYPES.error).
* @returns {string} - The block type determined by the messages (e.g., 'warning' or 'error').
*/
// eslint-disable-next-line import/prefer-default-export
export const getMessagesBlockType = (messages) => {
let type = MESSAGE_ERROR_TYPES.warning;
if (messages.some((message) => message.type === MESSAGE_ERROR_TYPES.error)) {
type = MESSAGE_ERROR_TYPES.error;
}
return type;
};

View File

@@ -1,44 +0,0 @@
import { MESSAGE_ERROR_TYPES } from '../constants';
import { getMessagesBlockType } from './utils';
describe('xblock-messages utils', () => {
describe('getMessagesBlockType', () => {
it('returns "warning" when there are no error messages', () => {
const messages = [
{ type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' },
{ type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' },
];
const result = getMessagesBlockType(messages);
expect(result).toBe(MESSAGE_ERROR_TYPES.warning);
});
it('returns "error" when there is at least one error message', () => {
const messages = [
{ type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' },
{ type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' },
{ type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' },
];
const result = getMessagesBlockType(messages);
expect(result).toBe(MESSAGE_ERROR_TYPES.error);
});
it('returns "error" when there are only error messages', () => {
const messages = [
{ type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' },
{ type: MESSAGE_ERROR_TYPES.error, text: 'Another error' },
];
const result = getMessagesBlockType(messages);
expect(result).toBe(MESSAGE_ERROR_TYPES.error);
});
it('returns "warning" when there are no messages', () => {
const messages = [];
const result = getMessagesBlockType(messages);
expect(result).toBe(MESSAGE_ERROR_TYPES.warning);
});
});
});

View File

@@ -148,16 +148,3 @@ export async function duplicateUnitItem(itemId, XBlockId) {
return data;
}
/**
* Sets the order list of XBlocks.
* @param {string} blockId - The identifier of the course unit.
* @param {Object[]} children - The array of child elements representing the updated order of XBlocks.
* @returns {Promise<Object>} - A promise that resolves to the updated data after setting the XBlock order.
*/
export async function setXBlockOrderList(blockId, children) {
const { data } = await getAuthenticatedHttpClient()
.put(getXBlockBaseApiUrl(blockId), { children });
return data;
}

View File

@@ -103,14 +103,6 @@ const slice = createSlice({
fetchStaticFileNoticesSuccess: (state, { payload }) => {
state.staticFileNotices = payload;
},
reorderXBlockList: (state, { payload }) => {
// Create a map for payload IDs to their index for O(1) lookups
const indexMap = new Map(payload.map((id, index) => [id, index]));
// Directly sort the children based on the order defined in payload
// This avoids the need to copy the array beforehand
state.courseVerticalChildren.children.sort((a, b) => (indexMap.get(a.id) || 0) - (indexMap.get(b.id) || 0));
},
},
});
@@ -132,7 +124,6 @@ export const {
deleteXBlock,
duplicateXBlock,
fetchStaticFileNoticesSuccess,
reorderXBlockList,
} = slice.actions;
export const {

View File

@@ -18,7 +18,6 @@ import {
handleCourseUnitVisibilityAndData,
deleteUnitItem,
duplicateUnitItem,
setXBlockOrderList,
} from './api';
import {
updateLoadingCourseUnitStatus,
@@ -36,7 +35,6 @@ import {
deleteXBlock,
duplicateXBlock,
fetchStaticFileNoticesSuccess,
reorderXBlockList,
} from './slice';
import { getNotificationMessage } from './utils';
@@ -249,26 +247,3 @@ export function duplicateUnitItemQuery(itemId, xblockId) {
}
};
}
export function setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setXBlockOrderList(blockId, xblockListIds).then(async (result) => {
if (result) {
dispatch(reorderXBlockList(xblockListIds));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
const courseUnit = await getCourseUnitData(blockId);
dispatch(fetchCourseItemSuccess(courseUnit));
}
});
} catch (error) {
restoreCallback();
handleResponseErrors(error, dispatch, updateSavingStatus);
} finally {
dispatch(hideProcessingNotification());
}
};
}

View File

@@ -11,7 +11,6 @@ import {
fetchCourseVerticalChildrenData,
deleteUnitItemQuery,
duplicateUnitItemQuery,
setXBlockOrderListQuery,
editCourseUnitVisibilityAndData,
} from './data/thunk';
import {
@@ -107,10 +106,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
},
};
const handleXBlockDragAndDrop = (xblockListIds, restoreCallback) => {
dispatch(setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback));
};
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateQueryPendingStatus(true));
@@ -146,7 +141,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handleCreateNewCourseXBlock,
handleConfigureSubmit,
courseVerticalChildren,
handleXBlockDragAndDrop,
canPasteComponent,
};
};

View File

@@ -0,0 +1,139 @@
import {
useState, useLayoutEffect, useCallback, useEffect,
} from 'react';
import { logError } from '@edx/frontend-platform/logging';
// eslint-disable-next-line import/no-extraneous-dependencies
import { useKeyedState } from '@edx/react-unit-test-utils';
import { useEventListener } from '../../generic/hooks';
import { stateKeys, messageTypes } from '../constants';
interface UseIFrameBehaviorParams {
id: string;
iframeUrl: string;
onLoaded?: boolean;
}
interface UseIFrameBehaviorReturn {
iframeHeight: number;
handleIFrameLoad: () => void;
showError: boolean;
hasLoaded: boolean;
}
/**
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
* useEffect hooks until the user interacts with the page again. This is particularly confusing
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
* state.
*
* We were able to solve this error by using a layout effect to update some component state, which
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
* a joke) one here so it wouldn't be accidentally removed elsewhere.
*
* If we remove this hook when one of these happens:
* 1. React figures out that there's an issue here and fixes a bug.
* 2. We cease to use an iframe for unit rendering.
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
* 4. We stop supporting Firefox.
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
* Firefox/React for review, and they kindly help us figure out what in the world is happening
* so we can fix it.
*
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
*/
export const useLoadBearingHook = (id: string): void => {
const setValue = useState(0)[1];
useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
};
/**
* Custom hook to manage iframe behavior.
*
* @param {Object} params - The parameters for the hook.
* @param {string} params.id - The unique identifier for the iframe.
* @param {string} params.iframeUrl - The URL of the iframe.
* @param {boolean} [params.onLoaded=true] - Flag to indicate if the iframe has loaded.
* @returns {Object} The state and handlers for the iframe.
* @returns {number} return.iframeHeight - The height of the iframe.
* @returns {Function} return.handleIFrameLoad - The handler for iframe load event.
* @returns {boolean} return.showError - Flag to indicate if there was an error loading the iframe.
* @returns {boolean} return.hasLoaded - Flag to indicate if the iframe has loaded.
*/
export const useIFrameBehavior = ({
id,
iframeUrl,
onLoaded = true,
}: UseIFrameBehaviorParams): UseIFrameBehaviorReturn => {
// Do not remove this hook. See function description.
useLoadBearingHook(id);
const [iframeHeight, setIframeHeight] = useKeyedState<number>(stateKeys.iframeHeight, 0);
const [hasLoaded, setHasLoaded] = useKeyedState<boolean>(stateKeys.hasLoaded, false);
const [showError, setShowError] = useKeyedState<boolean>(stateKeys.showError, false);
const [windowTopOffset, setWindowTopOffset] = useKeyedState<number | null>(stateKeys.windowTopOffset, null);
const receiveMessage = useCallback(({ data }: MessageEvent) => {
const { payload, type } = data;
if (type === messageTypes.resize) {
setIframeHeight(payload.height);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
}
} else if (type === messageTypes.videoFullScreen) {
// We observe exit from the video xblock fullscreen mode
// and scroll to the previously saved scroll position
if (!payload.open && windowTopOffset !== null) {
window.scrollTo(0, Number(windowTopOffset));
}
// We listen for this message from LMS to know when we need to
// save or reset scroll position on toggle video xblock fullscreen mode
setWindowTopOffset(payload.open ? window.scrollY : null);
} else if (data.offset) {
// We listen for this message from LMS to know when the page needs to
// be scrolled to another location on the page.
window.scrollTo(0, data.offset + document.getElementById('unit-iframe')!.offsetTop);
}
}, [
id,
onLoaded,
hasLoaded,
setHasLoaded,
iframeHeight,
setIframeHeight,
windowTopOffset,
setWindowTopOffset,
]);
useEventListener('message', receiveMessage);
const handleIFrameLoad = () => {
if (!hasLoaded) {
setShowError(true);
logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', {
iframeUrl,
});
}
};
useEffect(() => {
setIframeHeight(0);
setHasLoaded(false);
}, [iframeUrl]);
return {
iframeHeight,
handleIFrameLoad,
showError,
hasLoaded,
};
};

View File

@@ -0,0 +1,51 @@
import { useRef, FC } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { IFRAME_FEATURE_POLICY } from '../constants';
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 {
blockId: string;
}
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({ blockId }) => {
const intl = useIntl();
const iframeRef = useRef<HTMLIFrameElement>(null);
const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`;
const { iframeHeight } = useIFrameBehavior({
id: blockId,
iframeUrl,
});
return (
<iframe
ref={iframeRef}
title={intl.formatMessage(messages.xblockIframeTitle)}
src={iframeUrl}
frameBorder="0"
allow={IFRAME_FEATURE_POLICY}
allowFullScreen
loading="lazy"
style={{ width: '100%', height: iframeHeight + IFRAME_BOTTOM_OFFSET }}
scrolling="no"
referrerPolicy="origin"
/>
);
};
XBlockContainerIframe.propTypes = {
blockId: PropTypes.string.isRequired,
};
export default XBlockContainerIframe;

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
xblockIframeTitle: {
id: 'course-authoring.course-unit.xblock.iframe.title',
defaultMessage: 'Course unit iframe',
description: 'Title for the xblock iframe',
},
});
export default messages;

View File

@@ -0,0 +1,44 @@
import { render } from '@testing-library/react';
import { getConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IFRAME_FEATURE_POLICY } from '../../constants';
import { useIFrameBehavior } from '../hooks';
import XBlockContainerIframe from '..';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('../hooks', () => ({
useIFrameBehavior: jest.fn(),
}));
describe('<XBlockContainerIframe />', () => {
const blockId = 'test-block-id';
const iframeUrl = `http://example.com/container_embed/${blockId}`;
const iframeHeight = '500px';
beforeEach(() => {
(getConfig as jest.Mock).mockReturnValue({ STUDIO_BASE_URL: 'http://example.com' });
(useIFrameBehavior as jest.Mock).mockReturnValue({ iframeHeight });
});
it('renders correctly with the given blockId', () => {
const { getByTitle } = render(
<IntlProvider locale="en">
<XBlockContainerIframe blockId={blockId} />
</IntlProvider>,
);
const iframe = getByTitle('Course unit iframe');
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('src', iframeUrl);
expect(iframe).toHaveAttribute('frameBorder', '0');
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('allowFullScreen');
expect(iframe).toHaveAttribute('loading', 'lazy');
expect(iframe).toHaveAttribute('scrolling', 'no');
expect(iframe).toHaveAttribute('referrerPolicy', 'origin');
});
});

View File

@@ -0,0 +1,173 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { stateKeys, messageTypes } from '../../constants';
import { useIFrameBehavior, useLoadBearingHook } from '../hooks';
jest.mock('@edx/react-unit-test-utils', () => ({
useKeyedState: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
describe('useIFrameBehavior', () => {
const id = 'test-id';
const iframeUrl = 'http://example.com';
const setIframeHeight = jest.fn();
const setHasLoaded = jest.fn();
const setShowError = jest.fn();
const setWindowTopOffset = jest.fn();
beforeEach(() => {
(useKeyedState as jest.Mock).mockImplementation((key, initialValue) => {
switch (key) {
case stateKeys.iframeHeight:
return [0, setIframeHeight];
case stateKeys.hasLoaded:
return [false, setHasLoaded];
case stateKeys.showError:
return [false, setShowError];
case stateKeys.windowTopOffset:
return [null, setWindowTopOffset];
default:
return [initialValue, jest.fn()];
}
});
window.scrollTo = jest.fn((x: number | ScrollToOptions, y?: number): void => {
const scrollY = typeof x === 'number' ? y : (x as ScrollToOptions).top || 0;
Object.defineProperty(window, 'scrollY', { value: scrollY, writable: true });
}) as typeof window.scrollTo;
});
it('initializes state correctly', () => {
const { result } = renderHook(() => useIFrameBehavior({ id, iframeUrl }));
expect(result.current.iframeHeight).toBe(0);
expect(result.current.showError).toBe(false);
expect(result.current.hasLoaded).toBe(false);
});
it('scrolls to previous position on video fullscreen exit', () => {
const mockWindowTopOffset = 100;
(useKeyedState as jest.Mock).mockImplementation((key) => {
if (key === stateKeys.windowTopOffset) {
return [mockWindowTopOffset, setWindowTopOffset];
}
return [null, jest.fn()];
});
renderHook(() => useIFrameBehavior({ id, iframeUrl }));
const message = {
data: {
type: messageTypes.videoFullScreen,
payload: { open: false },
},
};
act(() => {
window.dispatchEvent(new MessageEvent('message', message));
});
expect(window.scrollTo).toHaveBeenCalledWith(0, mockWindowTopOffset);
});
it('handles resize message correctly', () => {
renderHook(() => useIFrameBehavior({ id, iframeUrl }));
const message = {
data: {
type: messageTypes.resize,
payload: { height: 500 },
},
};
act(() => {
window.dispatchEvent(new MessageEvent('message', message));
});
expect(setIframeHeight).toHaveBeenCalledWith(500);
expect(setHasLoaded).toHaveBeenCalledWith(true);
});
it('handles videoFullScreen message correctly', () => {
renderHook(() => useIFrameBehavior({ id, iframeUrl }));
const message = {
data: {
type: messageTypes.videoFullScreen,
payload: { open: true },
},
};
act(() => {
window.dispatchEvent(new MessageEvent('message', message));
});
expect(setWindowTopOffset).toHaveBeenCalledWith(window.scrollY);
});
it('handles offset message correctly', () => {
document.body.innerHTML = '<div id="unit-iframe" style="position: absolute; top: 50px;"></div>';
renderHook(() => useIFrameBehavior({ id, iframeUrl }));
const message = {
data: { offset: 100 },
};
act(() => {
window.dispatchEvent(new MessageEvent('message', message));
});
expect(window.scrollY).toBe(100 + (document.getElementById('unit-iframe') as HTMLElement).offsetTop);
});
it('handles iframe load error correctly', () => {
const { result } = renderHook(() => useIFrameBehavior({ id, iframeUrl }));
act(() => {
result.current.handleIFrameLoad();
});
expect(setShowError).toHaveBeenCalledWith(true);
expect(logError).toHaveBeenCalledWith('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', {
iframeUrl,
});
});
it('resets state when iframeUrl changes', () => {
// eslint-disable-next-line @typescript-eslint/no-shadow
const { rerender } = renderHook(({ id, iframeUrl }) => useIFrameBehavior({ id, iframeUrl }), {
initialProps: { id, iframeUrl },
});
rerender({ id, iframeUrl: 'http://new-url.com' });
expect(setIframeHeight).toHaveBeenCalledWith(0);
expect(setHasLoaded).toHaveBeenCalledWith(false);
});
});
describe('useLoadBearingHook', () => {
it('updates state when id changes', () => {
const setValue = jest.fn();
jest.spyOn(React, 'useState').mockReturnValue([0, setValue]);
const { rerender } = renderHook(({ id }) => useLoadBearingHook(id), {
initialProps: { id: 'initial-id' },
});
setValue.mockClear();
rerender({ id: 'new-id' });
expect(setValue).toHaveBeenCalledWith(expect.any(Function));
expect(setValue.mock.calls);
});
});

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { useEventListener } from './useEventListener';

View File

@@ -0,0 +1,62 @@
import { renderHook } from '@testing-library/react-hooks';
import { useEventListener } from '../useEventListener';
describe('useEventListener', () => {
let addEventListenerSpy: jest.SpyInstance;
let removeEventListenerSpy: jest.SpyInstance;
beforeEach(() => {
addEventListenerSpy = jest.spyOn(global, 'addEventListener');
removeEventListenerSpy = jest.spyOn(global, 'removeEventListener');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should add event listener on mount', () => {
const handler = jest.fn();
renderHook(() => useEventListener('click', handler));
expect(addEventListenerSpy).toHaveBeenCalledWith('click', handler);
});
it('should remove event listener on unmount', () => {
const handler = jest.fn();
const { unmount } = renderHook(() => useEventListener('click', handler));
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', handler);
});
it('should update event listener when handler changes', () => {
const handler1 = jest.fn();
const handler2 = jest.fn();
const { rerender } = renderHook(({ handler }: {
handler: (event: Event) => void
}) => useEventListener('click', handler), {
initialProps: { handler: handler1 },
});
rerender({ handler: handler2 });
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', handler1);
expect(addEventListenerSpy).toHaveBeenCalledWith('click', handler2);
});
it('should update event listener when type changes', () => {
const handler = jest.fn();
const { rerender } = renderHook(({ type }: {
type: keyof WindowEventMap
}) => useEventListener(type, handler), {
initialProps: { type: 'click' },
});
rerender({ type: 'scroll' });
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', handler);
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', handler);
});
});

View File

@@ -0,0 +1,27 @@
import { useEffect, useRef, MutableRefObject } from 'react';
// eslint-disable-next-line import/prefer-default-export
export function useEventListener<K extends keyof WindowEventMap>(
type: K,
handler: (event: WindowEventMap[K]) => void,
) {
// We use this ref so that we can hold a reference to the currently active event listener.
const eventListenerRef = useRef<(event: WindowEventMap[K]) => void | null>(null);
useEffect(() => {
// If we currently have an event listener, remove it.
if (eventListenerRef.current !== null) {
global.removeEventListener(type, eventListenerRef.current);
}
// Now add our new handler as the event listener.
global.addEventListener(type, handler);
// And then save it to our ref for next time.
(eventListenerRef as MutableRefObject<(event: WindowEventMap[K]) => void>).current = handler;
// When the component finally unmounts, use the ref to remove the correct handler.
return () => {
if (eventListenerRef.current !== null) {
global.removeEventListener(type, eventListenerRef.current);
}
};
}, [type, handler]);
}