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:
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 *'
|
||||
);
|
||||
|
||||
57
src/course-unit/course-sequence/hooks.test.js
Normal file
57
src/course-unit/course-sequence/hooks.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const MESSAGE_ERROR_TYPES = {
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
139
src/course-unit/xblock-container-iframe/hooks.tsx
Normal file
139
src/course-unit/xblock-container-iframe/hooks.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
51
src/course-unit/xblock-container-iframe/index.tsx
Normal file
51
src/course-unit/xblock-container-iframe/index.tsx
Normal 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;
|
||||
11
src/course-unit/xblock-container-iframe/messages.ts
Normal file
11
src/course-unit/xblock-container-iframe/messages.ts
Normal 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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
173
src/course-unit/xblock-container-iframe/tests/hooks.test.tsx
Normal file
173
src/course-unit/xblock-container-iframe/tests/hooks.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
2
src/generic/hooks/index.tsx
Normal file
2
src/generic/hooks/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { useEventListener } from './useEventListener';
|
||||
62
src/generic/hooks/tests/useEventListener.test.tsx
Normal file
62
src/generic/hooks/tests/useEventListener.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
27
src/generic/hooks/useEventListener.tsx
Normal file
27
src/generic/hooks/useEventListener.tsx
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user