Compare commits
5 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6841e94ab0 | ||
|
|
4f04698a60 | ||
|
|
82a76b0cad | ||
|
|
62445a18f3 | ||
|
|
91ee5004a4 |
@@ -104,7 +104,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleNewUnitSubmit,
|
||||
getUnitUrl,
|
||||
handleVideoSharingOptionChange,
|
||||
handleCopyToClipboardClick,
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
@@ -397,7 +396,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
onOrderChange={updateUnitOrderByIndex}
|
||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||
discussionsSettings={discussionsSettings}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2182,9 +2182,6 @@ describe('<CourseOutline />', () => {
|
||||
.onPost(getClipboardUrl(), {
|
||||
usage_key: unit.id,
|
||||
}).reply(200, clipboardUnit);
|
||||
// check that initialUserClipboard state is empty
|
||||
const { initialUserClipboard } = store.getState().courseOutline;
|
||||
expect(initialUserClipboard).toBeUndefined();
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||
@@ -2194,9 +2191,6 @@ describe('<CourseOutline />', () => {
|
||||
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
|
||||
await act(async () => fireEvent.click(copyButton));
|
||||
|
||||
// check that initialUserClipboard state is updated
|
||||
expect(store.getState().generic.clipboardData).toEqual(clipboardUnit);
|
||||
|
||||
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
// find clipboard content label
|
||||
const clipboardLabel = await within(subsectionElement).findByText(
|
||||
|
||||
@@ -28,7 +28,6 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein
|
||||
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
|
||||
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { updateClipboardData } from '../../generic/data/slice';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
@@ -88,7 +87,6 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
},
|
||||
} = outlineIndex;
|
||||
dispatch(fetchOutlineIndexSuccess(outlineIndex));
|
||||
dispatch(updateClipboardData(outlineIndex.initialUserClipboard));
|
||||
dispatch(updateStatusBar({
|
||||
courseReleaseDate,
|
||||
highlightsEnabledForMessaging,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { copyToClipboard } from '../generic/data/thunks';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
@@ -72,6 +71,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
mfeProctoredExamSettingsUrl,
|
||||
advanceSettingsUrl,
|
||||
} = useSelector(getOutlineIndexData);
|
||||
|
||||
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
|
||||
const statusBarData = useSelector(getStatusBarData);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
@@ -95,10 +95,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||
|
||||
const handleCopyToClipboardClick = (usageKey) => {
|
||||
dispatch(copyToClipboard(usageKey));
|
||||
};
|
||||
|
||||
const handlePasteClipboardClick = (parentLocator, sectionId) => {
|
||||
dispatch(pasteClipboardContent(parentLocator, sectionId));
|
||||
};
|
||||
@@ -339,7 +335,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
openUnitPage,
|
||||
handleNewUnitSubmit,
|
||||
handleVideoSharingOptionChange,
|
||||
handleCopyToClipboardClick,
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
|
||||
import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import { useClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
@@ -49,7 +49,7 @@ const SubsectionCard = ({
|
||||
const isScrolledToElement = locatorId === subsection.id;
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'subsection';
|
||||
const { sharedClipboardData, showPasteUnit } = useCopyToClipboard();
|
||||
const { sharedClipboardData, showPasteUnit } = useClipboard();
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -233,7 +233,7 @@ const SubsectionCard = ({
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
{enableCopyPasteUnits && showPasteUnit && (
|
||||
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||
<PasteComponent
|
||||
className="mt-4"
|
||||
text={intl.formatMessage(messages.pasteButton)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import TitleLink from '../card-header/TitleLink';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
|
||||
const UnitCard = ({
|
||||
unit,
|
||||
@@ -30,7 +31,6 @@ const UnitCard = ({
|
||||
onDuplicateSubmit,
|
||||
getTitleLink,
|
||||
onOrderChange,
|
||||
onCopyToClipboardClick,
|
||||
discussionsSettings,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
@@ -41,6 +41,8 @@ const UnitCard = ({
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'unit';
|
||||
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
const {
|
||||
id,
|
||||
category,
|
||||
@@ -98,7 +100,7 @@ const UnitCard = ({
|
||||
};
|
||||
|
||||
const handleCopyClick = () => {
|
||||
onCopyToClipboardClick(unit.id);
|
||||
copyToClipboard(id);
|
||||
};
|
||||
|
||||
const titleComponent = (
|
||||
@@ -241,7 +243,6 @@ UnitCard.propTypes = {
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
||||
onCopyToClipboardClick: PropTypes.func.isRequired,
|
||||
discussionsSettings: PropTypes.shape({
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act, render, fireEvent, within,
|
||||
} from '@testing-library/react';
|
||||
@@ -48,6 +47,13 @@ const unit = {
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -62,7 +68,6 @@ const renderComponent = (props) => render(
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onCopyToClipboardClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
|
||||
@@ -185,7 +185,9 @@ const CourseUnit = ({ courseId }) => {
|
||||
{showPasteXBlock && canPasteComponent && (
|
||||
<PasteComponent
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={handleCreateNewCourseXBlock}
|
||||
onClick={
|
||||
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
|
||||
}
|
||||
text={intl.formatMessage(messages.pasteButtonText)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
act, render, waitFor, fireEvent, within, screen,
|
||||
} from '@testing-library/react';
|
||||
@@ -38,10 +39,7 @@ import {
|
||||
courseVerticalChildrenMock,
|
||||
clipboardMockResponse,
|
||||
} from './__mocks__';
|
||||
import {
|
||||
clipboardUnit,
|
||||
clipboardXBlock,
|
||||
} from '../__mocks__';
|
||||
import { clipboardUnit, clipboardXBlock } from '../__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import deleteModalMessages from '../generic/delete-modal/messages';
|
||||
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
|
||||
@@ -54,6 +52,7 @@ import { extractCourseUnitId } from './sidebar/utils';
|
||||
import CourseUnit from './CourseUnit';
|
||||
|
||||
import configureModalMessages from '../generic/configure-modal/messages';
|
||||
import { getClipboardUrl } from '../generic/data/api';
|
||||
import courseXBlockMessages from './course-xblock/messages';
|
||||
import addComponentMessages from './add-component/messages';
|
||||
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
|
||||
@@ -63,6 +62,7 @@ import { RequestStatus } from '../data/constants';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
let queryClient;
|
||||
const courseId = '123';
|
||||
const blockId = '567890';
|
||||
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
|
||||
@@ -80,31 +80,6 @@ jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockedUsedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(({ queryKey }) => {
|
||||
if (queryKey[0] === 'contentTaxonomyTags') {
|
||||
return {
|
||||
data: {
|
||||
taxonomies: [],
|
||||
},
|
||||
isSuccess: true,
|
||||
};
|
||||
} if (queryKey[0] === 'contentTagsCount') {
|
||||
return {
|
||||
data: 17,
|
||||
isSuccess: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {},
|
||||
isSuccess: true,
|
||||
};
|
||||
}),
|
||||
useQueryClient: jest.fn(() => ({
|
||||
setQueryData: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
@@ -115,7 +90,9 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseUnit courseId={courseId} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CourseUnit courseId={courseId} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -132,7 +109,17 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
global.localStorage.clear();
|
||||
store = initializeStore();
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, courseUnitIndexMock);
|
||||
@@ -147,7 +134,7 @@ describe('<CourseUnit />', () => {
|
||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||
axiosMock
|
||||
.onGet(getContentTaxonomyTagsApiUrl(blockId))
|
||||
.reply(200, {});
|
||||
.reply(200, { taxonomies: [] });
|
||||
axiosMock
|
||||
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
|
||||
.reply(200, 17);
|
||||
@@ -1087,19 +1074,16 @@ describe('<CourseUnit />', () => {
|
||||
queryByTestId, getByRole, getAllByLabelText, getByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
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();
|
||||
|
||||
@@ -1141,11 +1125,8 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
@@ -1197,11 +1178,8 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
@@ -1228,13 +1206,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1288,13 +1259,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1349,13 +1313,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1410,13 +1367,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
ActionRow, Card, Dropdown, Icon, IconButton, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
@@ -15,7 +15,7 @@ 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 { useClipboard } from '../../generic/clipboard';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import XBlockMessages from './xblock-messages/XBlockMessages';
|
||||
import messages from './messages';
|
||||
@@ -28,7 +28,6 @@ const CourseXBlock = ({
|
||||
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();
|
||||
@@ -48,6 +47,8 @@ const CourseXBlock = ({
|
||||
showCorrectness: 'always',
|
||||
};
|
||||
|
||||
const { copyToClipboard } = useClipboard(canEdit);
|
||||
|
||||
const onDeleteSubmit = () => {
|
||||
unitXBlockActions.handleDelete(id);
|
||||
closeDeleteModal();
|
||||
@@ -120,7 +121,7 @@ const CourseXBlock = ({
|
||||
{intl.formatMessage(messages.blockLabelButtonMove)}
|
||||
</Dropdown.Item>
|
||||
{canEdit && (
|
||||
<Dropdown.Item onClick={() => dispatch(copyToClipboard(id))}>
|
||||
<Dropdown.Item onClick={() => copyToClipboard(id)}>
|
||||
{intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
render, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
@@ -24,6 +25,15 @@ import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
let queryClient;
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const courseId = '1234';
|
||||
const blockId = '567890';
|
||||
const handleDeleteMock = jest.fn();
|
||||
@@ -49,17 +59,19 @@ jest.mock('react-redux', () => ({
|
||||
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}
|
||||
/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CourseXBlock
|
||||
id={id}
|
||||
title={name}
|
||||
type={type}
|
||||
blockId={blockId}
|
||||
unitXBlockActions={unitXBlockActionsMock}
|
||||
userPartitionInfo={userPartitionInfoFormatted}
|
||||
shouldScroll={false}
|
||||
handleConfigureSubmit={handleConfigureSubmitMock}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
@@ -98,6 +110,7 @@ describe('<CourseXBlock />', () => {
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, courseSectionVerticalMock);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
it('render CourseXBlock component correctly', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { handleResponseErrors } from '../../generic/saving-error-alert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { updateModel, updateModels } from '../../generic/model-store';
|
||||
import { updateClipboardData } from '../../generic/data/slice';
|
||||
import {
|
||||
getCourseUnitData,
|
||||
editUnitDisplayName,
|
||||
@@ -75,7 +74,6 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
|
||||
}));
|
||||
dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices'))));
|
||||
localStorage.removeItem('staticFileNotices');
|
||||
dispatch(updateClipboardData(courseSectionVerticalData.userClipboard));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -214,8 +212,6 @@ export function deleteUnitItemQuery(itemId, xblockId) {
|
||||
try {
|
||||
await deleteUnitItem(xblockId);
|
||||
dispatch(deleteXBlock(xblockId));
|
||||
const { userClipboard } = await getCourseSectionVerticalData(itemId);
|
||||
dispatch(updateClipboardData(userClipboard));
|
||||
const courseUnit = await getCourseUnitData(itemId);
|
||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||
dispatch(hideProcessingNotification());
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { useClipboard } from '../generic/clipboard';
|
||||
import {
|
||||
createNewCourseXBlock,
|
||||
fetchCourseUnitQuery,
|
||||
@@ -28,8 +29,6 @@ import {
|
||||
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
|
||||
import { PUBLISH_TYPES } from './constants';
|
||||
|
||||
import { useCopyToClipboard } from '../generic/clipboard';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -47,7 +46,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const { currentlyVisibleToStudents } = courseUnit;
|
||||
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit);
|
||||
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
|
||||
const { canPasteComponent } = courseVerticalChildren;
|
||||
|
||||
const unitTitle = courseUnit.metadata?.displayName || '';
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Divider } from '../../../../generic/divider';
|
||||
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
|
||||
import { copyToClipboard } from '../../../../generic/data/thunks';
|
||||
import { useClipboard } from '../../../../generic/clipboard';
|
||||
import messages from '../../messages';
|
||||
|
||||
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const {
|
||||
id,
|
||||
@@ -18,6 +17,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||
enableCopyPasteUnits,
|
||||
} = useSelector(getCourseUnitData);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -40,7 +40,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||
<>
|
||||
<Divider className="course-unit-sidebar-footer__divider" />
|
||||
<Button
|
||||
onClick={() => dispatch(copyToClipboard(id))}
|
||||
onClick={() => copyToClipboard(id)}
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
@@ -12,24 +13,28 @@ import { clipboardUnit } from '../../../../__mocks__';
|
||||
import { getCourseUnitApiUrl } from '../../../data/api';
|
||||
import { getClipboardUrl } from '../../../../generic/data/api';
|
||||
import { fetchCourseUnitQuery } from '../../../data/thunk';
|
||||
import { copyToClipboard } from '../../../../generic/data/thunks';
|
||||
import { courseUnitIndexMock } from '../../../__mocks__';
|
||||
import messages from '../../messages';
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
jest.mock('../../../../generic/data/thunks', () => ({
|
||||
...jest.requireActual('../../../../generic/data/thunks'),
|
||||
copyToClipboard: jest.fn().mockImplementation(() => () => {}),
|
||||
}));
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
let queryClient;
|
||||
const courseId = '123';
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const renderComponent = (props = {}) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ActionButtons {...props} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActionButtons {...props} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
@@ -57,6 +62,8 @@ describe('<ActionButtons />', () => {
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
queryClient = new QueryClient();
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
@@ -73,8 +80,8 @@ describe('<ActionButtons />', () => {
|
||||
const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage });
|
||||
|
||||
userEvent.click(copyXBlockBtn);
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(courseUnitIndexMock.id);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ usage_key: courseUnitIndexMock.id }));
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,6 +148,40 @@ export const getImageResizeHandler = ({ editor, imagesRef, setImage }) => () =>
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fix TinyMCE editors used in Paragon modals, by re-parenting their modal <div>
|
||||
* from the body to the Paragon modal container.
|
||||
*
|
||||
* This fixes a problem where clicking on any modal/popup within TinyMCE (e.g.
|
||||
* the emoji inserter, the link inserter, the floating format toolbar -
|
||||
* quickbars, etc.) would cause the parent Paragon modal to close, because
|
||||
* Paragon sees it as a "click outside" event. Also fixes some hover effects by
|
||||
* ensuring the layering of the divs is correct.
|
||||
*
|
||||
* This could potentially cause problems if there are TinyMCE editors being used
|
||||
* both on the parent page and inside a Paragon modal popup, but I don't think
|
||||
* we have that situation.
|
||||
*
|
||||
* Note: we can't just do this on init, because the quickbars plugin used by
|
||||
* ExpandableTextEditors creates its modal DIVs later. Ideally we could listen
|
||||
* for some kind of "modal open" event, but I haven't been able to find anything
|
||||
* like that so for now we do this quite frequently, every time there is a
|
||||
* "selectionchange" event (which is pretty often).
|
||||
*/
|
||||
export const reparentTinyMceModals = /* istanbul ignore next */ () => {
|
||||
const modalLayer = document.querySelector('.pgn__modal-layer');
|
||||
if (!modalLayer) {
|
||||
return;
|
||||
}
|
||||
const tinymceAuxDivs = document.querySelectorAll('.tox.tox-tinymce-aux');
|
||||
for (const tinymceAux of tinymceAuxDivs) {
|
||||
if (tinymceAux.parentElement !== modalLayer) {
|
||||
// Move this tinyMCE modal div into the paragon modal layer.
|
||||
modalLayer.appendChild(tinymceAux);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const setupCustomBehavior = ({
|
||||
updateContent,
|
||||
openImgModal,
|
||||
@@ -221,30 +255,17 @@ export const setupCustomBehavior = ({
|
||||
}
|
||||
|
||||
editor.on('init', /* istanbul ignore next */ () => {
|
||||
// Moving TinyMce aux modal inside the Editor modal
|
||||
// if the editor is on modal mode.
|
||||
// This is to avoid issues using the aux modal:
|
||||
// * Avoid close aux modal when clicking the content inside.
|
||||
// * When the user opens the `Edit Source Code` modal, this adds `data-focus-on-hidden`
|
||||
// to the TinyMce aux modal, making it unusable.
|
||||
const modalLayer = document.querySelector('.pgn__modal-layer');
|
||||
const tinymceAux = document.querySelector('.tox.tox-tinymce-aux');
|
||||
|
||||
if (modalLayer && tinymceAux) {
|
||||
modalLayer.appendChild(tinymceAux);
|
||||
// Check if this editor is inside a (Paragon) modal.
|
||||
// The way we get the editor's root <div> depends on whether or not this particular editor is using an iframe:
|
||||
const editorDiv = editor.bodyElement ?? editor.container;
|
||||
if (editorDiv?.closest('.pgn__modal')) {
|
||||
// This editor is inside a Paragon modal. Use this hack to avoid interference with TinyMCE's own modal popups:
|
||||
reparentTinyMceModals();
|
||||
editor.on('selectionchange', reparentTinyMceModals);
|
||||
}
|
||||
});
|
||||
|
||||
editor.on('ExecCommand', /* istanbul ignore next */ (e) => {
|
||||
// Remove `data-focus-on-hidden` and `aria-hidden` on TinyMce aux modal used on emoticons, formulas, etc.
|
||||
// When using the Editor in modal mode, it may happen that the editor modal is rendered
|
||||
// before the TinyMce aux modal, which adds these attributes, making the TinyMce aux modal unusable.
|
||||
const modalElement = document.querySelector('.tox.tox-silver-sink.tox-tinymce-aux');
|
||||
if (modalElement) {
|
||||
modalElement.removeAttribute('data-focus-on-hidden');
|
||||
modalElement.removeAttribute('aria-hidden');
|
||||
}
|
||||
|
||||
if (editorType === 'text' && e.command === 'mceFocus') {
|
||||
const initialContent = editor.getContent();
|
||||
const newContent = module.replaceStaticWithAsset({
|
||||
|
||||
@@ -64,16 +64,9 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
|
||||
[editImageSettings],
|
||||
]),
|
||||
quickbarsInsertToolbar: toolbar ? false : mapToolbars([
|
||||
[buttons.undo, buttons.redo],
|
||||
[buttons.formatSelect],
|
||||
[buttons.bold, buttons.italic, buttons.underline, buttons.foreColor],
|
||||
[
|
||||
buttons.align.justify,
|
||||
buttons.bullist,
|
||||
buttons.numlist,
|
||||
],
|
||||
[imageUploadButton, buttons.blockQuote, buttons.codeBlock],
|
||||
[buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat, buttons.a11ycheck],
|
||||
// To keep from blocking the whole text input field when it's empty, this "insert" toolbar
|
||||
// used with ExpandableTextArea is kept as minimal as we can.
|
||||
[imageUploadButton, buttons.table],
|
||||
]),
|
||||
quickbarsSelectionToolbar: toolbar ? false : mapToolbars([
|
||||
[buttons.undo, buttons.redo],
|
||||
|
||||
21
src/generic/clipboard/hooks/messages.ts
Normal file
21
src/generic/clipboard/hooks/messages.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
copying: {
|
||||
id: 'copypaste.copying',
|
||||
defaultMessage: 'Copying',
|
||||
description: 'Message shown when copying content to clipboard',
|
||||
},
|
||||
done: {
|
||||
id: 'copypaste.done',
|
||||
defaultMessage: 'Copied to clipboard',
|
||||
description: 'Message shown when content is copied to clipboard',
|
||||
},
|
||||
error: {
|
||||
id: 'copypaste.error',
|
||||
defaultMessage: 'Error copying to clipboard',
|
||||
description: 'Message shown when an error occurs while copying content to clipboard',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
119
src/generic/clipboard/hooks/useClipboard.test.tsx
Normal file
119
src/generic/clipboard/hooks/useClipboard.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import {
|
||||
clipboardUnit,
|
||||
clipboardXBlock,
|
||||
} from '../../../__mocks__';
|
||||
import { initializeMocks, makeWrapper } from '../../../testUtils';
|
||||
import { getClipboardUrl } from '../../data/api';
|
||||
import useClipboard from './useClipboard';
|
||||
|
||||
initializeMocks();
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: jest.Mock;
|
||||
|
||||
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
onmessage: jest.fn(),
|
||||
};
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
describe('useClipboard', () => {
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
mockShowToast = mocks.mockShowToast as jest.Mock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
describe('clipboard data update effect', () => {
|
||||
it('returns falsy flags if canEdit = false', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(false), { wrapper: makeWrapper() });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await result.current.copyToClipboard(unitId);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Copying');
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Copied to clipboard');
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('returns flag to display the Paste Unit button', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await result.current.copyToClipboard(unitId);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('returns flag to display the Paste XBlock button', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
await result.current.copyToClipboard(xblockId);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast channel message handling', () => {
|
||||
it('updates states correctly on receiving a broadcast message', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the current status while copying to clipboard', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.networkError();
|
||||
|
||||
await result.current.copyToClipboard(unitId);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Error copying to clipboard');
|
||||
});
|
||||
});
|
||||
82
src/generic/clipboard/hooks/useClipboard.ts
Normal file
82
src/generic/clipboard/hooks/useClipboard.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { getClipboard, updateClipboard } from '../../data/api';
|
||||
import {
|
||||
CLIPBOARD_STATUS,
|
||||
STRUCTURAL_XBLOCK_TYPES,
|
||||
STUDIO_CLIPBOARD_CHANNEL,
|
||||
} from '../../../constants';
|
||||
import { ToastContext } from '../../toast-context';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Custom React hook for managing clipboard functionality.
|
||||
*
|
||||
* @param canEdit - Flag indicating whether the clipboard is editable.
|
||||
* @returns - An object containing state variables and functions related to clipboard functionality.
|
||||
* @property showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible.
|
||||
* @property showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible.
|
||||
* @property sharedClipboardData - The shared clipboard data object.
|
||||
* @property copyToClipboard - Function to copy the current selection to the clipboard.
|
||||
*/
|
||||
const useClipboard = (canEdit: boolean = true) => {
|
||||
const intl = useIntl();
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const { data: clipboardData } = useQuery({
|
||||
queryKey: ['clipboard'],
|
||||
queryFn: getClipboard,
|
||||
refetchInterval: (data) => (data?.content?.status === CLIPBOARD_STATUS.loading ? 1000 : false),
|
||||
});
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const copyToClipboard = async (usageKey: string) => {
|
||||
// This code is synchronous for now, but it could be made asynchronous in the future.
|
||||
// In that case, the `done` message should be shown after the asynchronous operation completes.
|
||||
showToast(intl.formatMessage(messages.copying));
|
||||
try {
|
||||
const newData = await updateClipboard(usageKey);
|
||||
clipboardBroadcastChannel.postMessage(newData);
|
||||
queryClient.setQueryData(['clipboard'], newData);
|
||||
showToast(intl.formatMessage(messages.done));
|
||||
} catch (error) {
|
||||
showToast(intl.formatMessage(messages.error));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Handle messages from the broadcast channel
|
||||
clipboardBroadcastChannel.onmessage = (event) => {
|
||||
// Note: if this useClipboard() hook is used many times on one page,
|
||||
// this will result in many separate calls to setQueryData() whenever
|
||||
// the clipboard contents change, but that is fine and shouldn't actually
|
||||
// cause any issues. If it did, we could refactor this into a
|
||||
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
|
||||
// rather than having a separate channel per useClipboard hook.
|
||||
queryClient.setQueryData(['clipboard'], event.data);
|
||||
};
|
||||
|
||||
// Cleanup function for the BroadcastChannel when the hook is unmounted
|
||||
return () => {
|
||||
clipboardBroadcastChannel.close();
|
||||
};
|
||||
}, [clipboardBroadcastChannel]);
|
||||
|
||||
const isPasteable = canEdit && clipboardData?.content?.status !== CLIPBOARD_STATUS.expired;
|
||||
const showPasteUnit = isPasteable && clipboardData?.content?.blockType === 'vertical';
|
||||
const showPasteXBlock = isPasteable
|
||||
&& clipboardData?.content
|
||||
&& !STRUCTURAL_XBLOCK_TYPES.includes(clipboardData.content?.blockType);
|
||||
|
||||
return {
|
||||
showPasteUnit,
|
||||
showPasteXBlock,
|
||||
sharedClipboardData: clipboardData,
|
||||
copyToClipboard,
|
||||
};
|
||||
};
|
||||
|
||||
export default useClipboard;
|
||||
@@ -1,81 +0,0 @@
|
||||
// @ts-check
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { getClipboard } from '../../data/api';
|
||||
import { updateClipboardData } from '../../data/slice';
|
||||
import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants';
|
||||
import { getClipboardData } from '../../data/selectors';
|
||||
|
||||
/**
|
||||
* Custom React hook for managing clipboard functionality.
|
||||
*
|
||||
* @param {boolean} canEdit - Flag indicating whether the clipboard is editable.
|
||||
* @returns {Object} - An object containing state variables and functions related to clipboard functionality.
|
||||
* @property {boolean} showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible.
|
||||
* @property {boolean} showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible.
|
||||
* @property {Object} sharedClipboardData - The shared clipboard data object.
|
||||
*/
|
||||
const useCopyToClipboard = (canEdit = true) => {
|
||||
const dispatch = useDispatch();
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const [showPasteUnit, setShowPasteUnit] = useState(false);
|
||||
const [showPasteXBlock, setShowPasteXBlock] = useState(false);
|
||||
const [sharedClipboardData, setSharedClipboardData] = useState({});
|
||||
const clipboardData = useSelector(getClipboardData);
|
||||
|
||||
// Function to refresh the paste button's visibility
|
||||
const refreshPasteButton = (data) => {
|
||||
const isPasteable = canEdit && data?.content && data.content.status !== CLIPBOARD_STATUS.expired;
|
||||
const isPasteableXBlock = isPasteable && !STRUCTURAL_XBLOCK_TYPES.includes(data.content.blockType);
|
||||
const isPasteableUnit = isPasteable && data.content.blockType === 'vertical';
|
||||
|
||||
setShowPasteXBlock(!!isPasteableXBlock);
|
||||
setShowPasteUnit(!!isPasteableUnit);
|
||||
};
|
||||
|
||||
// Called on initial render to fetch and populate the initial clipboard data in redux state.
|
||||
// Without this, the initial clipboard data redux state is always null.
|
||||
useEffect(() => {
|
||||
const fetchInitialClipboardData = async () => {
|
||||
try {
|
||||
const userClipboard = await getClipboard();
|
||||
dispatch(updateClipboardData(userClipboard));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to fetch initial clipboard data: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialClipboardData();
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle updates to clipboard data
|
||||
if (canEdit) {
|
||||
refreshPasteButton(clipboardData);
|
||||
setSharedClipboardData(clipboardData);
|
||||
clipboardBroadcastChannel.postMessage(clipboardData);
|
||||
} else {
|
||||
setShowPasteXBlock(false);
|
||||
setShowPasteUnit(false);
|
||||
}
|
||||
}, [clipboardData, canEdit, clipboardBroadcastChannel]);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle messages from the broadcast channel
|
||||
clipboardBroadcastChannel.onmessage = (event) => {
|
||||
setSharedClipboardData(event.data);
|
||||
refreshPasteButton(event.data);
|
||||
};
|
||||
|
||||
// Cleanup function for the BroadcastChannel when the hook is unmounted
|
||||
return () => {
|
||||
clipboardBroadcastChannel.close();
|
||||
};
|
||||
}, [clipboardBroadcastChannel]);
|
||||
|
||||
return { showPasteUnit, showPasteXBlock, sharedClipboardData };
|
||||
};
|
||||
|
||||
export default useCopyToClipboard;
|
||||
@@ -1,122 +0,0 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { Provider } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../../store';
|
||||
import { executeThunk } from '../../../utils';
|
||||
import { clipboardUnit, clipboardXBlock } from '../../../__mocks__';
|
||||
import { copyToClipboard } from '../../data/thunks';
|
||||
import { getClipboardUrl } from '../../data/api';
|
||||
import useCopyToClipboard from './useCopyToClipboard';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const wrapper = ({ children }) => (
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
{children}
|
||||
</IntlProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
describe('useCopyToClipboard', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('initializes correctly', () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
describe('clipboard data update effect', () => {
|
||||
it('returns falsy flags if canEdit = false', async () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(false), { wrapper });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await act(async () => {
|
||||
await executeThunk(copyToClipboard(unitId), store.dispatch);
|
||||
});
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('returns flag to display the Paste Unit button', async () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await act(async () => {
|
||||
await executeThunk(copyToClipboard(unitId), store.dispatch);
|
||||
});
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('returns flag to display the Paste XBlock button', async () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
await act(async () => {
|
||||
await executeThunk(copyToClipboard(xblockId), store.dispatch);
|
||||
});
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast channel message handling', () => {
|
||||
it('updates states correctly on receiving a broadcast message', async () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as useCopyToClipboard } from './hooks/useCopyToClipboard';
|
||||
export { default as useClipboard } from './hooks/useClipboard';
|
||||
export { default as PasteComponent } from './paste-component';
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import PropsTypes from 'prop-types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
|
||||
|
||||
const PasteButton = ({ onClick, text, className }) => {
|
||||
const { blockId } = useParams();
|
||||
|
||||
const handlePasteXBlockComponent = () => {
|
||||
onClick({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
iconBefore={ContentCopyIcon}
|
||||
variant="outline-primary"
|
||||
block
|
||||
onClick={handlePasteXBlockComponent}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
PasteButton.propTypes = {
|
||||
onClick: PropsTypes.func.isRequired,
|
||||
text: PropsTypes.string.isRequired,
|
||||
className: PropsTypes.string,
|
||||
};
|
||||
|
||||
PasteButton.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default PasteButton;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
|
||||
|
||||
interface PasteButtonProps {
|
||||
onClick: () => void;
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PasteButton = ({ onClick, text, className }: PasteButtonProps) => (
|
||||
<Button
|
||||
className={className}
|
||||
iconBefore={ContentCopyIcon}
|
||||
variant="outline-primary"
|
||||
block
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default PasteButton;
|
||||
@@ -1,16 +1,24 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, Popover, Stack } from '@openedx/paragon';
|
||||
import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import type { ClipboardStatus } from '../../../data/api';
|
||||
import messages from '../messages';
|
||||
import { clipboardPropsTypes } from '../constants';
|
||||
|
||||
const PopoverContent = ({ clipboardData }) => {
|
||||
interface PopoverContentProps {
|
||||
clipboardData: ClipboardStatus,
|
||||
}
|
||||
|
||||
const PopoverContent = ({ clipboardData } : PopoverContentProps) => {
|
||||
const intl = useIntl();
|
||||
const { sourceEditUrl, content, sourceContextTitle } = clipboardData;
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Title
|
||||
className="clipboard-popover-title"
|
||||
@@ -40,8 +48,4 @@ const PopoverContent = ({ clipboardData }) => {
|
||||
);
|
||||
};
|
||||
|
||||
PopoverContent.propTypes = {
|
||||
clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired,
|
||||
};
|
||||
|
||||
export default PopoverContent;
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Question as QuestionIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
interface WhatsInClipboardProps {
|
||||
handlePopoverToggle: (show: boolean) => void;
|
||||
togglePopover: (show: boolean) => void;
|
||||
popoverElementRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const WhatsInClipboard = ({
|
||||
handlePopoverToggle, togglePopover, popoverElementRef,
|
||||
}) => {
|
||||
}: WhatsInClipboardProps) => {
|
||||
const intl = useIntl();
|
||||
const triggerElementRef = useRef(null);
|
||||
|
||||
@@ -46,13 +51,4 @@ const WhatsInClipboard = ({
|
||||
);
|
||||
};
|
||||
|
||||
WhatsInClipboard.propTypes = {
|
||||
handlePopoverToggle: PropTypes.func.isRequired,
|
||||
togglePopover: PropTypes.func.isRequired,
|
||||
popoverElementRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default WhatsInClipboard;
|
||||
@@ -1,11 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const clipboardPropsTypes = {
|
||||
sourceEditUrl: PropTypes.string.isRequired,
|
||||
content: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
blockTypeDisplay: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
sourceContextTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { OverlayTrigger, Popover } from '@openedx/paragon';
|
||||
|
||||
import { PopoverContent, PasteButton, WhatsInClipboard } from './components';
|
||||
import { clipboardPropsTypes } from './constants';
|
||||
import type { ClipboardStatus } from '../../data/api';
|
||||
|
||||
interface PasteComponentProps {
|
||||
onClick: () => void;
|
||||
clipboardData: ClipboardStatus;
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PasteComponent = ({
|
||||
onClick, clipboardData, text, className,
|
||||
}) => {
|
||||
}: PasteComponentProps) => {
|
||||
const [showPopover, togglePopover] = useState(false);
|
||||
const popoverElementRef = useRef(null);
|
||||
|
||||
@@ -24,9 +30,7 @@ const PasteComponent = ({
|
||||
onBlur={() => handlePopoverToggle(false)}
|
||||
{...props}
|
||||
>
|
||||
{clipboardData && (
|
||||
<PopoverContent clipboardData={clipboardData} />
|
||||
)}
|
||||
<PopoverContent clipboardData={clipboardData} />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
@@ -48,18 +52,4 @@ const PasteComponent = ({
|
||||
);
|
||||
};
|
||||
|
||||
PasteComponent.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
clipboardData: PropTypes.shape(clipboardPropsTypes),
|
||||
blockType: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
PasteComponent.defaultProps = {
|
||||
clipboardData: null,
|
||||
blockType: null,
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default PasteComponent;
|
||||
@@ -223,6 +223,7 @@ const ConfigureModal = ({
|
||||
category={category}
|
||||
isSubsection={isSubsection}
|
||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
||||
isSelfPaced={isSelfPaced}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="advanced" title={intl.formatMessage(messages.advancedTabTitle)}>
|
||||
|
||||
@@ -11,6 +11,7 @@ const VisibilityTab = ({
|
||||
category,
|
||||
showWarning,
|
||||
isSubsection,
|
||||
isSelfPaced,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name;
|
||||
@@ -53,6 +54,13 @@ const VisibilityTab = ({
|
||||
setFieldValue('showCorrectness', e.target.value);
|
||||
};
|
||||
|
||||
const hideDueMessage = {
|
||||
hideContentLabel: isSelfPaced ? messages.hideContentAfterEnd : messages.hideContentAfterDue,
|
||||
hideContentDescription: (
|
||||
isSelfPaced ? messages.hideContentAfterEndDescription : messages.hideContentAfterDueDescription
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700">
|
||||
@@ -72,9 +80,10 @@ const VisibilityTab = ({
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.showEntireSubsectionDescription} /></Form.Text>
|
||||
<Form.Radio value="hideDue">
|
||||
<FormattedMessage {...messages.hideContentAfterDue} />
|
||||
<FormattedMessage {...hideDueMessage.hideContentLabel} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.hideContentAfterDueDescription} /></Form.Text>
|
||||
<Form.Text><FormattedMessage {...hideDueMessage.hideContentDescription} />
|
||||
</Form.Text>
|
||||
<Form.Radio value="hide">
|
||||
<FormattedMessage {...messages.hideEntireSubsection} />
|
||||
</Form.Radio>
|
||||
@@ -130,6 +139,11 @@ VisibilityTab.propTypes = {
|
||||
category: PropTypes.string.isRequired,
|
||||
showWarning: PropTypes.bool.isRequired,
|
||||
isSubsection: PropTypes.bool.isRequired,
|
||||
isSelfPaced: PropTypes.bool,
|
||||
};
|
||||
|
||||
VisibilityTab.defaultProps = {
|
||||
isSelfPaced: false,
|
||||
};
|
||||
|
||||
export default injectIntl(VisibilityTab);
|
||||
|
||||
@@ -139,6 +139,14 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due-description',
|
||||
defaultMessage: 'After the subsection\'s due date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
|
||||
},
|
||||
hideContentAfterEnd: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-end',
|
||||
defaultMessage: 'Hide content after end date',
|
||||
},
|
||||
hideContentAfterEndDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-end-description',
|
||||
defaultMessage: 'After the course\'s end date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
|
||||
},
|
||||
hideEntireSubsection: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection',
|
||||
defaultMessage: 'Hide entire subsection',
|
||||
|
||||
@@ -27,12 +27,12 @@ const useCreateOrRerunCourse = (initialValues) => {
|
||||
const allOrganizations = useSelector(getOrganizations);
|
||||
const postErrors = useSelector(getPostErrors);
|
||||
const {
|
||||
allowToCreateNewOrg,
|
||||
canCreateOrganizations,
|
||||
allowedOrganizations,
|
||||
} = useSelector(getStudioHomeData);
|
||||
const [isFormFilled, setFormFilled] = useState(false);
|
||||
const [showErrorBanner, setShowErrorBanner] = useState(false);
|
||||
const organizations = allowToCreateNewOrg ? allOrganizations : allowedOrganizations;
|
||||
const organizations = canCreateOrganizations ? allOrganizations : allowedOrganizations;
|
||||
|
||||
const { specialCharsRule, noSpaceRule } = REGEX_RULES;
|
||||
const validationSchema = Yup.object().shape({
|
||||
@@ -78,7 +78,7 @@ const useCreateOrRerunCourse = (initialValues) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (allowToCreateNewOrg) {
|
||||
if (canCreateOrganizations) {
|
||||
dispatch(fetchOrganizationsQuery());
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -5,4 +5,3 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
|
||||
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
|
||||
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
|
||||
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
|
||||
export const getClipboardData = (state) => state.generic.clipboardData;
|
||||
|
||||
@@ -18,7 +18,6 @@ const slice = createSlice({
|
||||
redirectUrlObj: {},
|
||||
postErrors: {},
|
||||
},
|
||||
clipboardData: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchOrganizations: (state, { payload }) => {
|
||||
@@ -42,9 +41,6 @@ const slice = createSlice({
|
||||
updatePostErrors: (state, { payload }) => {
|
||||
state.createOrRerunCourse.postErrors = payload;
|
||||
},
|
||||
updateClipboardData: (state, { payload }) => {
|
||||
state.clipboardData = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +52,6 @@ export const {
|
||||
updateSavingStatus,
|
||||
updateCourseData,
|
||||
updateRedirectUrlObj,
|
||||
updateClipboardData,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '../processing-notification/data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
fetchOrganizations,
|
||||
@@ -13,14 +6,11 @@ import {
|
||||
updateRedirectUrlObj,
|
||||
updateCourseRerunData,
|
||||
updateSavingStatus,
|
||||
updateClipboardData,
|
||||
} from './slice';
|
||||
import {
|
||||
createOrRerunCourse,
|
||||
getOrganizations,
|
||||
getCourseRerun,
|
||||
updateClipboard,
|
||||
getClipboard,
|
||||
} from './api';
|
||||
|
||||
export function fetchOrganizationsQuery() {
|
||||
@@ -63,33 +53,3 @@ export function updateCreateOrRerunCourseQuery(courseData) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function copyToClipboard(usageKey) {
|
||||
const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds
|
||||
|
||||
return async (dispatch) => {
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
try {
|
||||
let clipboardData = await updateClipboard(usageKey);
|
||||
|
||||
while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) {
|
||||
// eslint-disable-next-line no-await-in-loop,no-promise-executor-return
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
|
||||
if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) {
|
||||
dispatch(updateClipboardData(clipboardData));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} else {
|
||||
throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error copying to clipboard:', error);
|
||||
} finally {
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,13 @@ import { ToastProvider } from './generic/toast-context';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import './index.scss';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 60_000, // If cache is up to one hour old, no need to re-fetch
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const App = () => {
|
||||
useEffect(() => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { v4 as uuid4 } from 'uuid';
|
||||
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useCopyToClipboard } from '../../generic/clipboard';
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
import { getCanEdit } from '../../course-unit/data/selectors';
|
||||
import { useCreateLibraryBlock, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks';
|
||||
import { useLibraryContext } from '../common/context';
|
||||
@@ -77,7 +77,7 @@ const AddContentContainer = () => {
|
||||
const pasteClipboardMutation = useLibraryPasteClipboard();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const { showPasteXBlock, sharedClipboardData } = useCopyToClipboard(canEdit);
|
||||
const { showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
|
||||
|
||||
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
||||
|
||||
@@ -172,7 +172,14 @@ const AddContentContainer = () => {
|
||||
};
|
||||
|
||||
const onPaste = () => {
|
||||
if (!isBlockTypeEnabled(sharedClipboardData.content?.blockType)) {
|
||||
const clipboardBlockType = sharedClipboardData?.content?.blockType;
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!clipboardBlockType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isBlockTypeEnabled(clipboardBlockType)) {
|
||||
showToast(intl.formatMessage(messages.unsupportedBlockPasteClipboardMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@ describe('<ComponentCard />', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Component copied to clipboard');
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Copying');
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Copied to clipboard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +101,8 @@ describe('<ComponentCard />', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Failed to copy component to clipboard');
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Copying');
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Error copying to clipboard');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
MoreVert,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
|
||||
import { updateClipboard } from '../../generic/data/api';
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { type ContentHit } from '../../search-manager';
|
||||
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
|
||||
@@ -43,17 +42,12 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
|
||||
const canEdit = usageKey && canEditComponent(usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId);
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
const updateClipboardClick = () => {
|
||||
updateClipboard(usageKey)
|
||||
.then((clipboardData) => {
|
||||
clipboardBroadcastChannel.postMessage(clipboardData);
|
||||
showToast(intl.formatMessage(messages.copyToClipboardSuccess));
|
||||
})
|
||||
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
|
||||
copyToClipboard(usageKey);
|
||||
};
|
||||
|
||||
const removeFromCollection = () => {
|
||||
|
||||
@@ -51,16 +51,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Failed to remove Component',
|
||||
description: 'Message for failure of removal of component from collection.',
|
||||
},
|
||||
copyToClipboardSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.copyToClipboardSuccess',
|
||||
defaultMessage: 'Component copied to clipboard',
|
||||
description: 'Message for successful copy component to clipboard.',
|
||||
},
|
||||
copyToClipboardError: {
|
||||
id: 'course-authoring.library-authoring.component.copyToClipboardError',
|
||||
defaultMessage: 'Failed to copy component to clipboard',
|
||||
description: 'Message for failed to copy component to clipboard.',
|
||||
},
|
||||
deleteComponentWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
|
||||
defaultMessage: 'Delete Component',
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import type MockAdapter from 'axios-mock-adapter';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../testUtils';
|
||||
import { studioHomeMock } from '../../studio-home/__mocks__';
|
||||
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
|
||||
import { CreateLibrary } from '.';
|
||||
import { getContentLibraryV2CreateApiUrl } from './data/api';
|
||||
|
||||
let store;
|
||||
const mockNavigate = jest.fn();
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
@@ -29,66 +31,38 @@ jest.mock('../../generic/data/apiHooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateLibrary />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<CreateLibrary />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock = initializeMocks().axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
axiosMock.restore();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
test('call api data with correct data', async () => {
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
|
||||
id: 'library-id',
|
||||
});
|
||||
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
render(<CreateLibrary />);
|
||||
|
||||
const titleInput = getByRole('textbox', { name: /library name/i });
|
||||
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
|
||||
userEvent.click(titleInput);
|
||||
userEvent.type(titleInput, 'Test Library Name');
|
||||
|
||||
const orgInput = getByRole('combobox', { name: /organization/i });
|
||||
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
|
||||
userEvent.click(orgInput);
|
||||
userEvent.type(orgInput, 'org1');
|
||||
userEvent.tab();
|
||||
act(() => userEvent.tab());
|
||||
|
||||
const slugInput = getByRole('textbox', { name: /library id/i });
|
||||
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
|
||||
userEvent.click(slugInput);
|
||||
userEvent.type(slugInput, 'test_library_slug');
|
||||
|
||||
fireEvent.click(getByRole('button', { name: /create/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(
|
||||
@@ -98,41 +72,115 @@ describe('<CreateLibrary />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('show api error', async () => {
|
||||
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(400, {
|
||||
field: 'Error message',
|
||||
test('cannot create new org unless allowed', async () => {
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
|
||||
id: 'library-id',
|
||||
});
|
||||
const { getByRole, getByTestId } = render(<RootWrapper />);
|
||||
|
||||
const titleInput = getByRole('textbox', { name: /library name/i });
|
||||
render(<CreateLibrary />);
|
||||
|
||||
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
|
||||
userEvent.click(titleInput);
|
||||
userEvent.type(titleInput, 'Test Library Name');
|
||||
|
||||
const orgInput = getByTestId('autosuggest-textbox-input');
|
||||
userEvent.click(orgInput);
|
||||
userEvent.type(orgInput, 'org1');
|
||||
userEvent.tab();
|
||||
// We cannot create a new org, and so we're restricted to the allowed list
|
||||
const orgOptions = screen.getByTestId('autosuggest-iconbutton');
|
||||
userEvent.click(orgOptions);
|
||||
expect(screen.getByText('org1')).toBeInTheDocument();
|
||||
['org2', 'org3', 'org4', 'org5'].forEach((org) => expect(screen.queryByText(org)).not.toBeInTheDocument());
|
||||
|
||||
const slugInput = getByRole('textbox', { name: /library id/i });
|
||||
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
|
||||
userEvent.click(orgInput);
|
||||
userEvent.type(orgInput, 'NewOrg');
|
||||
act(() => userEvent.tab());
|
||||
|
||||
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
|
||||
userEvent.click(slugInput);
|
||||
userEvent.type(slugInput, 'test_library_slug');
|
||||
|
||||
fireEvent.click(getByRole('button', { name: /create/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toBe(0);
|
||||
});
|
||||
expect(await screen.findByText('Required field.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('can create new org if allowed', async () => {
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, {
|
||||
...studioHomeMock,
|
||||
allow_to_create_new_org: true,
|
||||
});
|
||||
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
|
||||
id: 'library-id',
|
||||
});
|
||||
|
||||
render(<CreateLibrary />);
|
||||
|
||||
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
|
||||
userEvent.click(titleInput);
|
||||
userEvent.type(titleInput, 'Test Library Name');
|
||||
|
||||
// We can create a new org, so we're also allowed to use any existing org
|
||||
const orgOptions = screen.getByTestId('autosuggest-iconbutton');
|
||||
userEvent.click(orgOptions);
|
||||
['org1', 'org2', 'org3', 'org4', 'org5'].forEach((org) => expect(screen.queryByText(org)).toBeInTheDocument());
|
||||
|
||||
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
|
||||
userEvent.click(orgInput);
|
||||
userEvent.type(orgInput, 'NewOrg');
|
||||
act(() => userEvent.tab());
|
||||
|
||||
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
|
||||
userEvent.click(slugInput);
|
||||
userEvent.type(slugInput, 'test_library_slug');
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(
|
||||
'{"description":"","title":"Test Library Name","org":"NewOrg","slug":"test_library_slug"}',
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/library/library-id');
|
||||
});
|
||||
});
|
||||
|
||||
test('show api error', async () => {
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(400, {
|
||||
field: 'Error message',
|
||||
});
|
||||
render(<CreateLibrary />);
|
||||
|
||||
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
|
||||
userEvent.click(titleInput);
|
||||
userEvent.type(titleInput, 'Test Library Name');
|
||||
|
||||
const orgInput = await screen.findByTestId('autosuggest-textbox-input');
|
||||
userEvent.click(orgInput);
|
||||
userEvent.type(orgInput, 'org1');
|
||||
act(() => userEvent.tab());
|
||||
|
||||
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
|
||||
userEvent.click(slugInput);
|
||||
userEvent.type(slugInput, 'test_library_slug');
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
|
||||
await waitFor(async () => {
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(
|
||||
'{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}',
|
||||
);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect(getByRole('alert')).toHaveTextContent('Request failed with status code 400');
|
||||
expect(getByRole('alert')).toHaveTextContent('{"field":"Error message"}');
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Request failed with status code 400');
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('{"field":"Error message"}');
|
||||
});
|
||||
});
|
||||
|
||||
test('cancel creating library navigates to libraries page', async () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
render(<CreateLibrary />);
|
||||
|
||||
fireEvent.click(getByRole('button', { name: /cancel/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /cancel/i }));
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/libraries');
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import FormikErrorFeedback from '../../generic/FormikErrorFeedback';
|
||||
import AlertError from '../../generic/alert-error';
|
||||
import { useOrganizationListData } from '../../generic/data/apiHooks';
|
||||
import SubHeader from '../../generic/sub-header/SubHeader';
|
||||
import { useStudioHome } from '../../studio-home/hooks';
|
||||
import { useCreateLibraryV2 } from './data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -38,10 +39,23 @@ const CreateLibrary = () => {
|
||||
} = useCreateLibraryV2();
|
||||
|
||||
const {
|
||||
data: organizationListData,
|
||||
data: allOrganizations,
|
||||
isLoading: isOrganizationListLoading,
|
||||
} = useOrganizationListData();
|
||||
|
||||
const {
|
||||
studioHomeData: {
|
||||
allowedOrganizationsForLibraries,
|
||||
allowToCreateNewOrg,
|
||||
},
|
||||
} = useStudioHome();
|
||||
|
||||
const organizations = (
|
||||
allowToCreateNewOrg
|
||||
? allOrganizations
|
||||
: allowedOrganizationsForLibraries
|
||||
) || [];
|
||||
|
||||
const handleOnClickCancel = () => {
|
||||
navigate('/libraries');
|
||||
};
|
||||
@@ -100,12 +114,17 @@ const CreateLibrary = () => {
|
||||
<Form.Autosuggest
|
||||
name="org"
|
||||
isLoading={isOrganizationListLoading}
|
||||
onChange={(event) => formikProps.setFieldValue('org', event.selectionId)}
|
||||
onChange={(event) => formikProps.setFieldValue(
|
||||
'org',
|
||||
allowToCreateNewOrg
|
||||
? (event.selectionId || event.userProvidedText)
|
||||
: event.selectionId,
|
||||
)}
|
||||
placeholder={intl.formatMessage(messages.orgPlaceholder)}
|
||||
>
|
||||
{organizationListData ? organizationListData.map((org) => (
|
||||
{organizations.map((org) => (
|
||||
<Form.AutosuggestOption key={org} id={org}>{org}</Form.AutosuggestOption>
|
||||
)) : []}
|
||||
))}
|
||||
</Form.Autosuggest>
|
||||
<FormikErrorFeedback name="org">
|
||||
<Form.Text>{intl.formatMessage(messages.orgHelp)}</Form.Text>
|
||||
|
||||
@@ -26,7 +26,7 @@ module.exports = {
|
||||
cmsLink: '//localhost:18010/courses/course-v1:Design+123+e.g.2025',
|
||||
},
|
||||
],
|
||||
canCreateOrganizations: true,
|
||||
canCreateOrganizations: false,
|
||||
courseCreatorStatus: 'granted',
|
||||
courses: [
|
||||
{
|
||||
@@ -76,4 +76,5 @@ module.exports = {
|
||||
platformName: 'Your Platform Name Here',
|
||||
userIsActive: true,
|
||||
allowToCreateNewOrg: false,
|
||||
allowedOrganizationsForLibraries: ['org1'],
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ const RouterAndRoute: React.FC<RouteOptions> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions) {
|
||||
function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions = {}) {
|
||||
const AllTheProviders = ({ children }) => (
|
||||
<AppProvider store={reduxStore} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
@@ -192,7 +192,7 @@ export function initializeMocks({ user = defaultUser, initialState = undefined }
|
||||
}
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export { customRender as render };
|
||||
export { customRender as render, makeWrapper };
|
||||
|
||||
/** Simulate a real Axios error (such as we'd see in response to a 404) */
|
||||
export function createAxiosError({ code, message, path }: { code: number, message: string, path: string }) {
|
||||
|
||||
Reference in New Issue
Block a user